@@ -5,7 +5,8 @@ use serde::Deserialize;
55use serde_json:: { json, Value } ;
66use 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} ;
1011use std:: error:: Error ;
1112use 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+
275295fn 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