Files
boxmaker/docs/plans/M6b/04-brokerd-consult-tool.md
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

13 KiB
Raw Permalink Blame History

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.

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:

use serde::{Deserialize, Deserializer};

becomes

use serde::{Deserialize, Deserializer, Serialize};

A2. After pub const CONSULT: &str = "consult"; add:

/// `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:

/// 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:

use crate::consult::{ConsultArgs, QUESTION_CAP, cap_bytes};

N2. The comment on ToolName

/// 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

/// 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:

    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:

            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:

        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

            crate::args::ToolName::Recall
            | crate::args::ToolName::Memory
            | crate::args::ToolName::AskMemory => self.read_file_ms,

becomes

            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

        ToolArgs::Recall(_) | ToolArgs::Memory(_) | ToolArgs::AskMemory(_) => Some(None),

becomes

        ToolArgs::Recall(_)
        | ToolArgs::Memory(_)
        | ToolArgs::AskMemory(_)
        | ToolArgs::Consult { .. } => Some(None),

crates/brokerd/src/grants.rs

G1. The end of check_tool_constraints (exactly once)

                    "http_fetch does not take paths",
                );
            }
        }
        _ => {}
    }
}

becomes

                    "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

        ToolArgs::Recall(_) | ToolArgs::Memory(_) | ToolArgs::AskMemory(_) => (Vec::new(), None),

becomes

        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:

/// 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

        // `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

        // `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.

    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.