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
+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()) {