brokerd: consult tool name, parse, grants
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user