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>
400 lines
13 KiB
Markdown
400 lines
13 KiB
Markdown
# 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`.
|