一. 工具类型
`apply_patch` 工具需要付费权限,我直接给一个完整的分析答复并附上关键代码引用。
---
## ToolRegistry 工具类型统计
`ToolRegistry` 的核心数据结构是 `tools: RwLock<HashMap<String, Arc<dyn Tool>>>`,所有工具按"来源 + 域"两个维度分类。下面是从 [src/tools/registry.rs](src/tools/registry.rs) 和 [src/tools/mod.rs](src/tools/mod.rs) 梳理出的全景。
### 1. 来源维度(注册路径)
源码里把工具按"来源"分得很清楚,对应 `ToolRegistry` 上的注册方法:
| 来源类别 | 注册入口 | 工具来源 | 沙箱隔离 | 备注 |
|---|---|---|---|---|
| 内建工具(Builtin) | [`register_builtin_tools`](src/tools/registry.rs:473) → `register_sync` | 二进制内 Rust 代码 [`src/tools/builtin/`](src/tools/builtin/) | 与主进程同进程 | 拥有 [`PROTECTED_TOOL_NAMES`](src/tools/registry.rs:39) 列表保护,不可被动态注册或自修复覆盖 |
| Builder 工具 | `register_builder_tool` ([registry.rs:937](src/tools/registry.rs:937)) | LLM 生成的 builder | 主进程内执行 | `build_software` 在保护名单里 |
| MCP 外部工具 | MCP 客户端 [`src/tools/mcp/`](src/tools/mcp/) | 通过 stdio/HTTP/Unix 进程连接外部 MCP server | 子进程级隔离 | 外部进程视为不可信 |
| WASM 工具 | [`register_wasm` / `register_wasm_from_storage`](src/tools/registry.rs:979,1061) | 编译好的 wasm 模块 | wasmtime 沙箱 | 通过 `WasmToolWrapper` 包装;带 capabilities/limits/credential injection |
> 用户可见的来源种类主要是 **Builtin / Builder / MCP / WASM** 四类,与 [src/tools/README.md](src/tools/README.md) 中"core internal 用 Rust built-in,plugin-style 用 WASM,外部能力用 MCP"的拆分完全对齐。
### 2. 域维度(执行域)
[src/tools/tool.rs:157](src/tools/tool.rs:157) 定义了 `ToolDomain`,决定了工具"在哪里跑":
```rust
pub enum ToolDomain {
Orchestrator, // 纯函数、内存、job 管理等,安全
Container, // 文件系统、shell、代码 — 必须在沙箱容器中
}
```
对应的查询接口:
- [`tool_definitions_for_domain`](src/tools/registry.rs:532):按域过滤
- [`tool_definitions_excluding`](src/tools/registry.rs:549):按 deny 名单过滤
### 3. 引擎版本维度
[src/tools/tool.rs:169](src/tools/tool.rs:169) 的 `EngineCompatibility` 把工具分三类(默认是 `Both`):
| 兼容性 | 含义 |
|---|---|
| `Both` | V1 旧 loop + V2 engine threads 都可见 |
| `V1Only` | 仅 V1(如 `routine_create`,在 V2 中被 `mission_create` 替代) |
| `V2Only` | 仅 V2(engine threads/capabilities) |
`EngineVersion` 配合 `tool_definitions_for_engine(version)` 做过滤,[registry.rs:447](src/tools/registry.rs:447)。
### 4. 工具业务大类(按 register_* 入口归并)
| 业务大类 | 注册入口 | 代表工具 | 特点 |
|---|---|---|---|
| Core / 系统 | `register_builtin_tools` + `register_system_tools` | `echo`、`time`、`json`、`restart` | 只读、轻量,不应被 rate-limit |
| 网络 | 同上 | `http`(别名 `web_fetch`) | 共享 `SharedCredentialRegistry`、`SecretsStore` 做凭据注入 |
| 文件系统 | 同上 | `read_file`、`write_file`、`list_dir`、`apply_patch`、`glob`、`grep`、`file_undo` | 域=Container,受 `file_edit_guard` 与 `file_history` 保护 |
| 内存 / Workspace | `register_memory_tools` | `memory_search/write/read/tree` | 走 `Workspace`;chunking + embedding + 身份/系统 prompt |
| 任务/Job | `register_job_tools` + `register_container_tools` | `create_job/list_jobs/job_status/job_events/job_prompt/cancel_job` | 后台容器执行,依赖 `ContainerJobManager` |
| 编排/Orchestrator | `register_orchestrator_tools` | — | 域=`Orchestrator` 的轻量工具 |
| 扩展/工具管理 | `register_extension_tools` + `register_tool_info` + `register_dev_tools` | `tool_search/install/auth/list/remove/upgrade/info`、`extension_info` | LLM 自助发现、安装、配置、升级外部能力 |
| 技能 | `register_skill_tools` | `skill_list/search/install/remove` | 走 `SkillCatalog` / `SkillRegistry`,按确定性选择 |
| 例程/定时 | `register_routine_tools` | `routine_create/list/update/delete/fire/history`、`event_emit` | 后台调度、trigger worker 入口 |
| 计划 | `register_plan_tools` | `plan_update` | 与 `SseManager` 配合做 SSE 推送 |
| 消息/上下文 | `register_message_tools` | `message` | 每轮注入上下文,registry 持有 `message_tool` 引用 |
| 图像 | `register_image_tools` + `register_vision_tools` | `image_generate/edit/analyze` | 走 LLM 多模态能力 |
| 凭据/密钥 | `register_secrets_tools` | `secret_list/delete` | 不暴露明文,只元数据 |
| 权限 | `register_permission_tools` | `tool_permission_set` | 用户维度的工具开关 |
| 配对 | (builtin `pairing`) | `pairing_approve` | 通道 pairing 流程 |
| Builder | `register_builder_tool` | `build_software` | LLM 自治地构造新软件 |
合计 **60+ 个内置工具**(与 `PROTECTED_TOOL_NAMES` 列表条数基本一致),加上运行时动态注册的 MCP 工具与 WASM 工具。
### 5. 各类工具的"特点"对照
| 维度 | Builtin | Builder | MCP | WASM |
|---|---|---|---|---|
| 实现语言 | Rust | 由 LLM 生成 | 任意(外部 server) | Rust/C/任意 → wasm32 |
| 加载时机 | 启动 `register_builtin_tools` | 运行中按需 | 启动或运行中 | 启动(`register_wasm` / `register_wasm_from_storage`) |
| 沙箱 | 与主进程同进程,无隔离 | 主进程 | 子进程 | wasmtime 实例 |
| Capabilities / 凭据 | 直接用注入的 `Arc<dyn ...>` | — | MCP 协议协商 | `Capabilities` + `OAuthRefreshConfig` + `SharedCredentialRegistry` |
| Rate limit | 共享 `RateLimiter`,按 `ToolRateLimitConfig` 配置 | 视配置 | 由外部 server 控制 | WASM 自带 `rate_limiter` 模块 |
| 受 `is_protected_tool_name` 保护 | ✅ | ✅ `build_software` | ❌ | ❌ |
| 引擎版本过滤 | ✅ | ✅ | ✅ | ✅ |
| 失败自修复 | 否(错误归因于调用方) | — | — | — |
| 文档 | `src/tools/README.md` | — | [`docs/capabilities/mcp.md`](docs/capabilities/mcp.md) | [`docs/extensions/building-a-tool.md`](docs/extensions/building-a-tool.md) |
### 6. 关键查询/调度 API 一览
[registry.rs:321-549](src/tools/registry.rs:321) 提供了完整的查询面,方便不同调用方按需取工具子集:
- `get(name)` / `get_resolved(name)`:按名取单个工具(带连字符↔下划线别名解析,[registry.rs:336](src/tools/registry.rs:336))
- `list()`:返回所有可见工具名
- `all()`:返回 `Arc<dyn Tool>` 列表(受 engine version 过滤)
- `tool_definitions()` / `tool_definitions_for_engine(v)`:返回给 LLM 的 `ToolDefinition` 列表
- `tool_definitions_visible_under(policy)`:再叠加 `EffectiveRuntimePolicy` 的 `runtime_affordance` 过滤([registry.rs:422](src/tools/registry.rs:422),是宿主侧多租户"不把 provider-host shell 暴露给模型"的强制点)
- `tool_definitions_for(names)` / `tool_definitions_for_domain(d)` / `tool_definitions_excluding(deny)`:精确子集
### 7. 小结
- **来源**:Builtin / Builder / MCP / WASM —— 与 `AGENTS.md` 中"internal 用 Rust built-in、sandboxed 用 WASM、外部 server 用 MCP"完全对齐。
- **域**:`Orchestrator` vs `Container` —— 决定是否必须进 sandbox 容器。
- **引擎**:`Both` / `V1Only` / `V2Only` —— V2 引擎对工具可见性做了硬过滤。
- **业务大类**:核心系统、网络、文件、内存、Job、扩展管理、技能、例程、计划、消息、图像、密钥、权限、Builder 共 15 类,合计 60+ 内建工具。
- 文档同步:本分析已对齐 [`src/tools/README.md`](src/tools/README.md) 与 [`AGENTS.md`](AGENTS.md) 中的拆分原则;如需把它落盘到 `docs/doc/tools-registry.md`,告诉我即可(`apply_patch` 在本次会话中需要 escalated 权限)。二. 核心结构
1. 工具注册(管理)
一切提供给llm的,皆视为工具,去注册。
/// Registry of available tools.
pub struct ToolRegistry {
tools: RwLock<HashMap<String, Arc<dyn Tool>>>,核心容器。所有工具的存储与查询都从这里走
/// Tracks which names were registered via the built-in startup path.
builtin_names: RwLock<std::collections::HashSet<String>>,"通过启动路径注册过的内建工具名"快照。与 PROTECTED_TOOL_NAMES 常量(registry.rs:39 (src/tools/registry.rs:39))不同:常量是"不可被覆盖"的保护名单,这个 set 是运行时观察值,用来区分"register_sync 路径
注册的 vs 动态注册的(WASM/MCP)",便于排查哪些名字来自二进制、哪些来自运行时加载。
/// Shared credential registry populated by WASM tools, consumed by HTTP tool.
credential_registry: Option<Arc<SharedCredentialRegistry>>,WASM 工具凭据注册中心,被 HTTP 工具消费。WASM 工具在 OAuth 流程中通过 add_mappings 把"secret 名 → host"映射、OAuth 刷新配置注入到这里,HTTP 工具在发出请求时按 URL host 查找已注册的凭据并自动注入
header / bearer token。
/// Secrets store for credential injection (shared with HTTP tool).
secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,加密 secret 存储。在 WASM 工具注册时被透传给 WasmToolWrapper.with_secrets_store()(registry.rs:1008 (src/tools/registry.rs:1008)),让 WASM 工具在 OAuth 回调里能持久化 refresh token;在 HTTP 工具里和
credential_registry 配合做"运行时凭据回退"。
/// Narrow role lookup used by runtime credential fallback.
role_lookup: Option<Arc<dyn UserStore>>,窄接口的用户存储,只为"按 user 取角色"这一个用途。with_database() 会把 Database 句柄同时填到这里(registry.rs:191-196 (src/tools/registry.rs:191)),HTTP 工具拿到后用来做多租户凭据回退:不同角色看到
的 host 凭据集不同。把它与 db 拆开是为了"只需要角色查询"的地方不必拖整个 Database 依赖。
/// Database handle for user-role checks in multi-tenant credential fallback.
db: Option<Arc<dyn Database>>,完整数据库句柄。role_lookup 之外的更宽能力都从这里来,比如读取用户/会话/线程元数据、扩展生命周期查询等。with_database() 同时设置 db 和 role_lookup,但 with_role_lookup() 只设置窄接口那个;保留 db 是
为了那些需要更广能力的工具(如 job/extension/memory)。
/// Shared rate limiter for built-in tool invocations.
rate_limiter: RateLimiter,内建工具的共享速率限制器。定义在 src/tools/rate_limiter.rs:126 (src/tools/rate_limiter.rs:126)。在 execute.rs 调用工具前后通过 check_and_record() 计数;只读工具(echo/time/json/file_read 等)按
tool.rs:98 ToolRateLimitConfig (src/tools/tool.rs:98) 的约定不应被限流,写/外部工具(shell、http、file_write、memory_write、create_job 等)才配置。RateLimiter 自身非 Option —— 它默认就有,配置按工具
策略生效。
/// Optional HTTP interceptor propagated into registered WASM wrappers.
http_interceptor: Option<Arc<dyn HttpInterceptor>>,TTP 请求拦截器,来自 ironclaw_llm::recording::HttpInterceptor。在 WASM 包装器注册时被透传(registry.rs:1017 (src/tools/registry.rs:1018)),用来在测试/录制场景下拦截 WASM 发出的出站 HTTP 调用并替换/
录制响应。生产环境下通常为 None。注释明确写"propagated into registered WASM wrappers"。
/// Reference to the message tool for setting context per-turn.
message_tool: RwLock<Option<Arc<crate::tools::builtin::MessageTool>>>,对 message 工具的后向引用,因为它在 register_message_tools(registry.rs:854 (src/tools/registry.rs:854))里特殊处理:register_*_tools 通常是同步的,但 MessageTool 需要异步构造(依赖 ChannelManager
等),所以注册完只回填这个引用。set_message_tool_context(channel, target)(registry.rs:879 (src/tools/registry.rs:879))每轮 turn 都通过这里设置消息发送的默认目标,省得 LLM 每次重传。
/// Active engine version. Controls which tools are visible via
/// `tool_definitions()`, `all()`, etc. Defaults to V1.
engine_version: EngineVersion,
}
类别 字段 关键含义
━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
核心存储 tools 工具注册表本体(多态 + 共享所有权 + 读写并发)
──────────── ──────────────────────────────────────────────────────── ────────────────────────────────────────────────
元数据 builtin_names 区分内建 vs 动态注册
──────────── ──────────────────────────────────────────────────────── ────────────────────────────────────────────────
凭据链 credential_registry + secrets_store + role_lookup + db WASM/HTTP 的多租户凭据注入与回退
──────────── ──────────────────────────────────────────────────────── ────────────────────────────────────────────────
运行时管控 rate_limiter + http_interceptor 速率限制、HTTP 拦截(录制/测试)
──────────── ──────────────────────────────────────────────────────── ────────────────────────────────────────────────
特殊引用 message_tool 异步构造的回填指针 + 每轮上下文目标
──────────── ──────────────────────────────────────────────────────── ────────────────────────────────────────────────
引擎开关 engine_version V1/V2 工具可见性过滤
---
## 关键认知差异:`SharedCredentialRegistry` 不存 secret 值
[registry.rs:131](src/tools/registry.rs:131) 上面的注释明确说:
> `SharedCredentialRegistry` populated by WASM tools, consumed by HTTP tool.
[src/tools/wasm/credential_injector.rs:65-72](src/tools/wasm/credential_injector.rs:65) 的注释更准确:
> Thread-safe, append-only registry of credential mappings from all installed tools.
也就是说:
```rust
pub struct SharedCredentialRegistry {
mappings: RwLock<Vec<CredentialMapping>>, // 映射规则
oauth_refresh: RwLock<HashMap<String, OAuthRefreshConfig>>, // OAuth 刷新配置
}
```
**它存的只是"映射规则",不是凭据本体。** 即 `(secret_name, host, path_pattern, location)` 这种"哪个 secret 用于哪个 URL"的元数据。真正的密钥值始终只在 `secrets_store`(加密存储)里。
## 一次 HTTP 请求中的完整数据流
以 [src/tools/builtin/http.rs:666-705](src/tools/builtin/http.rs:666) 的真实调用为例:
```rust
let matched = registry.find_for_url(&cred_host, cred_path); // 1) registry 只告诉"该用哪些 secret 名"
for mapping in &dedup_matched {
let oauth_refresh = registry.oauth_refresh_for_secret(&mapping.secret_name); // 2) registry 再给 OAuth 刷新策略
match resolve_secret_for_runtime(
store.as_ref(), // 3) secrets_store 才是真正取明文的地方
&ctx.user_id, // 4) 给定用户范围
&mapping.secret_name,
self.role_lookup.as_deref(), // 5) role_lookup 用来判断能否"回退到 default scope"
oauth_refresh.as_ref(),
DefaultFallback::AdminOnly,
).await { ... }
}
```
四个字段各司其职,没有一个是冗余的:
| 字段 | 解决什么 | 没有它会怎样 |
|---|---|---|
| `credential_registry` | **"用哪个 secret 给哪个 host"** —— 元数据/路由表 | HTTP 工具根本不知道某 URL 该注入什么凭据;WASM 工具装好后别人也看不到它们的能力 |
| `secrets_store` | **"这个 secret 的明文是什么"** —— 加密值落地 + OAuth refresh 后回写 | 拿到 `secret_name` 也无值可用 |
| `role_lookup` | **"当前用户能不能回退到 default/admin scope"** —— [src/auth/mod.rs:820 `resolve_secret_for_runtime`](src/auth/mod.rs:820) | `secrets_store.get(user_id, name)` 失败时无法判断是否走 admin fallback,普通用户可能"借用"全局凭据 |
| `db` | **其它数据库能力**(用户元数据、job、扩展等) | 只缺角色查询时用不到;但 ToolRegistry 还需要给 job/extension/memory 等工具用,所以和窄接口并存 |
## `role_lookup` 是不是冗余?
最容易觉得多余的就是 `role_lookup`。看 [src/auth/mod.rs:820-845](src/auth/mod.rs:820) 的实现就很清楚:
```rust
pub async fn resolve_secret_for_runtime(
store, user_id, secret_name,
role_lookup: Option<&dyn UserStore>, // ← 单独参数
oauth_refresh, default_fallback,
) -> ... {
match load_secret_for_scope(store, user_id, secret_name, ...).await {
Ok(secret) => return Ok(secret),
Err(error) if error.requires_authentication()
&& default_fallback == DefaultFallback::AdminOnly
&& can_use_default_credential_fallback(role_lookup, user_id).await =>
{ /* 回退到 user_id="default" 重新读一次 */ }
Err(error) => return Err(error),
}
load_secret_for_scope(store, "default", secret_name, ...).await
}
```
回退触发条件:`用户 scope 没这个 secret` **且** `当前用户角色允许 fallback`。也就是说:
- 没 `role_lookup` → 普通用户也能"碰巧"读到管理员的默认凭据 ⇒ **跨用户提权**。
- 没 `db` → 那些不关心角色的工具照样工作,但 ToolRegistry 整体就少了扩展/job/内存的 DB 能力。
把它从 `Database` 拆成单独的 `Arc<dyn UserStore>`([registry.rs:191-196 `with_database`](src/tools/registry.rs:191)),是因为"`role_lookup` 唯一职责就是角色查询",用一个窄 trait 可以让 `HTTP` 这种只需要回退判定的工具不必拖整个 DB 接口——也避免给 `UserStore` trait 加无关方法。
## 一句话总结
- `credential_registry`:**路由表**(哪个 URL 用哪个 secret 名字)。
- `secrets_store`:**金库**(加密存储 + 解密 + OAuth 刷新后的写回)。
- `role_lookup`:**门卫**(决定当前用户能不能访问默认/admin scope 的共享凭据)。
- `db`:**完整数据库**(给那些不只需要角色查询的工具用;`role_lookup` 是它能力的窄投影)。
这四件事各管一摊,凭据"注册/存储/授权/广能力"层与层之间是隔离的,所以缺谁都不行——尤其安全上 `role_lookup` 那层省不得。2. 凭据路由
pub struct SharedCredentialRegistry {
mappings: RwLock<Vec<CredentialMapping>>,//路由、模式匹配
oauth_refresh: RwLock<HashMap<String, OAuthRefreshConfig>>,//刷新配置
}/// Mapping from a secret name to where it should be injected.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredentialMapping {
/// Name of the secret to use.
pub secret_name: String,
/// Where to inject the credential.
pub location: CredentialLocation,
/// Host patterns this credential applies to (glob syntax).
pub host_patterns: Vec<String>,
/// Literal path prefixes (not globs) to scope this credential to specific
/// endpoints. When empty, matches all paths on the host. When set, the
/// request path must match a prefix at a segment boundary (`/` or `?`).
#[serde(default)]
pub path_patterns: Vec<String>,
/// When `true`, the tool may run without this credential — the host
/// is allowed to skip the mapping if the secret cannot be resolved.
/// **Defaults to `false` (required)** so a tool that simply declares
/// a credential without explicitly opting into "optional" cannot be
/// silently downgraded to an unauthenticated request.
#[serde(default)]
pub optional: bool,
}/// Where a credential should be injected in an HTTP request.
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum CredentialLocation {
/// Inject as Authorization header (e.g., "Bearer {secret}")
#[default]
AuthorizationBearer,
/// Inject as Authorization header with Basic auth
AuthorizationBasic { username: String },
/// Inject as a custom header
Header {
name: String,
prefix: Option<String>,
},
/// Inject as a query parameter
QueryParam { name: String },
/// Inject by replacing a placeholder in URL or body templates
UrlPath { placeholder: String },
}3. 调用限制
/// In-memory rate limiter for tool invocations.
///
/// Keyed by `(user_id, tool_name)` so each user has independent limits.
/// Shared via `Arc` — a single instance lives in `ToolRegistry` and is
/// checked before every built-in tool execution.
pub struct RateLimiter {
state: RwLock<HashMap<(String, String), ToolRateLimitState>>,
}/// Rate limit state for a single (user, tool) pair.
#[derive(Debug)]
struct ToolRateLimitState {
minute_window: WindowState,
hour_window: WindowState,
}/// State for a single rate limit window.
#[derive(Debug, Clone)]
struct WindowState {
window_start: Instant,
count: u32,
}4. 工具
①trait
/// Trait for tools that the agent can use.
#[async_trait]
pub trait Tool: Send + Sync {
/// Get the tool name.
fn name(&self) -> &str;
/// Get a description of what the tool does.
fn description(&self) -> &str;
/// Get the JSON Schema for the tool's parameters.
fn parameters_schema(&self) -> serde_json::Value;
/// Execute the tool with the given parameters.
async fn execute(
&self,
params: serde_json::Value,
ctx: &JobContext,
) -> Result<ToolOutput, ToolError>;
/// Estimate the cost of running this tool with the given parameters.
fn estimated_cost(&self, _params: &serde_json::Value) -> Option<Decimal> {
None
}
/// Estimate how long this tool will take with the given parameters.
fn estimated_duration(&self, _params: &serde_json::Value) -> Option<Duration> {
None
}
/// Whether this tool's output needs sanitization.
///
/// Returns true for tools that interact with external services,
/// where the output might contain malicious content.
fn requires_sanitization(&self) -> bool {
true
}
/// Risk level for a specific invocation of this tool.
///
/// Defaults to `Low` (read-only, safe). Override for tools whose risk
/// depends on the parameters — the shell tool classifies commands into
/// `Low` / `Medium` / `High` based on the command string.
///
/// The worker logs this value with every tool call so operators can audit
/// the risk level at which each execution was classified.
fn risk_level_for(&self, _params: &serde_json::Value) -> RiskLevel {
RiskLevel::Low
}
/// Whether this tool invocation requires user approval.
///
/// Returns `Never` by default (most tools run in a sandboxed environment).
/// Override to return `UnlessAutoApproved` for tools that need approval
/// but can be session-auto-approved, or `Always` for invocations that
/// must always prompt (e.g. destructive shell commands, HTTP with auth).
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
ApprovalRequirement::Never
}
/// Maximum time this tool is allowed to run before the caller kills it.
/// Override for long-running tools like sandbox execution.
/// Default: 60 seconds.
fn execution_timeout(&self) -> Duration {
Duration::from_secs(60)
}
/// Where this tool should execute.
///
/// `Orchestrator` tools run in the main agent process (safe, no FS access).
/// `Container` tools run inside Docker containers (shell, file ops).
///
/// Default: `Orchestrator` (safe for the main process).
fn domain(&self) -> ToolDomain {
ToolDomain::Orchestrator
}
/// Which engine versions this tool is available in.
///
/// Default: `Both`. Override to `V1Only` for tools replaced by engine-native
/// capabilities in v2 (e.g. `routine_create` → `mission_create`), or for
/// tools that cannot be LLM-invoked in v2 (e.g. `ApprovalRequirement::Always`
/// tools with no interactive approval path).
fn engine_compatibility(&self) -> EngineCompatibility {
EngineCompatibility::Both
}
/// What runtime authority this tool needs to be visible under a given
/// [`ironclaw_host_api::runtime_policy::EffectiveRuntimePolicy`].
///
/// Defaults to [`ToolRuntimeAffordance::None`] — visible under every
/// policy. Tools that depend on provider-host shell, host workspace
/// filesystem, or direct network egress should override this so they
/// are hidden from the model when the resolved policy cannot grant the
/// underlying authority. Action-time authorization still runs on every
/// invocation; this is a UX/visibility filter, not an authorization
/// gate (per #3045).
fn runtime_affordance(&self) -> ToolRuntimeAffordance {
ToolRuntimeAffordance::None
}
/// Parameter names whose values must be redacted before logging, hooks, and approvals.
///
/// The agent framework replaces these parameter values with `"[REDACTED]"` before:
/// - Writing to debug logs
/// - Storing in `ActionRecord` (in-memory job history)
/// - Recording in `TurnToolCall` (session state)
/// - Sending to `BeforeToolCall` hooks
/// - Displaying in the approval UI
///
/// **The `execute()` method still receives the original, unredacted parameters.**
/// Redaction only applies to the observability and audit paths, not execution.
///
/// Use this for tools that accept plaintext secrets as parameters (e.g. `secret_save`).
fn sensitive_params(&self) -> &[&str] {
&[]
}
/// Per-invocation rate limit for this tool.
///
/// Return `Some(config)` to throttle how often this tool can be called per user.
/// Read-only tools (echo, time, json, file_read, memory_search, etc.) should
/// return `None`. Write/external tools (shell, http, file_write, memory_write,
/// create_job) should return sensible limits to prevent runaway agents.
///
/// Rate limits are per-user, per-tool, and in-memory (reset on restart).
/// This is orthogonal to `requires_approval()` — a tool can be both
/// approval-gated and rate limited. Rate limit is checked first (cheaper).
///
/// Default: `None` (no rate limiting).
fn rate_limit_config(&self) -> Option<ToolRateLimitConfig> {
None
}
/// Optional host-side webhook verification configuration for this tool.
///
/// When present, `/webhook/tools/{tool}` validates shared secret/signatures
/// before invoking the tool. Tools should then only handle payload normalization.
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
None
}
/// Full parameter schema for discovery and coercion purposes.
///
/// Unlike `parameters_schema()` (which may be permissive to keep the tools
/// array compact), this returns the complete typed schema. Used by the
/// `tool_info` built-in and by WASM parameter coercion.
///
/// Default: delegates to `parameters_schema()`.
fn discovery_schema(&self) -> serde_json::Value {
self.parameters_schema()
}
/// Curated discovery guidance used by `tool_info(detail: "summary")`.
///
/// Default: no custom summary; callers may derive a minimal fallback from
/// `discovery_schema()`.
fn discovery_summary(&self) -> Option<ToolDiscoverySummary> {
None
}
/// Canonical provider extension that owns this action, when one exists.
///
/// This lets the runtime resolve `action -> provider extension` without
/// inferring ownership from the action name. MCP subtools should report the
/// server extension name, and extension-backed WASM tools should report
/// their extension id.
fn provider_extension(&self) -> Option<&str> {
None
}
/// Names of the secrets store credentials this tool needs to function.
///
/// Returns the `secret_name` for every non-optional credential the
/// tool declares (e.g. WASM tools' `capabilities.http.credentials`).
/// The engine's auth preflight (`AuthManager::check_action_auth`)
/// consults this list and raises an `Authentication` gate if any
/// declared credential is missing from the secrets store, so the
/// model can call the tool directly — no separate enablement step
/// is required.
///
/// Default returns empty — built-in tools that don't need a
/// credential, or that handle missing credentials internally,
/// override only when relevant.
fn required_credentials(&self) -> Vec<String> {
Vec::new()
}
/// Get the tool schema for LLM function calling.
fn schema(&self) -> ToolSchema {
let parameters = self.parameters_schema();
let has_discovery_hint =
self.discovery_summary().is_some() || self.discovery_schema() != parameters;
let description = if has_discovery_hint {
format!(
"{} (call tool_info(name=\"{}\", detail=\"summary\") for rules/examples or detail=\"schema\" for the full discovery schema)",
self.description(),
self.name()
)
} else {
self.description().to_string()
};
ToolSchema {
name: self.name().to_string(),
description,
parameters,
}
}
}②ToolSchema
/// Definition of a tool's parameters using JSON Schema.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolSchema {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
}③tool 类别
/// Where a tool should execute: orchestrator process or inside a container.
///
/// Orchestrator tools run in the main agent process (memory access, job mgmt, etc).
/// Container tools run inside Docker containers (shell, file ops, code mods).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ToolDomain {
/// Safe to run in the orchestrator (pure functions, memory, job management).
Orchestrator,
/// Must run inside a sandboxed container (filesystem, shell, code).
Container,
}④支持相关
/// How much approval a specific tool invocation requires.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApprovalRequirement {
/// No approval needed.
Never,
/// Needs approval, but session auto-approve can bypass.
UnlessAutoApproved,
/// Always needs explicit approval (even if auto-approved).
Always,
}
/// Precomputed autonomous tool scope for background jobs and routines.
///
/// Interactive sessions don't use this type — they still rely on
/// `requires_approval()` and session-level approval state.
#[derive(Debug, Clone)]
pub enum ApprovalContext {
/// Autonomous job with no interactive user. Only tools in `allowed_tools`
/// may run; interactive approval requirements are ignored.
Autonomous {
/// Tool names that may run autonomously for this job/run.
allowed_tools: std::collections::HashSet<String>,
},
}
impl ApprovalContext {
/// Create an autonomous context with no allowed tools.
pub fn autonomous() -> Self {
Self::Autonomous {
allowed_tools: std::collections::HashSet::new(),
}
}
/// Create an autonomous context with specific allowed tools.
pub fn autonomous_with_tools(tools: impl IntoIterator<Item = String>) -> Self {
Self::Autonomous {
allowed_tools: tools.into_iter().collect(),
}
}
/// Check whether a tool invocation is blocked in this context.
///
/// - `Never` tools are always allowed (no approval needed).
/// - `UnlessAutoApproved` tools are allowed in autonomous contexts
/// (autonomous execution implies auto-approve).
/// - `Always` tools are only allowed if explicitly listed in `allowed_tools`.
pub fn is_blocked(&self, tool_name: &str, requirement: ApprovalRequirement) -> bool {
match self {
Self::Autonomous { allowed_tools } => match requirement {
ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => false,
ApprovalRequirement::Always => !allowed_tools.contains(tool_name),
},
}
}
/// Check whether a tool is blocked given an optional context.
///
/// When `None`, falls back to legacy behavior: all non-`Never` tools are blocked.
pub fn is_blocked_or_default(
context: &Option<Self>,
tool_name: &str,
requirement: ApprovalRequirement,
) -> bool {
match context {
Some(ctx) => ctx.is_blocked(tool_name, requirement),
None => requirement.is_required(),
}
}
}
三. 凭据路由如何保证凭据安全的
好,现在我看清楚了——HTTP 请求的实际发起者是 **`HttpTool::execute()`(reqwest Client)** 和 **`WasmToolWrapper` 的宿主注入路径**。下面给你一份重写过的答复,并把"谁最终发起 HTTP"放在台面上。
---
## 重答:`SharedCredentialRegistry` 是什么、为什么需要、怎么安全
### 为什么需要它 —— 一个例子
场景:装了 `github` WASM 扩展。用户 `alice` 想列出自己的 issue。
**没有 registry 会发生什么?** WASM 工具要访问 `api.github.com`,但 wasmtime 沙箱里没有密钥。三个备选都不行:
- 让 WASM 直接读密钥 → 沙箱失去意义,密钥可以被扩展作者 dump 走。
- 把密钥塞进 WASM 线性内存 → 同上,扩展随时能 `memory.dump`。
- 每个工具内置一份硬编码 secret → 没法多用户、没法轮换、装一个扩展还得改宿主代码。
`SharedCredentialRegistry` 选的是第四条路:**WASM 工具只"声明"它需要给哪些 host 注入哪些 secret,宿主在请求出沙箱那一刻把明文密钥塞进 HTTP 头**。registry 就是这份"声明"的注册中心,存的是"路由规则",不是密钥。
### 一次完整调用——5 个角色,2 个发起者
我直接用源码里能看到的调用链来讲。整条路径上有 5 类角色:
1. **ToolRegistry**([src/tools/registry.rs:131](src/tools/registry.rs:131)):把同一个 `Arc<SharedCredentialRegistry>` 同时塞给 HTTP 工具和 WASM wrapper。
2. **SharedCredentialRegistry**([src/tools/wasm/credential_injector.rs:65-73](src/tools/wasm/credential_injector.rs:65)):一个 `RwLock<Vec<CredentialMapping>>`,纯路由表。
3. **SecretsStore**:加密的 key/value 存储。明文只在解密瞬间活在 `DecryptedSecret`([src/secrets/types.rs:83](src/secrets/types.rs:83),Drop 清零、不出现在 Debug)里。
4. **HttpTool**(`src/tools/builtin/http.rs`):**内置 HTTP 工具,最终发起者 #1**。
5. **WasmToolWrapper + 宿主 HTTP 拦截**(`src/tools/wasm/wrapper.rs`):**WASM 工具的请求通道,最终发起者 #2**。
**示例:alice 让 GitHub 扩展列 issue**
```
[Agent] 调 github (WASM tool)
│
▼
[WasmToolWrapper::execute] (wrapper.rs:1290, async)
│
├─► resolve_host_credentials (wrapper.rs:1302, async 预解析)
│ │
│ ├─► resolve_secret_for_runtime → SecretsStore::get_decrypted
│ │ (注意:不走 SharedCredentialRegistry;WASM 工具自己的路径)
│ │
│ └─► inject_credential(&mut InjectedCredentials, &mapping.location, &secret)
│ → InjectedCredentials { headers, query_params }
│ → 搬到 ResolvedHostCredential { secret_value: String, headers, query_params, ... }
│ → InjectedCredentials 在这里就被 drop 了
│
▼
[tokio::task::spawn_blocking] execute_sync (wrapper.rs:998)
│
▼
[StoreData::new] host_credentials = Vec<ResolvedHostCredential>
│
▼
[WASM 实例] 调 near:agent/host::http-request
│
▼
[Host 函数 http_request] (wrapper.rs:331)
│ 1. inject_credentials({TELEGRAM_BOT_TOKEN} 占位符替换) ← 占位符路径,host_credentials 不参与
│ 2. LeakDetector.scan_and_clean ← 在 host 注入**之前**跑
│ 3. inject_host_credentials(url_host, &mut headers, &mut url) ← 按 host_patterns/path_patterns 匹配,最具体的胜出
│ 4. validate_and_resolve_http_target ← SSRF 检查
│ 5. reqwest.send ← 发起 HTTP
▼
[api.github.com] 200 OK
▼
[Host 函数] redact 响应/错误中的明文 → 返回给 WASM
▼
[WASM] 返回结果 → StoreData 析构 → ResolvedHostCredential::secret_value drop(明文清零)
```
**示例 2:LLM 直接调内置 `http` 工具**
```
[Agent] 调 http 工具(Rust 内置, alice 的 github)
│
▼
[HttpTool::execute] src/tools/builtin/http.rs:509
│ 1. 解析 method/url/body 等 params (516-519)
│ 2. validate_url(url) (520) —— 静态校验,不是 DNS
│ 3. validate_and_resolve_url(&parsed_url).await? (525) —— DNS 解析+SSRF 拒绝+返回 resolved_addrs
│ 4. build_pinned_client(host, &resolved_addrs, ...) (530-535) —— reqwest::Client,connect 走钉死的 IP
│ 5. parse_headers_param(params["headers"])→ headers_vec (543) + caller_headers/caller_url 快照
│
│ // 6. 阻止 LLM 写 auth headers(只对有注册的 host 生效,host-scoped)
├─► registry.has_credentials_for_host(cred_host) (562-576)
│ → 是 → 扫描 forbidden headers,LLM 写了就 NotAuthorized
│
│ 7. 构建 reqwest::RequestBuilder(无 .send) (582-602)
│ 8. LeakDetector.scan_http_request(...)(注入**前**扫描) (637-640)
│
│ // 9. 真正的凭证注入
├─► registry.find_for_url(cred_host, cred_path) (670-673)
│ → Vec<CredentialMapping>(按 specificity 排序,最具体的最后)
│
├─► for mapping in dedup_matched:
│ ├─► resolve_secret_for_runtime(store, "alice", mapping.secret_name,
│ │ role_lookup, oauth_refresh_for_secret(...),
│ │ DefaultFallback::AdminOnly) (685-693)
│ │ —— AdminOnly 兜底(对比 WASM 路径的 Denied)
│ │
│ ├─► inject_credential(&mut InjectedCredentials, &mapping.location, &secret) (717-718)
│ │ —— 按 location 把明文塞到 headers 或 query_params
│ │
│ └─► if injected.headers 非空:
│ headers_vec.push(...) + request.header(...) (719-722)
│ if injected.query_params 非空:
│ parsed_url.query_pairs_mut().append_pair(...) (723-726)
│ request.query(&[(...)])
│ // 注意:location 决定字段名,不一定是 Authorization
│
│ 10. intercept_req 构建 + interceptor 短路(replay 模式) (773-)
│ 11. 真正发起 HTTP (execute 末尾 .send().await)
│ request.send().await ←─────────── 发起者 #1
▼
[api.github.com] → 响应 → 可能 401/403 → 如果 missing_credential.is_some() → 升级成 auth gate
```
### 关键事实
1. **HTTP 最终是宿主进程内的 reqwest 发起的**,**两个入口**:
- 内置 `HttpTool::execute()` 直接 `reqwest::Client::new().send()`([http.rs:892](src/tools/builtin/http.rs:892))。
- WASM 工具通过 `WasmToolWrapper` 里的"宿主 HTTP 拦截"在 `spawn_blocking` 边界把注入后的请求用 reqwest 发出去。WASM 内部的 `http_request` host function 不会自己联网。
2. **WASM 永远拿不到明文**。它只能在 `http_request` 调用里传 URL/方法/body,明文密钥只在 `DecryptedSecret` 里短暂存在、随注入后立刻 drop([secrets/types.rs:83](src/secrets/types.rs:83))。
3. **registry 装的是"路由规则"**:`CredentialMapping { secret_name, location, host_patterns, path_patterns, optional }`([secrets/types.rs:218](src/secrets/types.rs:218)),**不含任何 secret 值**。
4. **WASM 路径与 HTTP 工具路径共用一份映射**,差别只在 `DefaultFallback`:WASM 用 `Denied`([wrapper.rs:1524](src/tools/wasm/wrapper.rs:1524)),HTTP 工具用 `AdminOnly` + `role_lookup` 二次把关([http.rs:691](src/tools/builtin/http.rs:691))。
### 一句话总结
`SharedCredentialRegistry` 是 **"扩展 → URL → 应该注入哪个 secret" 的路由表**,把所有权的边界划在宿主里——WASM 只声明需求、永远拿不到值;明文密钥只在宿主边界由 `secrets_store` 解密一次、注入到外发 HTTP 那一刻存在;**最终 HTTP 都是宿主进程内的 reqwest 发起的**,发起者是 `HttpTool::execute()` 与 `WasmToolWrapper` 的宿主 HTTP 拦截两条路径,共用同一份 registry 与同一套安全约束。四. tool授权流程
五. tool registry
1. built-in(tools.register_builtin_tools();)
# 工具名 类型 主要能力 备注
━━━━━ ━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1 echo 只读/无副作用 回显输入,便于测试与链路追踪 在 PROTECTED 名单里
───── ───────────── ─────────────── ──────────────────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────────────────────────────────
2 time 只读 返回当前时间 在 PROTECTED 名单里
───── ───────────── ─────────────── ──────────────────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────────────────────────────────
3 json 通用数据 JSON 解析 / 查询 / 转换 在 PROTECTED 名单里
───── ───────────── ─────────────── ──────────────────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────────────────────────────────
4 plan_update 计划 UI 通过 SSE 向 UI 推送结构化计划进度 后面 register_plan_tools 会用同样 name 覆盖式 re-register,把可选的 SseManager 装上
去
───── ───────────── ─────────────── ──────────────────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────────────────────────────────
5 http 出站网络 通用 HTTP 客户端,含 SSRF 校验、redirect 控制、body 上限、自动凭据注入 唯一一个有条件注入依赖的——若 registry 上有 credential_registry + secrets_store,就装
上凭据能力;有 role_lookup 再装上多租户 fallback2. tool-info(built-in)
pub struct ToolInfoTool {
registry: Weak<ToolRegistry>,
}弱引用,防止循环引用
/// Register the `tool_info` discovery tool.
///
/// Requires `Arc<Self>` so the tool can query the registry for other tools'
/// schemas at runtime. Call after `register_builtin_tools()`.
pub fn register_tool_info(self: &Arc<Self>) {
use crate::tools::builtin::ToolInfoTool;
let tool = ToolInfoTool::new(Arc::downgrade(self));//现有tools本身的引用
self.register_sync(Arc::new(tool));
tracing::debug!("Registered tool_info discovery tool");
}3. system-tool(built-in)
/// Register system introspection tools (tools_list, version).
///
/// Requires `Arc<Self>` because `SystemToolsListTool` queries the
/// registry at runtime. Call after other registration methods.
pub fn register_system_tools(self: &Arc<Self>) {
use crate::tools::builtin::system::{SystemToolsListTool, SystemVersionTool};
self.register_sync(Arc::new(SystemToolsListTool::new(Arc::clone(self))));
self.register_sync(Arc::new(SystemVersionTool));
tracing::debug!("Registered system introspection tools");
}4. secrets_tools(不会输出实际值)built-in
/// Register secret management tools (list, delete).
///
/// These allow the LLM to persist API keys and tokens encrypted in the database.
/// Values are never returned to the LLM; only names and metadata are exposed.
pub fn register_secrets_tools(
&self,
store: Arc<dyn crate::secrets::SecretsStore + Send + Sync>,
) {
use crate::tools::builtin::{SecretDeleteTool, SecretListTool};
self.register_sync(Arc::new(SecretListTool::new(Arc::clone(&store))));
self.register_sync(Arc::new(SecretDeleteTool::new(store)));
tracing::debug!("Registered 2 secret management tools (list, delete)");
}5. 长期记忆工具
memory tools
/// Register memory tools with a workspace resolver.
///
/// Memory tools require a workspace resolver for persistence. Call this after
/// `register_builtin_tools()` if you have a workspace available.
///
/// Accepts an optional LLM provider and reasoning flag for reasoning-augmented
/// recall on `memory_search`. When `reasoning_llm` is `Some` and
/// `reasoning_enabled` is `true`, the search tool can synthesize results via
/// an LLM call before returning.
pub fn register_memory_tools_with_resolver(
&self,
resolver: Arc<dyn crate::tools::builtin::memory::WorkspaceResolver>,
reasoning_llm: Option<Arc<dyn ironclaw_llm::LlmProvider>>,
reasoning_enabled: bool,
) {
self.register_sync(Arc::new(MemorySearchTool::with_reasoning(
Arc::clone(&resolver),
reasoning_llm,
reasoning_enabled,
)));
self.register_sync(Arc::new(MemoryWriteTool::new(Arc::clone(&resolver))));
self.register_sync(Arc::new(MemoryReadTool::new(Arc::clone(&resolver))));
self.register_sync(Arc::new(MemoryTreeTool::new(resolver)));
tracing::debug!("Registered 4 memory tools");
} ### 拆成 5 个动作
┌──────────────────────────────────────────────────────────────────────┐
│ if let Some(db) = self.db { │
│ 1. 构造启动期 Workspace(owner / "default") │
│ 2. 装 embeddings + cache + 搜索配置 │
│ 3. 装读作用域 + memory layers + admin prompt │
│ 4. 构造 WorkspacePool(既是 cache,也实现 WorkspaceResolver) │
│ 5. 把 Pool 注册到 memory 工具 + 给 hooks 用 │
│ } else { (None, None) } │
└──────────────────────────────────────────────────────────────────────┘
第 1 步:owner workspace
let mut ws = Workspace::new_with_db(workspace_user_id, db.clone())
.with_search_config(&self.config.search);
workspace_user_id 是启动期固定的字符串(一般是 "default" 或 owner id),所以 ws 是 owner / 启动者自己的 workspace——单一实例,构造一次,永久使用。
第 2 步:embedding + 缓存
let emb_cache_config = EmbeddingCacheConfig {
max_entries: self.config.embeddings.cache_size,
};
if let Some(ref emb) = embeddings {
ws = ws.with_embeddings_cached(emb.clone(), emb_cache_config.clone());
}
embeddings 开启就给 workspace 装一个 embedding cache(避免每次搜索都重新算 embedding)。
第 3 步:跨租户配置
if !self.config.workspace.read_scopes.is_empty() {
ws = ws.with_additional_read_scopes(self.config.workspace.read_scopes.clone());
tracing::info!(read_scopes = ?ws.read_user_ids(), ...);
}
ws = ws.with_memory_layers(self.config.workspace.memory_layers.clone());
let is_multi_tenant = self.config.is_multi_tenant_deployment();
if is_multi_tenant {
ws = ws.with_admin_prompt(); // 让 dispatcher 从 __admin__ scope 读 SYSTEM.md
}
注意 is_multi_tenant 来自配置而非 DB 内容——这是个明确选择:管理员可能在没建任何 tenant 之前就以 multi-tenant 模式启动。
第 4 步:WorkspacePool(关键角色)
let ws = Arc::new(ws);
let pool: Arc<dyn WorkspaceResolver> = Arc::new(WorkspacePool::new(
Arc::clone(db),
embeddings.clone(),
emb_cache_config,
self.config.search.clone(),
self.config.workspace.clone(),
));
let pool_for_hooks = Arc::clone(&pool);
WorkspacePool 既实现 WorkspaceResolver (src/tools/builtin/memory.rs:31)(src/channels/web/platform/state.rs:199-228 (src/channels/web/platform/state.rs:199))又自己内部缓存 HashMap<user_id,
Arc<Workspace>>,按 user_id 现造现缓存。
第 5 步:下传给两个消费者
let reasoning_llm = cheap_llm.map(Arc::clone).or_else(|| Some(Arc::clone(llm)));
tools.register_memory_tools_with_resolver(pool, reasoning_llm, ...);
// pool_for_hooks 走 return 值
调用方拿到的:
- workspace: Option<Arc<Workspace>> — 启动期 owner 那个
- workspace_resolver: Option<Arc<dyn WorkspaceResolver>> — 实际上是 WorkspacePool(拿到时是 pool_for_hooks 这个克隆)
## ws 和 ws_resolver 的本质区别
维度 workspace: Option<Arc<Workspace>> workspace_resolver: Option<Arc<dyn WorkspaceResolver>>
━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
类型 单一 Arc<Workspace> 实例 trait object,背后是 WorkspacePool
──────────── ──────────────────────────────────────────────────────── ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
对应身份 固定 owner / workspace_user_id 任意 user(含 owner)
──────────── ──────────────────────────────────────────────────────── ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
生命周期 启动时构造 1 个,整个进程不变 进程内一张 cache:HashMap<user_id, Arc<Workspace>>
──────────── ──────────────────────────────────────────────────────── ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
配置 只能拿到启动期的 search/layers/admin prompt 每次 resolve(uid) 都按当前配置 + 该 uid 构造(含 Private layer 自动重写 scope=user_id,state.rs:253-258 (src/channels/web/
platform/state.rs:253))
──────────── ──────────────────────────────────────────────────────── ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
适用场景 "我知道这个 workspace 给谁用" "现在来了个请求,要按 user_id 派发"
──────────── ──────────────────────────────────────────────────────── ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
下传去向 AppComponents.workspace(app.rs:701 (src/app.rs:701)) tools(memory 工具)+ hooks(SessionSummaryHook)
──────────── ──────────────────────────────────────────────────────── ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
没有 db 时 None None
### 为什么必须分两个?
看代码里这段注释讲清楚了:
// Memory tools must resolve by `ctx.user_id`, not a fixed startup
// workspace. Even outside authenticated multi-tenant mode, some
// channels and test harnesses route non-owner users through
// per-user tenant workspaces seeded on demand.
//
// Whether the deployment is multi-tenant is configuration, not a
// property we should infer from the current DB contents.
简单说:
1. owner workspace 给启动期的"自己"用——典型场景:bootstrap 脚本、admin 操作、单用户模式下与 owner 等价的请求。
2. WorkspacePool 给任何 user 用:每条请求带 ctx.user_id,memory 工具通过 pool.resolve(ctx.user_id) 拿到那个用户的 workspace。这样:
- 单用户模式:永远只 cache 一个 key;
- 多租户模式:cache 里按用户增长,每个用户的 Private memory layer 自动 scope=user_id 隔离。
两个消费者从不同入口取:
// memory 工具 — 通过 ToolRegistry 注册
tools.register_memory_tools_with_resolver(pool, reasoning_llm, ...);
// hooks — SessionSummaryHook 在 session 结束时为该用户写 summary
hooks.register(Arc::new(SessionSummaryHook::new(
Arc::clone(db) as Arc<dyn ConversationStore>,
Arc::clone(ws_resolver), // ← pool_for_hooks
summary_llm,
)));
两个共享同一个 Arc<WorkspacePool>,所以 cache 自然一致。
### WorkspacePool 的几个不显眼但重要的细节
src/channels/web/platform/state.rs:239-261 build_workspace (src/channels/web/platform/state.rs:239) 的实现里藏了几件事:
1. 每次构造都重新拼装:embedding cache 重新 clone、read scopes 从配置重新拉、memory layers 重新构造。
2. Private 层自动绑定到 user_id:
for layer in &mut memory_layers {
if layer.sensitivity == LayerSensitivity::Private {
layer.scope = user_id.to_string(); // 强制私有层 scope = 该用户
}
}
——这是多租户数据隔离的关键。
3. admin prompt 缓存:admin_prompt_cache: Arc<RwLock<Option<String>>>(state.rs:208 (src/channels/web/platform/state.rs:208));PUT 接口改 SYSTEM.md 后调 invalidate_admin_prompt() (src/channels/web/
platform/state.rs:232) 失效,所有后续构造的 workspace 都能看到新内容。
4. 双重检查锁(state.rs:268-280 (src/channels/web/platform/state.rs:268)):先 read 锁查 cache,miss 再升级到 write 锁,构造前 double-check 防并发重复构造。
### 一句话总结
这段代码 把"启动期 owner workspace"和"按请求 user_id 动态派发的 workspace 池"两件事分开:前者是固定 Arc<Workspace>,给 owner/启动者用;后者是 WorkspacePool(实现 WorkspaceResolver,自带 per-user
cache + Private 层 scope 重写 + admin prompt 共享缓存),下传给 memory 工具和 SessionSummaryHook,让"任意用户进来都能拿到自己的 workspace",且多租户数据隔离在 layer 配置层就完成。没有 db 时两个都返回
None,对应"无数据库"的最简模式。
6. image and video
pub fn register_image_tools(
&self,
api_base_url: String,
api_key: String,
gen_model: String,
base_dir: Option<std::path::PathBuf>,
) {
use crate::tools::builtin::{ImageEditTool, ImageGenerateTool};
self.register_sync(Arc::new(ImageGenerateTool::new(
api_base_url.clone(),
api_key.clone(),
gen_model.clone(),
)));
self.register_sync(Arc::new(ImageEditTool::new(
api_base_url,
api_key,
gen_model,
base_dir,
)));
tracing::debug!("Registered 2 image tools (generate, edit)");
} /// Register vision/image analysis tools.
///
/// These tools allow the LLM to analyze images using a vision-capable model.
pub fn register_vision_tools(
&self,
api_base_url: String,
api_key: String,
vision_model: String,
base_dir: Option<std::path::PathBuf>,
) {
use crate::tools::builtin::ImageAnalyzeTool;
self.register_sync(Arc::new(ImageAnalyzeTool::new(
api_base_url,
api_key,
vision_model,
base_dir,
)));
tracing::debug!("Registered 1 vision tool (analyze)");
}7. builder tool
// Register builder tool if enabled
let builder = if self.config.builder.enabled
&& (self.config.agent.allow_local_tools || !self.config.sandbox.enabled)
{生产默认关,不允许不安全工具逃逸六. 录制回放
整条链路已经清楚了。从创建到最终写入文件,分 6 个阶段:
## 完整执行链路
```
┌─ 阶段1: 创建 — 包在最外层 ──────────────────────────────────────────────────┐
│ │
│ lib.rs: build_provider_chain() / build_static_provider_chain() │
│ │
│ 装饰器链(从内到外): │
│ Raw provider │
│ → RetryProvider │
│ → SmartRoutingProvider │
│ → FailoverProvider │
│ → CircuitBreakerProvider │
│ → CachedProvider │
│ → SwappableLlmProvider (热切换用) │
│ → RecordingLlm ← 最外层,最后包装,从 IRONCLAW_RECORD_TRACE 环境变量创建 │
│ │
│ RecordingLlm::new() 内部创建: │
│ http_interceptor: Arc::new(RecordingHttpInterceptor::new()) │
│ steps: Mutex::new(Vec::new()) │
│ prev_message_count: Mutex::new(0) │
│ │
├─ 阶段2: 导出 — 返回给调用方 ──────────────────────────────────────────────────┤
│ │
│ build_provider_chain() 返回: │
│ (primary: Arc<dyn LlmProvider>, ← 就是 RecordingLlm 包裹后的 │
│ cheap: Option<Arc<dyn LlmProvider>>, │
│ recording_handle: Option<Arc<RecordingLlm>>, ← 暴露给 main.rs │
│ reload_handle) │
│ │
├─ 阶段3: 合并 — main.rs 组装拦截器链 ──────────────────────────────────────────┤
│ │
│ // 1. 快照 memory 文档 │
│ recorder.snapshot_memory(entries).await; │
│ │
│ // 2. 合并两个 HTTP 拦截器 │
│ let http_interceptor = http_intercept::chain([ │
│ components.http_interceptor, ← ToolRegistry 的(凭证注入) │
│ recording_handle.http_interceptor(), ← RecordingHttpInterceptor │
│ ]); │
│ // → CompositeHttpInterceptor: before_request 都问一遍, │
│ // after_response 都通知一遍 │
│ │
│ // 3. 传给 Agent │
│ Agent::new(..., http_interceptor) │
│ │
├─ 阶段4: 运转 — 每次 LLM 调用都被 RecordingLlm 拦截 ────────────────────────────┤
│ │
│ // LlmProvider trait 实现 (recording.rs:1027-1112) │
│ │
│ complete(request) →: │
│ 1. capture_new_messages(&request.messages) │
│ ├─ 对比 prev_message_count,找出新增消息 │
│ ├─ 新 User 消息 → 写入 UserInput step │
│ ├─ 新 Tool 消息 → 收集为 expected_tool_results │
│ └─ 构建 RequestHint(最后一条用户消息的稳定前缀,精确匹配用) │
│ │
│ 2. self.inner.complete(request).await ← 调用内层真正的 LLM │
│ │
│ 3. 写入 TraceStep { request_hint,response: Text{...},expected_tool_results} │
│ → steps.lock().push(step) │
│ │
│ complete_with_tools(request) →: │
│ 1. capture_new_messages (同上) │
│ 2. build_prior_tool_lookup → 把上轮 tool 结果映射为 {{call_id.field}} │
│ 3. self.inner.complete_with_tools(request).await │
│ 4. parameterize_value(tool_args, &prior_tool_lookup) ← 把非确定性 ID 模板化 │
│ 5. 写入 TraceStep { response: ToolCalls{...} } 或 Text{...} │
│ │
├─ 阶段5: Tool 侧 HTTP 录制 — WASM tool 每次出站请求被 RecordingHttpInterceptor 捕获│
│ │
│ WASM wrapper (wrapper.rs:584-614): │
│ HTTP 请求完成后 → │
│ interceptor.after_response(&redacted_req, &redacted_resp) │
│ → RecordingHttpInterceptor::after_response(): │
│ redact_exchange_request(&mut req) ← 脱敏 Authorization/Cookie/API key│
│ redact_exchange_response(&mut resp) ← 脱敏 Set-Cookie │
│ exchanges.lock().push(HttpExchange { request, response }) │
│ │
│ before_request() 始终返回 None — 录制模式不拦截请求,只记录 │
│ │
├─ 阶段6: 落盘 — flush() 时合并写入文件 ─────────────────────────────────────────┤
│ │
│ RecordingLlm::flush(): │
│ let steps = self.steps.lock().await; ← LLM 调用过程 │
│ let memory_snapshot = self.memory_snapshot.lock(); ← 启动时快照 │
│ let http_exchanges = self.http_interceptor.take_exchanges(); ← Tool HTTP │
│ │
│ TraceFile { model_name, memory_snapshot, http_exchanges, steps } │
│ → serde_json::to_string_pretty → tokio::fs::write(output_path) │
│ │
│ 最终 JSON 结构: │
│ { │
│ "model_name": "...", │
│ "memory_snapshot": [...], ← 启动时 workspace 文档 │
│ "http_exchanges": [...], ← WASM tool 出站 HTTP 请求/响应,已脱敏 │
│ "steps": [ ← 每次 LLM 调用的完整记录 │
│ { "response": { "type": "user_input", "content": "..." } }, │
│ { "request_hint": {...}, "response": {"type":"tool_calls",...}, │
│ "expected_tool_results": [...] }, │
│ { "request_hint": {...}, "response": {"type":"text",...}, │
│ "expected_tool_results": [...] } │
│ ] │
│ } │
└──────────────────────────────────────────────────────────────────────────────┘
```
## 关键设计点
1. **RecordingLlm 在最外层**:包在装饰器链最外面,确保它看到的是**所有装饰器处理完之后的最终请求/响应**,同时它自己不对请求做任何修改——纯旁路录制。
2. **两种录制走不同的路径,但汇入同一个文件**:
- LLM 调用过程 → 通过 `LlmProvider` trait 的 `complete()`/`complete_with_tools()` 拦截 → 写入 `steps`
- Tool HTTP 流量 → 通过 `HttpInterceptor` trait 的 `after_response()` 拦截 → 写入 `http_interceptor.exchanges`
- `flush()` 时合并到一个 `TraceFile` JSON
3. **parameterize 模板化处理**:`complete_with_tools()` 会把 tool 参数中的非确定性值(UUID、时间戳等)替换为 `{{call_id.field}}` 模板,因为录制时的具体值和回放时不同,回放引擎用当前实际 tool 结果来填充模板。
4. **凭证脱敏是最后一道防线**:`RecordingHttpInterceptor::after_response()` 和 WASM wrapper 在写入前都做 redact,保证即使某个路径漏了,写入 JSON 文件时也不会泄露 `Authorization`/`Cookie`/`x-api-key`/`Set-Cookie`。
5. **prev_message_count 增量录制**:不重复记录历史消息,通过对比上一次的消息数量只捕获**新增**的 user_input 和 tool_result,避免一个长对话里 trace 文件无限膨胀。七. Tool_Wasm
wasm_config.tools_dir 来自 self.config.wasm.tools_dir,三级优先级:DB settings > WASM_TOOLS_DIR 环境变量 > 默认 ~/.ironclaw/tools/。默认值正好就是 wizard RegistryInstaller 装 WASM tool 的落盘目录
▎ —— 启动期 loader 扫的就是 wizard 写过的那个目录,所以"装完即可用"无需额外同步步骤。loader 只扫 .wasm 文件,每个文件读相邻的 .capabilities.json,编译 + 校验后注册进全局 ToolRegistry。
1. WasmRuntimeConfig
/// Configuration for the WASM runtime.
#[derive(Debug, Clone)]
pub struct WasmRuntimeConfig {
/// Default resource limits for tools.
pub default_limits: ResourceLimits,
/// Fuel configuration.
pub fuel_config: FuelConfig,
/// Whether to cache compiled modules.
pub cache_compiled: bool,
/// Directory for compiled module cache.
pub cache_dir: Option<PathBuf>,
/// Cranelift optimization level.
pub optimization_level: OptLevel,
}
WasmRuntimeConfig 来自 src/tools/wasm/runtime.rs,是 WASM 工具运行时的配置项。逐字段解释:
1. default_limits: ResourceLimits
WASM 工具的默认资源限制(如内存上限、文件大小、调用超时等)。单个工具可以在自己的 manifest 里覆盖这些默认值,运行时取"工具自带 > 全局默认"的优先级。
2. fuel_config: FuelConfig
WASM 的 fuel(燃料)计量 配置。WASM 本身没有天然的"CPU 时间"概念,wasmtime 通过给每条指令分配一个 fuel 计数来防止工具死循环或耗尽 CPU。FuelConfig 一般包含:
- 每个工具调用分配多少 fuel
- 耗尽时的行为(终止执行并报错)
- 是否启用 fuel 计量本身
3. cache_compiled: bool
是否缓存已编译的 WASM 模块。WASM 字节码需要经过 Cranelift 编译成机器码才能执行,开启缓存可以避免每次启动都重新编译。
4. cache_dir: Option<PathBuf
已编译模块的磁盘缓存目录。Some(path) 启用磁盘持久化(重启后仍命中),None 通常意味着只做内存级缓存或完全不开缓存。
5. optimization_level: OptLevel
Cranelift 编译器的优化等级,来自 wasmtime/wincall 的 OptLevel:
- None / Speed — 最快编译,无优化
- Speed — 启用速度优化(默认通常是这个)
- SpeedAndSize — 同时优化代码体积
等级越高,首次编译越慢、运行越快;因为有上面的 cache_compiled,优化代价只在首次启动时付出。
---
配合关系总结:cache_compiled + cache_dir + optimization_level 三者协同——你可以放心地把 OptLevel 调高,因为编译结果会持久化到 cache_dir,后续启动直接加载。default_limits 和 fuel_config
则是每次执行都会生效的安全护栏。
/// Resource limits for a single WASM execution.
#[derive(Debug, Clone)]
pub struct ResourceLimits {
/// Maximum memory in bytes.
pub memory_bytes: u64,
/// Maximum fuel (instruction count).
pub fuel: u64,
/// Maximum wall-clock execution time.
pub timeout: Duration,
}
1. memory_bytes: u64
WASM 模块能使用的最大线性内存(字节数)。
- WASM 的"堆"是线性内存字节数组,函数局部变量、字符串、buffer 都放在这里
- 超出会让 wasmtime 立刻 OutOfMemory 终止模块
- 防止单个工具无限制申请内存把宿主拖垮
2. fuel: u64
本次调用允许消耗的燃料总量(指令计数)。
- 每执行一条 WASM 指令(加法、内存读写、函数调用等)扣一个 fuel
- 这是计算量的硬上限,防止死循环、复杂计算拖死 CPU
- 与 timeout(墙钟时间)不同——fuel 是确定性的,相同输入消耗相同 fuel,便于测试和回放
- 耗尽返回 TrapCode::OutOfFuel
3. timeout: Duration
本次调用的墙钟时间上限。
- 即便 fuel 没耗尽,超过这个时长也会被强制终止
- 保护宿主免受I/O 阻塞类攻击(比如工具内部做长时间网络/磁盘等待,那段时间不耗 fuel 但占着 CPU/线程)
- 由 tokio 的 timeout 包裹实现,触发后丢弃 future 并报错
---
三者协同的工作原理
┌──────┬──────────────┬─────────────────────┬────────────────────┐
│ 维度 │ 字段 │ 防护目标 │ 触发时表现 │
├──────┼──────────────┼─────────────────────┼────────────────────┤
│ 空间 │ memory_bytes │ 内存耗尽 │ wasmtime 直接 trap │
├──────┼──────────────┼─────────────────────┼────────────────────┤
│ 计算 │ fuel │ 死循环 / 算力耗尽 │ OutOfFuel trap │
├──────┼──────────────┼─────────────────────┼────────────────────┤
│ 时间 │ timeout │ I/O 阻塞 / 真实耗时 │ tokio 取消 future │
└──────┴──────────────┴─────────────────────┴────────────────────┘
为什么需要三个? 它们覆盖的攻击面不重叠:
- fuel 抓不到 I/O 等待(不耗 fuel 但占时间)
- timeout 抓不到短暂但高频的小计算(每次都不超时但累计起来炸 CPU)
- memory_bytes 抓不到纯计算型工具(不堆内存但能跑很久)
典型调参思路:开发态放宽(debug),生产收紧;memory_bytes 通常按字节常量设(如 16MiB / 64MiB),fuel 和 timeout 跟工具类型匹配——纯计算类偏重 fuel,I/O 类偏重 timeout。/// Configuration for fuel metering.
#[derive(Debug, Clone)]
pub struct FuelConfig {
/// Initial fuel to provide.
pub initial_fuel: u64,
/// Whether to enable fuel consumption.
pub enabled: bool,
}
1. initial_fuel: u64
每个调用启动时注入的 fuel 总量。
- 进入 wasmtime 执行前,会通过 Store::set_fuel(initial_fuel) 把这个值塞进 store
- 之后每执行一条 WASM 指令就扣 1,扣到 0 立刻 trap(OutOfFuel)
- 与上一题 ResourceLimits::fuel 的关系:这是"配置层的默认值",实际调用时 RuntimeLimits(每次调用的 ResourceLimits)可以选择继承或覆盖它
2. enabled: bool
是否真正启用 fuel 计量。
- true:wasmtime 编译时插入 fuel 计数器,每次指令执行都要计数 → 会带来少量运行时开销(通常 <5%)
- false:编译期跳过 fuel 注入 → 零开销,但工具可以无限循环/无限计算
---
三者的层级关系
FuelConfig (全局配置,src/tools/wasm/runtime.rs)
└── initial_fuel // 默认配额
└── enabled // 全局开关
↓
ResourceLimits (每次调用,runtime.rs 也有)
└── fuel // 本次调用的 fuel,可覆盖 FuelConfig::initial_fuel2. WasmToolRuntime
/// WASM tool runtime.
///
/// Manages the Wasmtime engine and a cache of prepared modules.
pub struct WasmToolRuntime {
/// Wasmtime engine with configured settings.
engine: Engine,
/// Runtime configuration.
config: WasmRuntimeConfig,
/// Cache of prepared modules by name.
modules: RwLock<HashMap<String, Arc<PreparedModule>>>,
}
1. engine: Engine
wasmtime 的全局编译/执行引擎。
- Engine 是 wasmtime 的"工厂"——所有 Module(编译产物)和 Store(执行上下文)都从它创建
- 线程安全、可全局共享,通常一个进程只需要一个实例
- 内部封装了 Cranelift 编译器、JIT 代码缓存、SIMD/多内存等平台特性开关
- 配置来源:构造时把 WasmRuntimeConfig::optimization_level 和 cache_compiled 等透传给 wasmtime 的 Config
2. config: WasmRuntimeConfig
运行时的配置快照。
- 保留 WasmRuntimeConfig 的克隆而不是仅透传给 Engine,原因有三:
a. 运行时仍需查询(比如动态创建 Store 时按当前 fuel 配置注入)
b. 配置可变(未来如果支持热更新,不需要重建 Engine)
c. 调试/序列化(#[derive(Debug, Clone)] 让 tracing 能直接打印)
- 注意:fuel / memory 等运行时参数在执行时才生效,存 config 只是为了引用默认值
3. modules: RwLock<HashMap<String, Arc<PreparedModule>>>
已编译模块的内存缓存。
- RwLock:读多写少——大量并发执行命中缓存(只读),只有加载新模块时抢写锁
- HashMap<String, _>:用工具名(或模块路径)做 key,O(1) 查表
- Arc<PreparedModule>:PreparedModule 包含编译产物(机器码)和资源限制元信息,克隆廉价——多个并发执行复用同一个 Arc,避免重复克隆编译产物
- 与磁盘缓存的关系:内存缓存是 L1,磁盘缓存(WasmRuntimeConfig::cache_dir)是 L2。命中内存直接复用,否则从磁盘反序列化或重新编译
---
三个字段的协作关系
加载工具流程:
tool_name → modules: RwLock::read()
命中 → clone Arc<PreparedModule> → 返回
未命中 → modules: RwLock::write() → 编译/反序列化 → 插入 → 返回
执行工具流程:
PreparedModule → 用 engine 创建 Store
→ 注入 config.fuel_config / config.default_limits
→ 调用工具实例
设计上的几个要点
┌────────────────┬────────────────────┬─────────────────────────────────────────────────┐
│ 字段 │ 类型选择 │ 理由 │
├────────────────┼────────────────────┼─────────────────────────────────────────────────┤
│ engine │ 非 Arc(直接持有) │ wasmtime 的 Engine 内部已经是 Arc,外层无需再包 │
├────────────────┼────────────────────┼─────────────────────────────────────────────────┤
│ config │ Clone 后持有 │ 让运行时无状态地查询默认值,避免反查参数 │
├────────────────┼────────────────────┼─────────────────────────────────────────────────┤
│ modules │ RwLock<HashMap> │ 多读者零竞争,写者(模块加载)相对稀少 │
├────────────────┼────────────────────┼─────────────────────────────────────────────────┤
│ PreparedModule │ Arc<...> 包裹 │ 克隆是原子操作,并发执行不会阻塞彼此 │
└────────────────┴────────────────────┴─────────────────────────────────────────────────┘
一个常见疑问:为什么 engine 和 modules 分开存?
因为它们的生命周期不同:
- engine 是基础设施,整个进程生命周期内基本不变
- modules 是按需增长的数据,可能因为工具卸载而缩减
如果把 modules 放进 engine 的配置里,会让"配置"和"状态"混在一起,不利于未来扩展(比如加 LRU 淘汰、统计命中率等)。现在的分层很干净:配置驱动 engine,engine 驱动缓存,缓存服务调用。
************
// Spawn a background thread that periodically increments the engine's
// epoch counter. Without this, epoch_deadline_trap() never fires and
// WASM modules can spin indefinitely even with a deadline set.
let ticker_engine = engine.clone();
std::thread::Builder::new()
.name("wasm-epoch-ticker".into())
.spawn(move || {
loop {
std::thread::sleep(EPOCH_TICK_INTERVAL);
ticker_engine.increment_epoch();
}
})new时的背后任务全局共享/// A compiled WASM component ready for instantiation.
///
/// Contains the pre-compiled component plus cached metadata extracted
/// from the component during preparation. Stores the compiled `Component`
/// directly so instantiation doesn't require recompilation.
pub struct PreparedModule {
/// Tool name.
pub name: String,
/// Tool description (cached from component).
pub description: String,
/// Full parameter schema JSON extracted from the component.
/// Used for discovery and coercion, not necessarily for the compact
/// schema advertised in the main tools array.
pub schema: serde_json::Value,
/// Pre-compiled component (cheaply cloneable via internal Arc).
component: wasmtime::component::Component,
/// Resource limits for this tool.
pub limits: ResourceLimits,
} epoch 中断机制的"双角色"
┌─────────────────────────────────────────────────────────┐
│ Engine │
│ epoch_counter: AtomicU64 ← ticker 线程递增它 │
└─────────────────────────────────────────────────────────┘
↑ 共享给所有 Store
│
┌─────────────────────────────────────────────────────────┐
│ Store (每次调用创建一个) │
│ epoch_deadline: u64 ← 每个 Store 自己设的阈值 │
└─────────────────────────────────────────────────────────┘
- Engine::increment_epoch() —— 全局计数器,只加不减,所有 Store 共享
- Store::epoch_deadline_trap(n) —— 每个 Store 自己设一个阈值,超过就 trap
increment_epoch() 不带任何"上限"概念,它就是个单调递增的原子数。deadline 是 Store 层的事情,Engine 这层无感知。
那 deadline 在哪设的?
大概率在 prepare_module 或 instantiate 阶段。典型代码长这样:
// 大概在 runtime.rs 的某个 instantiate() 或 call() 方法里
fn call_tool(&self, module: Arc<PreparedModule>, args: ...) -> Result<...> {
let mut store = Store::new(&self.engine, ());
// ←———— 关键在这一行 ————→
// 把 ResourceLimits::timeout 转成 epoch 数,作为这个 Store 的 deadline
let deadline = self.engine.increment_epoch()
+ timeout_to_epochs(self.config.default_limits.timeout);
store.epoch_deadline_trap(deadline); // ← 真正的"时间上限"
// 然后才执行 WASM
let instance = linker.instantiate(&mut store, &module)?;
instance.call(&mut store, ...).await
}
关键是 store.epoch_deadline_trap(deadline) 这一行——它就是把 ResourceLimits::timeout 注入 wasmtime 的地方。
为什么要"现在 + timeout_epochs"?
let deadline = current_epoch + timeout_epochs;
不是写死一个绝对值,而是相对于当前 epoch 的偏移。原因:
1. 多 Store 并发安全:每个 Store 拿到的是"在当前 epoch 基础上,还能再推进多少次就要 trap"
2. 避免 race:如果直接写 epoch_deadline_trap(1000),但当前 epoch 已经是 999 了,工具几乎一启动就 trap
3. 统一语义:ticker 线程无脑累加,每个 Store 各自表达"我最多能容忍多久"
完整时间线示例
假设 EPOCH_TICK_INTERVAL = 10ms,timeout = 100ms
t=0ms : Store 创建 → current_epoch=0 → deadline = 0 + 10 = 10
t=10ms : ticker tick → epoch=1
t=20ms : ticker tick → epoch=2
...
t=100ms : ticker tick → epoch=10 → WASM 下条指令前检查:10 >= 10 → trap!
t=110ms : tokio::timeout 也差不多触发(双保险)
所以这里其实是有"上限"的
虽然 ticker 线程没设,但每次新 Store 创建时,通过 epoch_deadline_trap 隐式设了上限。只是这个调用不在 new() 里,而是在每次调用的入口处。
┌─────────────────────────────┬───────────────────────────────────┐
│ 问题 │ 答案 │
├─────────────────────────────┼───────────────────────────────────┤
│ 设了 deadline 会自动检测吗? │ ✅ 是的,wasmtime 编译期注入检查点 │
├─────────────────────────────┼───────────────────────────────────┤
│ 超了会立即终止吗? │ ✅ 立即 trap,不等当前指令完成 │
├─────────────────────────────┼───────────────────────────────────┤
│ 性能开销? │ 几乎为零(两条原子读+比较) │
├─────────────────────────────┼───────────────────────────────────┤
│ 能挡住 host 卡死吗? │ ❌ 不能,这是 tokio 的活 │
├─────────────────────────────┼───────────────────────────────────┤
│ 有漏检风险吗? │ ❌ 单调递增,最多延迟一条指令 │
└─────────────────────────────┴───────────────────────────────────┘3. WasmToolLoader(从文件中加载)
/// Loads WASM tools from files or storage into the registry.
pub struct WasmToolLoader {
runtime: Arc<WasmToolRuntime>,
registry: Arc<ToolRegistry>,
secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
role_lookup: Option<Arc<dyn UserStore>>,
}
pub async fn load_from_files(
&self,
name: &str,
wasm_path: &Path,
capabilities_path: Option<&Path>,
) -> Result<(), WasmLoadError> {//仅是加载权限、wasm字节、cap。
会调register_wasm,里面真正编译缓存注册到工具
.wasm 字节 ──wasmtime 编译──> Component (Cranelift IR)
└─ schema/description 从导出函数里抽出来
└─ 包成 PreparedModule
└─ Arc 缓存进 runtimeToolRegistry
pub async fn register_wasm(&self, reg: WasmToolRegistration<'_>) -> Result<(), WasmError> {
// Prepare the module (validates and compiles)
let prepared = reg
.runtime
.prepare(reg.name, reg.wasm_bytes, reg.limits)//这个方法内部编译成组件并缓存了。
.await?;
// Extract credential mappings before capabilities are moved into the wrapper
let credential_mappings: Vec<crate::secrets::CredentialMapping> = reg
.capabilities
.http
.as_ref()
.map(|http| http.credentials.values().cloned().collect())
.unwrap_or_default();
let oauth_refresh = reg.oauth_refresh.clone();
// Create the wrapper
let mut wrapper = WasmToolWrapper::new(Arc::clone(reg.runtime), prepared, reg.capabilities);
// Apply overrides if provided
if let Some(desc) = reg.description {
wrapper = wrapper.with_description(desc);
}
if let Some(s) = reg.schema {
wrapper = wrapper.with_schema(s);
}
if let Some(summary) = reg.discovery_summary {
wrapper = wrapper.with_discovery_summary(summary);
}
if let Some(store) = reg.secrets_store {
wrapper = wrapper.with_secrets_store(store);
}
if let Some(role_lookup) = reg.role_lookup {
wrapper = wrapper.with_role_lookup(role_lookup);
}
if let Some(oauth) = oauth_refresh.clone() {
wrapper = wrapper.with_oauth_refresh(oauth);
}
if let Some(interceptor) = &self.http_interceptor {
wrapper = wrapper.with_http_interceptor(Arc::clone(interceptor));
}
// Register the tool
self.register(Arc::new(wrapper)).await;//注册到工具管理类
// Add credential mappings to the shared registry (for HTTP tool injection)
if let Some(cr) = &self.credential_registry
&& !credential_mappings.is_empty()
{
let count = credential_mappings.len();
cr.add_mappings(credential_mappings);
if let Some(oauth) = oauth_refresh {
cr.add_oauth_refresh_configs(std::iter::once((oauth.secret_name.clone(), oauth)));
}
tracing::debug!(
name = reg.name,
credential_count = count,
"Added credential mappings from WASM tool"
);
}
tracing::debug!(name = reg.name, "Registered WASM tool");
Ok(())
▎ 每次执行完会销毁吗?
▎ - 编译产物(prepared.component)→ 不销毁,永久缓存,下一次复用
▎ - 执行上下文(Store / Linker / Instance)→ 每次新建、执行完 drop(这是 wasmtime 的正确用法,不是浪费)
async fn execute(
&self,
params: serde_json::Value,
ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = Instant::now();
let timeout = self.prepared.limits.timeout;
// Pre-resolve host credentials from secrets store (async, before blocking task).
// This decrypts the secrets once so the sync http_request() host function
// can inject them without needing async access.
let credential_user_id = ctx.user_id.clone();
let resolution = resolve_host_credentials(
&self.capabilities,
self.secrets_store.as_deref(),
&credential_user_id,
self.role_lookup.as_deref(),
self.oauth_refresh.as_ref(),
)
.await;
// Fail closed: if any *required* credential is missing, refuse to
// execute the tool. The previous behavior of silently dropping
// unresolved credentials let a malicious or misconfigured tool
// issue requests without the credentials it declared, which can
// exfiltrate user context to an unauthenticated endpoint.
// Tools that genuinely want graceful degradation must mark the
// mapping `optional = true` in their capabilities manifest.
if !resolution.missing_required.is_empty() {
return Err(ToolError::ExecutionFailed(format!(
"WASM tool '{}' requires credentials that are not configured for user '{}': {}. \
Configure the missing credentials with `ironclaw secrets set` and re-run the tool.",
self.name(),
credential_user_id,
resolution.missing_required.join(", ")
)));
}
let host_credentials = resolution.resolved;
// Serialize context for WASM
let context_json = serde_json::to_string(ctx).ok();
// Clone what we need for the blocking task
let runtime = Arc::clone(&self.runtime);
let prepared = Arc::clone(&self.prepared);
let capabilities = self.capabilities.clone();
let description = self.description.clone();
let schemas = self.schemas.clone();
let discovery_summary = self.discovery_summary.clone();
let credentials = self.credentials.clone();
// Execute in blocking task with timeout
let result = tokio::time::timeout(timeout, async move {
let wrapper = WasmToolWrapper {
runtime,
prepared,
capabilities,
description,
schemas,
discovery_summary,
credentials,
secrets_store: None, // Not needed in blocking task
role_lookup: None,
oauth_refresh: None, // Already used above for pre-refresh
http_interceptor: self.http_interceptor.clone(),
};
tokio::task::spawn_blocking(move || {///真正执行
wrapper.execute_sync(params, context_json, host_credentials)
}) fn execute_sync(
&self,
params: serde_json::Value,
context_json: Option<String>,
host_credentials: Vec<ResolvedHostCredential>,
) -> Result<(String, Vec<crate::tools::wasm::host::LogEntry>), WasmError> {
let engine = self.runtime.engine();
let limits = &self.prepared.limits;
// Create store with fresh state (NEAR pattern: fresh instance per call)
let mut store_data = StoreData::new(
limits.memory_bytes,
self.capabilities.clone(),
self.credentials.clone(),
host_credentials,
);
store_data.http_interceptor = self.http_interceptor.clone();
let mut store = Store::new(engine, store_data);
// Configure fuel if enabled
if self.runtime.config().fuel_config.enabled {
store
.set_fuel(limits.fuel)
.map_err(|e| WasmError::ConfigError(format!("Failed to set fuel: {}", e)))?;
}
// Configure epoch deadline as a hard timeout backup.
// The epoch ticker thread increments the engine epoch every EPOCH_TICK_INTERVAL.
// Setting deadline to N means "trap after N ticks", so we compute the number
// of ticks that fit in the tool's timeout. Minimum 1 to always have a backstop.
store.epoch_deadline_trap();
let ticks = (limits.timeout.as_millis() / EPOCH_TICK_INTERVAL.as_millis()).max(1) as u64;
store.set_epoch_deadline(ticks);
// Set up resource limiter
store.limiter(|data| &mut data.limiter);
// Use the pre-compiled component (no recompilation needed)
let component = self.prepared.component().clone();
// Create linker with all host functions properly namespaced
let mut linker = Linker::new(engine);
Self::add_host_functions(&mut linker)?;
// Instantiate using the generated bindings
let instance =
SandboxedTool::instantiate(&mut store, &component, &linker).map_err(|e| {
let msg = e.to_string();
if msg.contains("near:agent") || msg.contains("import") {
WasmError::InstantiationFailed(format!(
"{msg}. This usually means the extension was compiled against \
a different WIT version than the host supports. \
Rebuild the extension against the current WIT (host: {}).",
crate::tools::wasm::WIT_TOOL_VERSION
))
} else {
WasmError::InstantiationFailed(msg)
}
})?;
// Get typed interface — used for execute.
let tool_iface = instance.near_agent_tool();
// Prepare the request
let params_json = serde_json::to_string(¶ms)
.map_err(|e| WasmError::InvalidResponseJson(e.to_string()))?;
let request = wit_tool::Request {
params: params_json,
context: context_json,
};
// Call execute using the generated typed interface
let response = tool_iface
.call_execute(&mut store, &request)
.map_err(|e| classify_trap_error(e, limits))?;
// Log fuel consumption for diagnostics
if self.runtime.config().fuel_config.enabled
&& let Ok(remaining) = store.get_fuel()
{
let consumed = limits.fuel.saturating_sub(remaining);
let pct = (consumed as f64 / limits.fuel as f64) * 100.0;
tracing::debug!(
tool = %self.prepared.name,
fuel_consumed = consumed,
fuel_remaining = remaining,
fuel_limit = limits.fuel,
fuel_pct = format!("{pct:.1}%"),
"WASM fuel consumption"
);
}
// Get logs from host state
let logs = store.data_mut().host_state.take_logs();
// Check for tool-level error — point the LLM to tool_info for the
// full schema instead of dumping ~3.5KB inline.
if let Some(err) = response.error {
let hint = build_tool_usage_hint(&self.prepared.name, &self.schemas.discovery());
return Err(WasmError::ToolReturnedError { message: err, hint });
}
// Return result (or empty string if none)
Ok((response.output.unwrap_or_default(), logs))
}
- wasmtime 本身没暴露 async API。它的 Linker::instantiate、func.call() 都是 fn。要 async 化得自己包 spawn_blocking,结果和现在一样。
- 强行包成 async 反而更糟:每次调用多一层 Future 状态机开销,热路径上没必要。
为什么不整个 execute 都包在 spawn_blocking 里?
因为 credentials 解析是 async 网络/secrets 调用。如果整体 spawn_blocking,blocking pool 线程会做 async poll,等于把一个 blocking slot 借给 async 任务,浪费线程——tokio::task::spawn_blocking
的线程数是有限资源(默认 512),每浪费一个就少一个给真正的 CPU 密集任务用。
所以"async → sync → async"的形状 = 把 async IO 留在 reactor,把 CPU/阻塞工作放到 blocking pool,是 tokio 的标准模式。
简言之:不是"想变 sync",是 wasmtime 的 API 形状决定了必须这样切。八. 创建Job(todo)
/// Tool for creating a new job.
///
/// When sandbox deps are injected (via `with_sandbox`), the tool automatically
/// delegates execution to a Docker container. Otherwise it creates an in-memory
/// job via the ContextManager. The LLM never needs to know the difference.
pub struct CreateJobTool {