diff --git a/crates/brokerd/src/args.rs b/crates/brokerd/src/args.rs index aa01ce0..15619d6 100644 --- a/crates/brokerd/src/args.rs +++ b/crates/brokerd/src/args.rs @@ -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 { 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 }) + } } } diff --git a/crates/brokerd/src/config.rs b/crates/brokerd/src/config.rs index 98a3ade..eb78bbb 100644 --- a/crates/brokerd/src/config.rs +++ b/crates/brokerd/src/config.rs @@ -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) } diff --git a/crates/brokerd/src/consult.rs b/crates/brokerd/src/consult.rs index fe1c8de..7e92ead 100644 --- a/crates/brokerd/src/consult.rs +++ b/crates/brokerd/src/consult.rs @@ -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::(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 { diff --git a/crates/brokerd/src/container.rs b/crates/brokerd/src/container.rs index 1c7de6d..fc297ec 100644 --- a/crates/brokerd/src/container.rs +++ b/crates/brokerd/src/container.rs @@ -36,6 +36,8 @@ pub type Log = Arc; /// 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 { - // `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(); diff --git a/crates/brokerd/src/grants.rs b/crates/brokerd/src/grants.rs index da97815..cbadc0e 100644 --- a/crates/brokerd/src/grants.rs +++ b/crates/brokerd/src/grants.rs @@ -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(), + ); + } + } _ => {} } } diff --git a/crates/brokerd/src/policy/matching.rs b/crates/brokerd/src/policy/matching.rs index 6876ce1..57c0071 100644 --- a/crates/brokerd/src/policy/matching.rs +++ b/crates/brokerd/src/policy/matching.rs @@ -94,7 +94,10 @@ fn covers(args: &ToolArgs, grant: &Grant) -> Option> { } 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 diff --git a/crates/brokerd/src/runner.rs b/crates/brokerd/src/runner.rs index 9b2730e..010770f 100644 --- a/crates/brokerd/src/runner.rs +++ b/crates/brokerd/src/runner.rs @@ -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(), diff --git a/crates/brokerd/tests/grants_consult.rs b/crates/brokerd/tests/grants_consult.rs new file mode 100644 index 0000000..ee642d3 --- /dev/null +++ b/crates/brokerd/tests/grants_consult.rs @@ -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:?}" + ); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index d9deb89..3e5a772 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -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 |