Task files, the files they copy in (byte-identical to the reference on m3a-ref), each area's check record, and a README with the per-task table of what each check exposed. The handoff note is done with. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
149 lines
7.9 KiB
Markdown
149 lines
7.9 KiB
Markdown
# M3a task 16: the tool port, the registry and denials in `loopd`
|
|
|
|
**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop)
|
|
**Commit subject:** `Give loopd's tool port approvals, its own clock and plain denials`
|
|
|
|
## Goal
|
|
|
|
Get `loopd`'s tool path ready for `brokerd`, without a socket yet: the port can report a pending
|
|
approval, `clock` is answered by `loopd` itself, the registry offers the four tools `brokerd`
|
|
decides on, and a denial reaches the model as one fixed sentence and the owner as an event.
|
|
|
|
Nothing here may change an earlier byte of any session's prompt. It does not: the `tools` array
|
|
(`core_schemas()`) stays `clock`, `find_tool`, `call_tool` with the same schemas, and a test checks
|
|
that. The registry change only alters what a *new* `find_tool` call returns.
|
|
|
|
## Files
|
|
|
|
- Copy: `crates/loopd/tests/tools.rs`, `crates/loopd/tests/turn.rs`,
|
|
`crates/loopd/tests/turn_broker.rs`, `crates/loopd/tests/support/mod.rs`
|
|
- Modify: `crates/loopd/src/tools.rs`, `crates/loopd/src/turn.rs`, `crates/loopd/src/main.rs`,
|
|
`docs/implementer-log.md`
|
|
|
|
## Interfaces (`tools.rs`)
|
|
|
|
```rust
|
|
pub trait ToolPort: Send + Sync {
|
|
/// Returns the final answer, never `PendingApproval`. If the broker says the call is waiting
|
|
/// for the owner, the port calls `on_pending` once and goes on waiting.
|
|
fn call(&self, request: &proto::ToolRequest, on_pending: &mut dyn FnMut(&Pending))
|
|
-> proto::ToolResponse;
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct Pending { pub approval: u64, pub expires: proto::Timestamp }
|
|
|
|
pub const CLOCK: &str = "clock";
|
|
impl Registry { pub fn m3a() -> Registry; } // m2b() stays: the tests use it
|
|
pub fn read_file_schema() -> ToolSchema; // and write_file_, shell_, http_fetch_
|
|
pub fn denial_text(reason: proto::DenyReason) -> &'static str;
|
|
```
|
|
|
|
## Rules
|
|
|
|
1. **`Registry::m3a()`**: five entries in this order: `clock` (`core: true`, `clock_schema()`
|
|
unchanged), then `read_file`, `write_file`, `shell`, `http_fetch` (`core: false`). Keep
|
|
`Registry::m2b()`; change its doc comment to say it is the test registry (`echo` exists only in
|
|
`FakeTools` and the recorded conversations) and that `loopd serve` never uses it.
|
|
2. **The four schemas.** Every property is `{"type": "string", "description": "…"}`. Use exactly
|
|
these descriptions; the tests search them.
|
|
|
|
| Tool | Description | Properties (description) | `required` |
|
|
|---|---|---|---|
|
|
| `read_file` | Read a text file. Needs a grant from the owner for the path. | `path` (The absolute path of the file.) | `["path"]` |
|
|
| `write_file` | Write a text file, replacing it. Needs a grant from the owner for the path. | `path` (The absolute path of the file.), `content` (The whole new content of the file.) | `["path", "content"]` |
|
|
| `shell` | Run a shell command in a sandbox. Needs a grant from the owner. | `command` (The command line to run.), `cwd` (The absolute path of the directory to run it in.) | `["command"]` |
|
|
| `http_fetch` | Fetch an https URL. Needs a grant from the owner for the host. | `url` (The URL. It must start with https://.) | `["url"]` |
|
|
|
|
One of them in full, as the pattern for the rest:
|
|
|
|
```rust
|
|
pub fn shell_schema() -> ToolSchema {
|
|
ToolSchema {
|
|
name: "shell".to_string(),
|
|
description: "Run a shell command in a sandbox. Needs a grant from the owner.".to_string(),
|
|
parameters: serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"command": { "type": "string", "description": "The command line to run." },
|
|
"cwd": { "type": "string", "description": "The absolute path of the directory to run it in." }
|
|
},
|
|
"required": ["command"]
|
|
}),
|
|
}
|
|
}
|
|
```
|
|
3. **`clock` is local.** In `dispatch`, after the `find_tool` and `call_tool` checks: if
|
|
`name == CLOCK`, return `Dispatch::Local(proto::Timestamp::now().to_rfc3339())`, whatever the
|
|
arguments are and whatever the registry holds. The time is not authority and needs no broker.
|
|
It is volatile, which is fine here: a tool result is always the newest message. Remove the
|
|
`"clock"` arm from `FakeTools::call`; `echo` stays.
|
|
4. **`denial_text`**: one `match` with no `_` arm, exactly these sentences:
|
|
|
|
| Reason | Sentence |
|
|
|---|---|
|
|
| `NoGrant` | Denied: no grant allows this call. |
|
|
| `GrantExpired` | Denied: the grant for this call has expired. |
|
|
| `TaintTooHigh` | Denied: this session has seen data too sensitive for this call. |
|
|
| `DeniedByGrant` | Denied: a grant forbids this call. |
|
|
| `ApprovalRefused` | Denied: the owner refused this call. |
|
|
| `ApprovalExpired` | Denied: the approval request expired without an answer. |
|
|
| `InvalidArguments` | Denied: the arguments are not valid for this tool. |
|
|
| `GrantsInvalid` | Denied: the grant files have an error; the owner has been told. |
|
|
| `AuditUnavailable` | Denied: the audit log cannot be written; the owner has been told. |
|
|
| `StateUnreadable` | Denied: this session's broker state is damaged; the owner has been told. |
|
|
5. **`run_call` in `turn.rs`** gains a last parameter `on_event: &mut dyn FnMut(&TurnEvent)`;
|
|
`run_turn` passes its own `on_event`. In the `Dispatch::Port` arm, build the `ToolRequest` as
|
|
now, then:
|
|
|
|
```rust
|
|
let response = rt.port.call(&request, &mut |pending| {
|
|
on_event(&TurnEvent::ApprovalPending {
|
|
approval: pending.approval,
|
|
tool: request.tool.clone(),
|
|
expires: pending.expires,
|
|
});
|
|
});
|
|
```
|
|
|
|
Then match `response`. There are four arms and each one returns `(text, class, untrusted)`:
|
|
- `Result { content, class, untrusted, .. }`: as now.
|
|
- `Failed { message }`: as now, `"The tool failed: {message}"`, `Public`, `false`. No event.
|
|
- `Denied { reason }`: first `on_event(&TurnEvent::ToolDenied { name: request.tool.clone(),
|
|
reason })`, then `(denial_text(reason).to_string(), Public, false)`. The old
|
|
`"The call was denied: {reason:?}"` text goes away.
|
|
- `PendingApproval { .. }`: `"The tool failed: the tool broker gave no final answer"`,
|
|
`Public`, `false`, no event. A pending frame is never a final answer; a port that returns
|
|
one has failed, and that is not a decision about the call. Replace the old text.
|
|
|
|
Both events carry `request.tool`, the tool `brokerd` decides on, not `call.name`: for a
|
|
`call_tool` call the owner must see `read_file`, because that is what grants are written for.
|
|
The existing `ToolCallStarted` and `ToolResult` events keep `call.name`. The `Dispatch::Local`
|
|
arm and the "already called" repeat path send neither new event.
|
|
6. **`main.rs`**: `Registry::m2b()` becomes `Registry::m3a()`. `FakeTools` stays until task 17.
|
|
7. `FakeTools::call` takes the new parameter and ignores it (`_on_pending`).
|
|
|
|
## Steps
|
|
|
|
- [ ] **1. Copy.** `git switch m3a`, then
|
|
`cp docs/plans/M3a/files/crates/loopd/tests/{tools,turn,turn_broker}.rs crates/loopd/tests/` and
|
|
`cp docs/plans/M3a/files/crates/loopd/tests/support/mod.rs crates/loopd/tests/support/`
|
|
- [ ] **2. See the tests fail.** `cargo test -p loopd --test tools`. Expected: it does not compile.
|
|
- [ ] **3. Change `tools.rs`** (rules 1 to 4 and 7), then `turn.rs` (rule 5), then `main.rs`.
|
|
Run `cargo fmt --all`.
|
|
- [ ] **4. See the tests pass.**
|
|
`cargo test -p loopd --test tools --test turn --test turn_broker --test limits --test channel`.
|
|
Expected: `10 passed`, `6 passed`, `5 passed`, `9 passed`, `6 passed`.
|
|
- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`.
|
|
- [ ] **6. Log and commit.** `git add crates/loopd docs/implementer-log.md && git commit`
|
|
|
|
## Done when
|
|
|
|
- The five test files in step 4 report those counts, and `make gate` prints `gate: ok`.
|
|
|
|
## Stop and report if
|
|
|
|
- `the_m3a_registry_has_the_same_core_and_the_four_broker_tools` fails on `core_schemas()`: the
|
|
baseline would change, and that must not be worked around.
|
|
- A test needs `loopd` to parse or check a tool's arguments. It must not: `brokerd` does that.
|