brokerd: consult tool name, parse, grants

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-10-03 22:20:39 -07:00
co-authored by ornith-1.5-35b-a3b
parent 00d9ea9967
commit d488910351
9 changed files with 186 additions and 7 deletions
+22 -2
View File
@@ -4,6 +4,7 @@
//! path, a host or a URL is well formed. The module is pure: no I/O, no clock. Everything it reads
//! was written by the model, so it is treated as hostile.
use crate::consult::{ConsultArgs, QUESTION_CAP, cap_bytes};
use bxcore::AskMemoryArgs;
use bxcore::tools::{HttpFetchArgs, ReadFileArgs, ShellArgs, WriteFileArgs};
use bxcore::{MemoryArgs, RecallArgs};
@@ -16,8 +17,8 @@ pub const MAX_URL: usize = 2048;
/// Maximum length, in characters, of the detail an invalid call is denied with.
pub const MAX_DETAIL: usize = 300;
/// 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).
/// 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).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolName {
ReadFile,
@@ -27,6 +28,7 @@ pub enum ToolName {
Recall,
Memory,
AskMemory,
Consult,
}
impl ToolName {
@@ -48,6 +50,7 @@ impl ToolName {
"recall" => Some(ToolName::Recall),
"memory" => Some(ToolName::Memory),
"ask_memory" => Some(ToolName::AskMemory),
"consult" => Some(ToolName::Consult),
_ => None,
}
}
@@ -62,6 +65,7 @@ impl ToolName {
ToolName::Recall => "recall",
ToolName::Memory => "memory",
ToolName::AskMemory => "ask_memory",
ToolName::Consult => "consult",
}
}
@@ -92,6 +96,9 @@ pub enum ToolArgs {
Recall(RecallArgs),
Memory(MemoryArgs),
AskMemory(AskMemoryArgs),
Consult {
question: String,
},
}
/// A URL that passed the checks, and its host. Only `parse` makes one, so the host policy matched
@@ -137,6 +144,7 @@ impl ToolArgs {
ToolArgs::Recall(_) => ToolName::Recall,
ToolArgs::Memory(_) => ToolName::Memory,
ToolArgs::AskMemory(_) => ToolName::AskMemory,
ToolArgs::Consult { .. } => ToolName::Consult,
}
}
@@ -179,6 +187,12 @@ impl ToolArgs {
ToolArgs::AskMemory(value) => {
serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string())
}
ToolArgs::Consult { question } => {
let value = ConsultArgs {
question: question.clone(),
};
serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string())
}
}
}
}
@@ -302,6 +316,12 @@ pub fn parse(tool: ToolName, arguments: &str) -> Result<ToolArgs, ArgsError> {
value.check().map_err(ArgsError::Shape)?;
Ok(ToolArgs::AskMemory(value))
}
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 })
}
}
}
+2 -1
View File
@@ -129,7 +129,8 @@ impl Runner {
// `memoryd` runs these; they never reach a container.
crate::args::ToolName::Recall
| crate::args::ToolName::Memory
| crate::args::ToolName::AskMemory => self.read_file_ms,
| crate::args::ToolName::AskMemory
| crate::args::ToolName::Consult => self.read_file_ms,
};
std::time::Duration::from_millis(ms)
}
+53 -1
View File
@@ -5,11 +5,13 @@ use std::ffi::OsString;
use std::time::Duration;
use bxcore::secrets::{Loaded, SecretError, SecretSpec};
use serde::{Deserialize, Deserializer};
use serde::{Deserialize, Deserializer, Serialize};
use crate::config::Config;
pub const CONSULT: &str = "consult";
/// `question` and the answer are cut to this many bytes, on a character boundary.
pub const QUESTION_CAP: usize = 16_384;
/// Default wait for the remote completion, milliseconds.
pub const DEFAULT_TIMEOUT_MS: u64 = 180_000;
/// Default sampling temperature as thousandths (0.6).
@@ -136,6 +138,56 @@ pub fn parse_loopback_http(url: &str) -> Result<(String, u16, String), String> {
Ok((host.to_string(), port, path.to_string()))
}
/// 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()
}
/// Why `brokerd` will not start with `[consult]` set.
#[derive(Debug)]
pub enum SecretStartError {
+6 -1
View File
@@ -36,6 +36,8 @@ pub type Log = Arc<dyn Fn(&str) + Send + Sync>;
/// A memory tool that reached the container runtime: fixed, it reaches the model.
pub const IN_MEMORYD: &str = "the memory tools run in memoryd, not in a container";
/// 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";
pub struct Podman {
runner: Runner,
@@ -243,10 +245,13 @@ impl Runtime for Podman {
/// an egress starts the proxy first and gives the tool the directory. `run_container` is step 3
/// to 7 for either. Already written.
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError> {
// `Routed` sends the memory tools to `memoryd`; one here is refused, never run.
// `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()));
}
let n = self.next.fetch_add(1, Ordering::SeqCst);
let name = podman::container_name(spec.session(), spec.call(), n);
let input = spec.arguments().canonical_json();
+18
View File
@@ -369,6 +369,24 @@ fn check_tool_constraints(
);
}
}
"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(),
);
}
}
_ => {}
}
}
+4 -1
View File
@@ -94,7 +94,10 @@ fn covers(args: &ToolArgs, grant: &Grant) -> Option<Option<String>> {
}
ToolArgs::Shell { cwd: Some(cwd), .. } => best_path(grant, cwd, true),
// The memory tools take no constraints: their grants are built in (M5a spec, section 8).
ToolArgs::Recall(_) | ToolArgs::Memory(_) | ToolArgs::AskMemory(_) => Some(None),
ToolArgs::Recall(_)
| ToolArgs::Memory(_)
| ToolArgs::AskMemory(_)
| ToolArgs::Consult { .. } => Some(None),
ToolArgs::HttpFetch(target) => {
if grant
.constraints
+4 -1
View File
@@ -129,7 +129,10 @@ pub fn run(decision: Decision, runtime: &dyn Runtime) -> bxcore::ToolResponse {
None,
),
ToolArgs::HttpFetch(_) => (Vec::new(), Some(decision.hosts().to_vec())),
ToolArgs::Recall(_) | ToolArgs::Memory(_) | ToolArgs::AskMemory(_) => (Vec::new(), None),
ToolArgs::Recall(_)
| ToolArgs::Memory(_)
| ToolArgs::AskMemory(_)
| ToolArgs::Consult { .. } => (Vec::new(), None),
};
let spec = RunSpec {
session: decision.request().session.clone(),
+76
View File
@@ -0,0 +1,76 @@
//! A `consult` grant: empty constraints, no paths or hosts. Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use brokerd::args::{ToolArgs, ToolName, parse};
use brokerd::grants::load;
use tmp::TempDir;
fn body(rest: &str) -> String {
format!("tool = \"consult\"\nmode = \"auto\"\nmax_taint = \"private\"\n{rest}")
}
#[test]
fn consult_parses_and_is_not_a_container_tool() {
assert_eq!(ToolName::parse("consult"), Some(ToolName::Consult));
assert_eq!(ToolName::Consult.as_str(), "consult");
assert!(!ToolName::Consult.is_memory());
assert!(
!ToolName::ALL.contains(&ToolName::Consult),
"ALL is the container tools"
);
let args = parse(ToolName::Consult, r#"{"question":"hello"}"#).unwrap();
assert_eq!(args.tool(), ToolName::Consult);
assert_eq!(
args,
ToolArgs::Consult {
question: "hello".to_string()
}
);
assert_eq!(args.canonical_json(), r#"{"question":"hello"}"#);
}
#[test]
fn unknown_keys_are_invalid_arguments() {
let err = parse(ToolName::Consult, r#"{"question":"hello","history":[]}"#).unwrap_err();
let detail = err.detail();
assert!(
detail.contains("unknown") || detail.contains("history"),
"{detail}"
);
}
#[test]
fn a_consult_grant_with_empty_constraints_loads() {
let dir = TempDir::new("grants-consult");
dir.write("consult.toml", &body(""));
let set = load(dir.path()).unwrap();
assert_eq!(set.grants()[0].grant.tool, "consult");
assert!(set.grants()[0].grant.untrusted);
}
#[test]
fn consult_does_not_take_paths_or_hosts() {
let dir = TempDir::new("grants-consult-paths");
dir.write("consult.toml", &body("[constraints]\npaths = [\"/tmp\"]\n"));
let problems = load(dir.path()).expect_err("paths");
assert!(
problems
.iter()
.any(|p| p.problem.contains("does not take paths")),
"{problems:?}"
);
let dir = TempDir::new("grants-consult-hosts");
dir.write(
"consult.toml",
&body("[constraints]\nhosts = [\"example.com\"]\n"),
);
let problems = load(dir.path()).expect_err("hosts");
assert!(
problems
.iter()
.any(|p| p.problem.contains("does not take hosts")),
"{problems:?}"
);
}
+1
View File
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|---|---|---|---|---|---|---|---|
| M6b/04-brokerd-consult-tool | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/brokerd/tests/grants_consult.rs` from `docs/plans/M6b/files/` with `cp`. Step 2 failed as expected: E0599 unresolved `ToolName::Consult`/`ConsultArgs`. Added `Serialize` to consult.rs serde import, `QUESTION_CAP` (16_384) after `CONSULT`, and `ConsultArgs`/`cap_bytes`/`question_of`/`wire_body`/`wire_body_parts` after `parse_loopback_http`. In args.rs added `Consult` to `ToolName` and `ToolArgs`, `parse`/`as_str`/`tool`/`canonical_json` arms, and the `use crate::consult::{ConsultArgs, QUESTION_CAP, cap_bytes}` import; did not add it to `is_memory` or `ALL`. config.rs `time_limit` arm uses `read_file_ms` for consult, matching.rs `covers` returns `Some(None)`, runner mounts none/egress none, grants rejects consult paths and hosts (unknown-tool sentence unchanged), container.rs added `IN_CONSULT` and refuses `Consult` in `Podman::run`. `cargo check -p brokerd` clean, no warning; `wc -l`: consult.rs 240, args.rs 395, grants.rs 423, container.rs 386, all under 500. All suites pass: `grants_consult` 4, `grants` 17, `args` 13; the existing `grants.rs` rule_3 still names the four container tools. `cargo fmt --all` formatted consult.rs/args.rs. `make gate` printed `gate: ok` with 1606 tests (1602 before, plus 4). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
| M6b/03-brokerd-consult-config | 2026-10-03 | done | 1 | pass | none | The previous session had already applied every code edit (C1, L1, consult.rs, F1-F3, R1, B1) and left them uncommitted; this session finished them. The two given tests were written into place directly rather than copied via `cp`, but I verified both `tests/config_consult.rs` and `tests/consult_secret.rs` are byte-identical to `docs/plans/M6b/files/`. Ran `cargo build` (Cargo.lock unchanged: the `secrets` feature enables `dep:zeroize`, already locked) and `cargo check -p brokerd` (clean, no warning). `wc -l`: consult.rs 188, config.rs 337, both under 500. Tests: `config` 7, `config_consult` 6, `consult_secret` 3, all pass, including `an_empty_file_gets_every_default`. `cargo fmt --all` changed nothing. `make gate` printed `gate: ok` with 1602 tests (1593 before, plus 9). No new dependency, so Cargo.lock unchanged. | ornith-1.5-35b-a3b |
| M6b/02-loopd-consult-wait | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/broker_port_consult_wait.rs` from `docs/plans/M6b/files/`. Step 2 failed as expected: E0432 unresolved `CONSULT_WAIT`. Added `CONSULT_WAIT` const (180 s) after `ASK_MEMORY_WAIT` in `broker_port.rs`. Added a `consult_timeout` field to the struct and to both `new` and `with_log` constructors (`with_ask_wait` and `with_pending_cap` use `..self` and need no edit). Extended `timeout_for` so `consult` returns `self.timeout.max(self.consult_timeout)` (floor, not replacement); a configured 300 s wait stays 300 s. `ASK_MEMORY_WAIT` and its arm unchanged; `broker_port_ask_wait.rs` and `config.rs` untouched. `cargo check -p loopd` clean, no warning; `wc -l` prints 270, under 500. All suites pass: `broker_port_consult_wait` 1, `broker_port_ask_wait` 4 (`the_wait_for_each_tool` still passes). `make gate` printed `gate: ok` with 1593 tests (1592 before, plus 1). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
| M6b/01-loopd-consult-registry | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/consult_registry.rs` from `docs/plans/M6b/files/`. Created `crates/loopd/src/consult.rs` with `CONSULT` const and `schema()` returning the `consult` `ToolSchema`. Added `pub mod consult;` to lib.rs after `pub mod config;`. In tools.rs `m5c()` pushed the non-core `consult` entry after the `subagent` entry (`Registry::new(entries)` unchanged). `cargo check -p loopd` clean, no warning; `wc -l` prints 21 for consult.rs, 481 for tools.rs, both under 500. All suites pass: `consult_registry` 2, `subagent_registry` 2, `ask_memory_tool`, `tools` 11, `baseline` 6. `make gate` printed `gate: ok` with 1592 tests (1590 before, plus 2). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |