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(),
|
||||
|
||||
Reference in New Issue
Block a user