Give loopd's tool port approvals, its own clock and plain denials

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-20 17:43:05 -07:00
parent 3ecaef3c8b
commit 1ceaa36b9b
8 changed files with 600 additions and 55 deletions
+1 -1
View File
@@ -108,7 +108,7 @@ fn run_serve(path: &str) -> ExitCode {
cfg,
client,
Box::new(FakeTools::new()),
Registry::m2b(),
Registry::m3a(),
));
if let Err(e) = channel::serve(listener, ctx) {
eprintln!("loopd: the channel server stopped: {e}");
+138 -9
View File
@@ -7,7 +7,20 @@ use crate::llama::ToolSchema;
/// The seam every tool call goes through. `loopd` answers `find_tool` and `call_tool` itself;
/// anything else, including grants, is passed to the port (`brokerd`).
pub trait ToolPort: Send + Sync {
fn call(&self, request: &proto::ToolRequest) -> proto::ToolResponse;
/// Returns the final answer, never `PendingApproval`. If the broker says the call is waiting
/// for the owner, the port calls `on_pending` once and goes on waiting.
fn call(
&self,
request: &proto::ToolRequest,
on_pending: &mut dyn FnMut(&Pending),
) -> proto::ToolResponse;
}
/// One waiting call, as the broker reports it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Pending {
pub approval: u64,
pub expires: proto::Timestamp,
}
#[derive(Debug, Clone, PartialEq)]
@@ -26,7 +39,9 @@ impl Registry {
Registry { entries }
}
/// The tool set for the M2b epoch: `clock` (core) and `echo` (not core).
/// The tool set for the M2b epoch: `clock` (core) and `echo` (not core). This is the test
/// registry: `echo` exists only in `FakeTools` and the recorded conversations, and `loopd
/// serve` never uses it.
pub fn m2b() -> Registry {
Registry::new(vec![
Entry {
@@ -40,6 +55,33 @@ impl Registry {
])
}
/// The tool set for the M3a epoch: `clock` (core), then the four tools `brokerd` decides on.
/// The core entries match `m2b()`, so the tools array is unchanged.
pub fn m3a() -> Registry {
Registry::new(vec![
Entry {
schema: clock_schema(),
core: true,
},
Entry {
schema: read_file_schema(),
core: false,
},
Entry {
schema: write_file_schema(),
core: false,
},
Entry {
schema: shell_schema(),
core: false,
},
Entry {
schema: http_fetch_schema(),
core: false,
},
])
}
/// The fixed tools array: core entries, then `find_tool`, then `call_tool`.
pub fn core_schemas(&self) -> Vec<ToolSchema> {
let mut schemas: Vec<ToolSchema> = self
@@ -76,6 +118,7 @@ impl Registry {
pub const FIND_TOOL: &str = "find_tool";
pub const CALL_TOOL: &str = "call_tool";
pub const CLOCK: &str = "clock";
pub fn clock_schema() -> ToolSchema {
ToolSchema {
@@ -102,6 +145,65 @@ pub fn echo_schema() -> ToolSchema {
}
}
pub fn read_file_schema() -> ToolSchema {
ToolSchema {
name: "read_file".to_string(),
description: "Read a text file. Needs a grant from the owner for the path.".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "The absolute path of the file." }
},
"required": ["path"]
}),
}
}
pub fn write_file_schema() -> ToolSchema {
ToolSchema {
name: "write_file".to_string(),
description: "Write a text file, replacing it. Needs a grant from the owner for the path."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "The absolute path of the file." },
"content": { "type": "string", "description": "The whole new content of the file." }
},
"required": ["path", "content"]
}),
}
}
pub fn shell_schema() -> ToolSchema {
ToolSchema {
name: "shell".to_string(),
description: "Run a shell command in a sandbox. Needs a grant from the owner.".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"command": { "type": "string", "description": "The command line to run." },
"cwd": { "type": "string", "description": "The absolute path of the directory to run it in." }
},
"required": ["command"]
}),
}
}
pub fn http_fetch_schema() -> ToolSchema {
ToolSchema {
name: "http_fetch".to_string(),
description: "Fetch an https URL. Needs a grant from the owner for the host.".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"url": { "type": "string", "description": "The URL. It must start with https://" }
},
"required": ["url"]
}),
}
}
fn find_tool_schema() -> ToolSchema {
ToolSchema {
name: "find_tool".to_string(),
@@ -157,6 +259,9 @@ pub fn dispatch(registry: &Registry, name: &str, arguments: &str) -> Dispatch {
if name == CALL_TOOL {
return dispatch_call_tool(registry, arguments);
}
if name == CLOCK {
return Dispatch::Local(proto::Timestamp::now().to_rfc3339());
}
Dispatch::Port {
tool: name.to_string(),
arguments: arguments.to_string(),
@@ -224,6 +329,32 @@ pub fn cap_result(text: &str, cap: usize) -> (String, bool) {
(result, true)
}
/// The one fixed sentence a denial reaches the model as, named by its reason.
pub fn denial_text(reason: proto::DenyReason) -> &'static str {
match reason {
proto::DenyReason::NoGrant => "Denied: no grant allows this call.",
proto::DenyReason::GrantExpired => "Denied: the grant for this call has expired.",
proto::DenyReason::TaintTooHigh => {
"Denied: this session has seen data too sensitive for this call."
}
proto::DenyReason::DeniedByGrant => "Denied: a grant forbids this call.",
proto::DenyReason::ApprovalRefused => "Denied: the owner refused this call.",
proto::DenyReason::ApprovalExpired => {
"Denied: the approval request expired without an answer."
}
proto::DenyReason::InvalidArguments => "Denied: the arguments are not valid for this tool.",
proto::DenyReason::GrantsInvalid => {
"Denied: the grant files have an error; the owner has been told."
}
proto::DenyReason::AuditUnavailable => {
"Denied: the audit log cannot be written; the owner has been told."
}
proto::DenyReason::StateUnreadable => {
"Denied: this session's broker state is damaged; the owner has been told."
}
}
}
pub struct FakeTools {
calls: Mutex<Vec<proto::ToolRequest>>,
}
@@ -247,19 +378,17 @@ impl FakeTools {
}
impl ToolPort for FakeTools {
fn call(&self, request: &proto::ToolRequest) -> proto::ToolResponse {
fn call(
&self,
request: &proto::ToolRequest,
_on_pending: &mut dyn FnMut(&Pending),
) -> proto::ToolResponse {
self.calls
.lock()
.unwrap_or_else(|p| p.into_inner())
.push(request.clone());
match request.tool.as_str() {
"clock" => proto::ToolResponse::Result {
content: proto::Timestamp::now().to_rfc3339(),
class: proto::DataClass::Public,
untrusted: false,
truncated: false,
},
"echo" => match serde_json::from_str::<serde_json::Value>(&request.arguments) {
Ok(serde_json::Value::Object(obj)) => {
match obj.get("text").and_then(|v| v.as_str()) {
+19 -10
View File
@@ -7,7 +7,7 @@ use crate::config::Config;
use crate::llama::info::{CacheOutcome, cache_outcome};
use crate::llama::{ChatEvent, ChatRequest, Client, InferError};
use crate::session::{Session, SessionError};
use crate::tools::{Registry, ToolPort, cap_result, dispatch};
use crate::tools::{Registry, ToolPort, cap_result, denial_text, dispatch};
use proto::{CallId, DataClass, LogRecord, Timestamp, ToolRequest, ToolResponse, TurnEvent, Usage};
/// What one turn can fail with, beyond the inference server.
@@ -230,7 +230,7 @@ pub fn run_turn(
false,
)
} else {
run_call(rt, session, call, call_id)
run_call(rt, session, call, call_id, on_event)
};
let (text, truncated) = cap_result(&text, rt.cfg.r#loop.tool_result_cap);
@@ -260,6 +260,7 @@ fn run_call(
session: &Session,
call: &proto::ToolCall,
call_id: CallId,
on_event: &mut dyn FnMut(&TurnEvent),
) -> (String, DataClass, bool) {
match dispatch(rt.registry, &call.name, &call.arguments) {
crate::tools::Dispatch::Local(text) => (text, DataClass::Public, false),
@@ -270,7 +271,14 @@ fn run_call(
tool,
arguments,
};
match rt.port.call(&request) {
let response = rt.port.call(&request, &mut |pending| {
on_event(&TurnEvent::ApprovalPending {
approval: pending.approval,
tool: request.tool.clone(),
expires: pending.expires,
});
});
match response {
ToolResponse::Result {
content,
class,
@@ -282,14 +290,15 @@ fn run_call(
DataClass::Public,
false,
),
ToolResponse::Denied { reason } => (
format!("The call was denied: {reason:?}"),
DataClass::Public,
false,
),
ToolResponse::Denied { reason } => {
on_event(&TurnEvent::ToolDenied {
name: request.tool.clone(),
reason,
});
(denial_text(reason).to_string(), DataClass::Public, false)
}
ToolResponse::PendingApproval { .. } => (
"This version of loopd cannot wait for approval; the call was rejected."
.to_string(),
"The tool failed: the tool broker gave no final answer".to_string(),
DataClass::Public,
false,
),