# 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::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` | | `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::(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`.