Files
boxmaker/docs/plans/M6b/04-brokerd-consult-tool.md
T
kyleandGrok 4.6 50e9cc2037 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>
2026-10-03 21:39:31 -07:00

400 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.