Skip to content

Commit 2f9f7bc

Browse files
feat(sparsekernel): add tool call broker
1 parent 1874315 commit 2f9f7bc

7 files changed

Lines changed: 675 additions & 2 deletions

File tree

crates/sparsekernel-cli/src/lib.rs

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ use serde::Deserialize;
55
use serde_json::{json, Value};
66
use sparsekernel_core::{
77
probe_browser_endpoint, ArtifactStore, AuditInput, BrowserBroker, CapabilityCheck,
8-
EnqueueTaskInput, GrantCapabilityInput, MockBrowserBroker, SparseKernelDb, SparseKernelPaths,
8+
CompleteToolCallInput, CreateToolCallInput, EnqueueTaskInput, GrantCapabilityInput,
9+
LedgerToolBroker, MockBrowserBroker, SparseKernelDb, SparseKernelPaths, ToolBroker,
910
};
1011
use std::error::Error;
1112
use std::net::ToSocketAddrs;
@@ -272,6 +273,25 @@ struct ProbeBrowserPoolRequest {
272273
cdp_endpoint: String,
273274
}
274275

276+
#[derive(Debug, Deserialize)]
277+
struct ToolCallIdRequest {
278+
id: String,
279+
}
280+
281+
#[derive(Debug, Deserialize)]
282+
struct CompleteToolCallRequest {
283+
id: String,
284+
output: Option<Value>,
285+
#[serde(default)]
286+
artifact_ids: Vec<String>,
287+
}
288+
289+
#[derive(Debug, Deserialize)]
290+
struct FailToolCallRequest {
291+
id: String,
292+
error: String,
293+
}
294+
275295
fn parse_body<T: for<'de> Deserialize<'de>>(body: &[u8]) -> Result<T, Box<dyn Error>> {
276296
if body.is_empty() {
277297
return Err("request body is required".into());
@@ -374,6 +394,48 @@ pub fn handle_api_request_with_artifact_root(
374394
status_code: 200,
375395
body: serde_json::to_value(db.list_tasks(100)?)?,
376396
},
397+
("GET", "/tool-calls") => ApiReply {
398+
status_code: 200,
399+
body: serde_json::to_value(db.list_tool_calls(100)?)?,
400+
},
401+
("POST", "/tool-calls/create") => {
402+
let input: CreateToolCallInput = parse_body(body)?;
403+
let broker = LedgerToolBroker { db };
404+
ApiReply {
405+
status_code: 200,
406+
body: serde_json::to_value(broker.create_call(input)?)?,
407+
}
408+
}
409+
("POST", "/tool-calls/start") => {
410+
let input: ToolCallIdRequest = parse_body(body)?;
411+
let broker = LedgerToolBroker { db };
412+
ApiReply {
413+
status_code: 200,
414+
body: serde_json::to_value(broker.start_call(&input.id)?)?,
415+
}
416+
}
417+
("POST", "/tool-calls/complete") => {
418+
let input: CompleteToolCallRequest = parse_body(body)?;
419+
let broker = LedgerToolBroker { db };
420+
ApiReply {
421+
status_code: 200,
422+
body: serde_json::to_value(broker.complete_call(
423+
&input.id,
424+
CompleteToolCallInput {
425+
output: input.output,
426+
artifact_ids: input.artifact_ids,
427+
},
428+
)?)?,
429+
}
430+
}
431+
("POST", "/tool-calls/fail") => {
432+
let input: FailToolCallRequest = parse_body(body)?;
433+
let broker = LedgerToolBroker { db };
434+
ApiReply {
435+
status_code: 200,
436+
body: serde_json::to_value(broker.fail_call(&input.id, &input.error)?)?,
437+
}
438+
}
377439
("GET", "/browser/contexts") => ApiReply {
378440
status_code: 200,
379441
body: serde_json::to_value(db.list_browser_contexts(100)?)?,
@@ -756,6 +818,85 @@ mod tests {
756818
assert_eq!(revoked["revoked"], true);
757819
}
758820

821+
#[test]
822+
fn tool_call_api_tracks_lifecycle_artifacts_and_denials() {
823+
let mut db = SparseKernelDb::open(":memory:").unwrap();
824+
let artifact_root = tempfile::tempdir().unwrap();
825+
let artifact = ArtifactStore::new(&db, artifact_root.path())
826+
.write(b"pixels", Some("image/png"), Some("debug"), None)
827+
.unwrap();
828+
db.grant_capability(GrantCapabilityInput {
829+
subject_type: "agent".to_string(),
830+
subject_id: "main".to_string(),
831+
resource_type: "tool".to_string(),
832+
resource_id: Some("browser.capture".to_string()),
833+
action: "invoke".to_string(),
834+
constraints: None,
835+
expires_at: None,
836+
})
837+
.unwrap();
838+
839+
let denied = handle_api_request(
840+
&mut db,
841+
"POST",
842+
"/tool-calls/create",
843+
serde_json::to_string(&json!({
844+
"agent_id": "main",
845+
"tool_name": "exec",
846+
}))
847+
.unwrap()
848+
.as_bytes(),
849+
);
850+
assert!(denied.is_err());
851+
852+
let created = json_call(
853+
&mut db,
854+
"POST",
855+
"/tool-calls/create",
856+
json!({
857+
"id": "tool-call-a",
858+
"agent_id": "main",
859+
"tool_name": "browser.capture",
860+
"input": { "url": "https://example.com" },
861+
}),
862+
);
863+
assert_eq!(created["status"], "created");
864+
865+
let started = json_call(
866+
&mut db,
867+
"POST",
868+
"/tool-calls/start",
869+
json!({ "id": "tool-call-a" }),
870+
);
871+
assert_eq!(started["status"], "running");
872+
873+
let completed = json_call(
874+
&mut db,
875+
"POST",
876+
"/tool-calls/complete",
877+
json!({
878+
"id": "tool-call-a",
879+
"output": { "ok": true },
880+
"artifact_ids": [artifact.id.clone()],
881+
}),
882+
);
883+
assert_eq!(completed["status"], "completed");
884+
assert_eq!(completed["output"]["artifact_ids"][0], artifact.id);
885+
886+
let calls = handle_api_request(&mut db, "GET", "/tool-calls", &[])
887+
.unwrap()
888+
.body;
889+
assert_eq!(calls.as_array().unwrap().len(), 1);
890+
let actions: Vec<String> = db
891+
.list_audit(10)
892+
.unwrap()
893+
.into_iter()
894+
.map(|event| event.action)
895+
.collect();
896+
assert!(actions.contains(&"tool_call.denied".to_string()));
897+
assert!(actions.contains(&"tool_call.completed".to_string()));
898+
}
899+
759900
#[test]
760901
fn browser_api_acquires_lists_and_releases_contexts() {
761902
let mut db = SparseKernelDb::open(":memory:").unwrap();

0 commit comments

Comments
 (0)