docs: M6b implementation plan

Six closed tasks for consult: registry, CONSULT_WAIT, [consult]/[secrets],
ToolName and grants, Decision body, loopback HTTP. Reference is the git bundle
~/boxmaker-refs/m6b-ref.bundle at 5fb05f0. Gate after task 06 is 1611.

Co-Authored-By: Grok 4.6 <noreply@x.ai>
This commit is contained in:
2026-10-03 21:39:31 -07:00
co-authored by Grok 4.6
parent 09580c705a
commit 50e9cc2037
15 changed files with 2887 additions and 0 deletions
+399
View File
@@ -0,0 +1,399 @@
# M6b task 04: `ToolName::Consult`, parse, grants
**Branch:** `m6b`. Check it: `git branch --show-current` prints `m6b` and `git status --short`
is empty; otherwise stop. Never switch branch.
**Commit subject:** `brokerd: consult tool name, parse, grants`
**Log row:** the Task cell is `M6b/04-brokerd-consult-tool`.
**First action.** Run this before any Read of crate source. Do not `ls`
`crates/brokerd/tests/`. Do not Read `broker.rs`, `ledger/`, `main.rs`,
`docs/implementer-log.md` (except the first 12 lines at the log step). Do not edit
`crates/brokerd/tests/grants.rs` (its `rule_3` unknown-tool sentence stays the four
container tools). The signatures below are complete.
```sh
cp docs/plans/M6b/files/crates/brokerd/tests/grants_consult.rs crates/brokerd/tests/
```
## Goal
`consult` is a `ToolName`. `parse` reads `{"question":…}` with `deny_unknown_fields` and
caps `question` at 16_384 bytes on a character boundary. A grant with empty constraints
loads (`untrusted` still defaults true). Paths or hosts on a consult grant are invalid.
`covers` treats consult like the memory tools (`Some(None)`). `ALL` stays the four
container tools. `is_memory` is false. Unknown tool names still fail with the four-tool
sentence.
## Every place that changes
Adding `ToolName::Consult` makes every exhaustive `match` on `ToolName` or `ToolArgs`
fail to compile until it has an arm. List each match and fill it.
| Place | What happens there |
|---|---|
| `crates/brokerd/src/consult.rs` | `ConsultArgs`, `QUESTION_CAP`, `cap_bytes`, `question_of`, `wire_body` |
| `crates/brokerd/src/args.rs` | `Consult` on `ToolName` and `ToolArgs`; `parse` |
| `crates/brokerd/src/config.rs` | `time_limit` arm (uses `read_file_ms`; consult never hits a container) |
| `crates/brokerd/src/policy/matching.rs` | `covers`: `Some(None)` |
| `crates/brokerd/src/grants.rs` | `check_tool_constraints`: no paths, no hosts |
| `crates/brokerd/src/runner.rs` | mounts none, egress none |
| `crates/brokerd/src/container.rs` | refuse `Consult` with `IN_CONSULT` |
| `crates/brokerd/tests/grants_consult.rs` | new, given |
| everything else | **no change**: not `ledger`, not `broker.rs`, not `tests/grants.rs` |
`memory_port::wait_for` already has `_ =>` and needs no edit.
## Files
- Copy, from `docs/plans/M6b/files/` (the same path under it): the new test
`crates/brokerd/tests/grants_consult.rs`.
- Modify: `crates/brokerd/src/consult.rs`, `crates/brokerd/src/args.rs`,
`crates/brokerd/src/config.rs`, `crates/brokerd/src/policy/matching.rs`,
`crates/brokerd/src/grants.rs`, `crates/brokerd/src/runner.rs`,
`crates/brokerd/src/container.rs`, `docs/implementer-log.md`.
- Do not touch: any other file. Do not edit `crates/brokerd/tests/grants.rs`.
## Facts you need
- **Unknown keys.** `ConsultArgs` has `#[serde(deny_unknown_fields)]`. `parse` maps a
serde error to `ArgsError::Shape`. The given test accepts a detail that contains
`unknown` or `history`.
- **Cap on parse.** `cap_bytes(&value.question, QUESTION_CAP).0` is the stored question.
`floor_char_boundary` then `.get(..boundary)`.
- **`wire_body`** is used by task 05's tests; write it now so those tests can call it.
No history, no `tools` key. When `consult` is `None`, model is `""` and temperature is
0.6.
- **`ALL` is `[ToolName; 4]`.** Do not put `Consult` in it.
- **Rule 3.** `ToolName::parse("consult")` is `Some`, so a consult grant is not "unknown
tool". The unknown-tool sentence in `grants.rs` still lists the four container tools.
Do not edit that string.
- The items the edits use. This table is complete.
| Item | Signature |
|---|---|
| `ToolName::parse` | `parse(name: &str) -> Option<ToolName>` |
| `ToolName::as_str` | `as_str(self) -> &'static str` |
| `ToolName::is_memory` | `is_memory(self) -> bool` |
| `ToolName::ALL` | `[ToolName; 4]` |
| `parse` | `parse(tool: ToolName, arguments: &str) -> Result<ToolArgs, ArgsError>` |
| `ArgsError::Shape` | `Shape(String)` |
| `ArgsError::detail` | `detail(&self) -> String` |
| `Grant.untrusted` | defaults true |
| `str::floor_char_boundary` | `floor_char_boundary(&self, index: usize) -> usize` (Rust 1.98) |
## The edits, exactly
Each quoted "before" block occurs exactly once after task 03.
### `crates/brokerd/src/consult.rs`
**A1.** Add `Serialize` to the serde import:
```rust
use serde::{Deserialize, Deserializer};
```
becomes
```rust
use serde::{Deserialize, Deserializer, Serialize};
```
**A2.** After `pub const CONSULT: &str = "consult";` add:
```rust
/// `question` and the answer are cut to this many bytes, on a character boundary.
pub const QUESTION_CAP: usize = 16_384;
```
**A3.** After `parse_loopback_http` (after its closing `}`, before `SecretStartError`) add:
```rust
/// The model's `consult` arguments. Unknown keys are errors.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConsultArgs {
pub question: String,
}
/// Cut `text` to `cap` bytes on a character boundary.
pub fn cap_bytes(text: &str, cap: usize) -> (String, bool) {
if text.len() <= cap {
return (text.to_string(), false);
}
let boundary = text.floor_char_boundary(cap);
match text.get(..boundary) {
Some(cut) => (cut.to_string(), true),
None => (String::new(), true),
}
}
/// The `question` field of `arguments`, capped, or empty when it is missing or not a string.
pub fn question_of(arguments: &str) -> String {
let extracted = match serde_json::from_str::<serde_json::Value>(arguments) {
Ok(serde_json::Value::Object(obj)) => obj
.get("question")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
_ => String::new(),
};
cap_bytes(&extracted, QUESTION_CAP).0
}
/// The exact JSON body `brokerd` POSTs (and records). No history, no tools.
pub fn wire_body(consult: Option<&Consult>, arguments: &str) -> String {
let model = consult.map(|c| c.model.as_str()).unwrap_or("");
let temperature = consult
.map(Consult::temperature)
.unwrap_or(f64::from(DEFAULT_TEMPERATURE_MILLI) / 1000.0);
wire_body_parts(model, temperature, &question_of(arguments))
}
fn wire_body_parts(model: &str, temperature: f64, question: &str) -> String {
serde_json::json!({
"model": model,
"temperature": temperature,
"messages": [{"role": "user", "content": question}],
})
.to_string()
}
```
### `crates/brokerd/src/args.rs`
**N1.** After the `use bxcore::{MemoryArgs, RecallArgs};` line add:
```rust
use crate::consult::{ConsultArgs, QUESTION_CAP, cap_bytes};
```
**N2.** The comment on `ToolName`
```rust
/// The tools the broker can dispatch to: four that run in a container, and the three memory
/// tools that `memoryd` runs (M5a spec, section 8; M5c spec, section 9).
```
becomes
```rust
/// The tools the broker can dispatch to: four that run in a container, the three memory
/// tools that `memoryd` runs, and `consult` (M6 spec, section 4).
```
**N3.** After `AskMemory,` in `ToolName` add `Consult,`.
**N4.** In `ToolName::parse`, after `"ask_memory" => Some(ToolName::AskMemory),` add
`"consult" => Some(ToolName::Consult),`.
**N5.** In `as_str`, after `ToolName::AskMemory => "ask_memory",` add
`ToolName::Consult => "consult",`.
**N6.** After `AskMemory(AskMemoryArgs),` in `ToolArgs` add:
```rust
Consult {
question: String,
},
```
**N7.** In `tool()`, after `ToolArgs::AskMemory(_) => ToolName::AskMemory,` add
`ToolArgs::Consult { .. } => ToolName::Consult,`.
**N8.** In `canonical_json()`, after the `AskMemory` arm add:
```rust
ToolArgs::Consult { question } => {
let value = ConsultArgs {
question: question.clone(),
};
serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string())
}
```
**N9.** In `parse()`, after the `AskMemory` arm add:
```rust
ToolName::Consult => {
let value: ConsultArgs = serde_json::from_str(arguments)
.map_err(|error| ArgsError::Shape(error.to_string()))?;
let question = cap_bytes(&value.question, QUESTION_CAP).0;
Ok(ToolArgs::Consult { question })
}
```
Do not add `Consult` to `is_memory`. Do not put it in `ALL`.
### `crates/brokerd/src/config.rs`
**T1.** The `time_limit` arm
```rust
crate::args::ToolName::Recall
| crate::args::ToolName::Memory
| crate::args::ToolName::AskMemory => self.read_file_ms,
```
becomes
```rust
crate::args::ToolName::Recall
| crate::args::ToolName::Memory
| crate::args::ToolName::AskMemory
| crate::args::ToolName::Consult => self.read_file_ms,
```
### `crates/brokerd/src/policy/matching.rs`
**M1.** The `covers` arm
```rust
ToolArgs::Recall(_) | ToolArgs::Memory(_) | ToolArgs::AskMemory(_) => Some(None),
```
becomes
```rust
ToolArgs::Recall(_)
| ToolArgs::Memory(_)
| ToolArgs::AskMemory(_)
| ToolArgs::Consult { .. } => Some(None),
```
### `crates/brokerd/src/grants.rs`
**G1.** The end of `check_tool_constraints` (exactly once)
```rust
"http_fetch does not take paths",
);
}
}
_ => {}
}
}
```
becomes
```rust
"http_fetch does not take paths",
);
}
}
"consult" => {
if !grant.constraints.paths.is_empty() {
push(
problems,
file.to_string(),
None,
"consult does not take paths".to_string(),
);
}
if !grant.constraints.hosts.is_empty() {
push(
problems,
file.to_string(),
None,
"consult does not take hosts".to_string(),
);
}
}
_ => {}
}
}
```
Do not edit the unknown-tool sentence.
### `crates/brokerd/src/runner.rs`
**U1.** The mounts arm
```rust
ToolArgs::Recall(_) | ToolArgs::Memory(_) | ToolArgs::AskMemory(_) => (Vec::new(), None),
```
becomes
```rust
ToolArgs::Recall(_)
| ToolArgs::Memory(_)
| ToolArgs::AskMemory(_)
| ToolArgs::Consult { .. } => (Vec::new(), None),
```
### `crates/brokerd/src/container.rs`
**P1.** After `pub const IN_MEMORYD: &str = "the memory tools run in memoryd, not in a container";` add:
```rust
/// A `consult` that reached the container runtime: fixed, it reaches the model.
pub const IN_CONSULT: &str = "consult runs in brokerd, not in a container";
```
**P2.** The comment and body at the start of `Podman::run`
```rust
// `Routed` sends the memory tools to `memoryd`; one here is refused, never run.
if spec.tool().is_memory() {
return Err(RunError::Unavailable(IN_MEMORYD.to_string()));
}
```
become
```rust
// `Routed` sends the memory tools to `memoryd`; `ConsultStack` runs `consult`.
if spec.tool().is_memory() {
return Err(RunError::Unavailable(IN_MEMORYD.to_string()));
}
if spec.tool() == crate::args::ToolName::Consult {
return Err(RunError::Unavailable(IN_CONSULT.to_string()));
}
```
## Steps
- [ ] **1. Copy the given test.**
```sh
cp docs/plans/M6b/files/crates/brokerd/tests/grants_consult.rs crates/brokerd/tests/
```
- [ ] **2. See it fail.** `cargo test -p brokerd --test grants_consult`, timeout 600000
ms: the target does not compile (`E0599` / unresolved `ToolName::Consult` or
`ConsultArgs`).
- [ ] **3. The edits.** A1–A3, N1–N9, T1, M1, G1, U1, P1–P2. After each file,
`cargo check -p brokerd`. When it prints no error and no warning, go on. `wc -l` of
`consult.rs`, `args.rs`, `grants.rs`, `container.rs` each ≤ 500.
- [ ] **4. The tests.** `cargo test -p brokerd --test grants_consult --test grants --test
args`: every test passes, 4 in `grants_consult`. The existing unknown-tool case in
`grants.rs` still names the four container tools.
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last
line `gate: ok`. Test count **1606** (1602 before, plus 4). After `gate: ok`, do not add
up `test result:` lines.
- [ ] **6. Log and commit.** Task cell `M6b/04-brokerd-consult-tool`. Read only the first
12 lines of `docs/implementer-log.md`; insert the row under the header. Stage with
`git add crates/brokerd docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md`
says.
## Done when
- `make gate` prints `gate: ok` with 1606 tests.
- `git diff HEAD~1 --stat` lists only the files in the Files section plus the log.
## Stop and report if
- The branch check fails; a "before" block is missing or duplicated; step 2 compiles; you
edit `tests/grants.rs`; you add `Consult` to `ALL` or `is_memory`; `cargo check` warns;
an existing `grants` or `args` test fails; a test outside the named files fails in the
gate (`git stash -u`, rerun, `git stash pop`, report both).
## What the given tests pin
`ToolName::parse("consult")` is `Consult`; `as_str` is `"consult"`; it is not memory and
not in `ALL`. `parse` of `{"question":"hello"}` yields that question. A `history` key is
invalid-arguments. An empty-constraints consult grant loads with `untrusted` true. Paths
or hosts on the grant are rejected with `does not take paths` / `does not take hosts`.