1use super::tools::ToolResult;
4use crate::network::{
5 Rail,
6 call::{Outcome, request},
7};
8use serde_json::{Map, Value, json};
9use std::io::Read;
10
11const HEX: &[u8; 16] = b"0123456789abcdef";
12
13#[must_use]
15pub fn tool_entries() -> Vec<Value> {
16 vec![
17 json!({"name":"plan_set","description":"Replace the building service's plan. Only its bound builder may write. Finished steps keep their ordinals and titles.",
18 "inputSchema":{"type":"object","required":["service","steps"],"additionalProperties":false,
19 "properties":{"service":{"type":"string"},"operation":{"type":"string"},"steps":{"type":"array","minItems":1,
20 "items":{"type":"object","required":["title"],"additionalProperties":false,"properties":{"title":{"type":"string"}}}}}}}),
21 json!({"name":"plan_step","description":"Change one ordinal step. At most one step may be doing. The server returns its exact refusal words.",
22 "inputSchema":{"type":"object","required":["service","step","status"],"additionalProperties":false,
23 "properties":{"service":{"type":"string"},"operation":{"type":"string"},"step":{"type":"integer","minimum":1},
24 "status":{"enum":["todo","doing","done","blocked"]},"note":{"type":"string"}}}}),
25 json!({"name":"create_site","description":"Propose a contracts-first construction site. A person approves its draft before the network changes.",
26 "inputSchema":{"type":"object","required":["name","purpose","subjects","contracts"],"additionalProperties":false,
27 "properties":{"name":{"type":"string"},"purpose":{"type":"string"},"operation":{"type":"string"},
28 "subjects":{"type":"array","minItems":1,"items":{"type":"string"}},"contracts":{"type":"object",
29 "additionalProperties":{"type":"object","required":["input_schema_json","output_schema_json"],"additionalProperties":false,
30 "properties":{"input_schema_json":{"type":"string"},"output_schema_json":{"type":"string"}}}}}}}),
31 json!({"name":"bind_builder","description":"Propose exactly one active builder and its site-only plan and trial serve grants.",
32 "inputSchema":{"type":"object","required":["service","agent"],"additionalProperties":false,
33 "properties":{"service":{"type":"string"},"agent":{"type":"string"},"operation":{"type":"string"}}}}),
34 json!({"name":"unbind_builder","description":"Propose revoking both recorded builder grants. Approval is required.",
35 "inputSchema":{"type":"object","required":["service"],"additionalProperties":false,
36 "properties":{"service":{"type":"string"},"operation":{"type":"string"}}}}),
37 json!({"name":"complete","description":"Propose completing a site with an approved template and no registered trial. Approval revokes its trial serve grant.",
38 "inputSchema":{"type":"object","required":["service"],"additionalProperties":false,
39 "properties":{"service":{"type":"string"},"operation":{"type":"string"}}}}),
40 ]
41}
42
43#[must_use]
45pub fn tool<R: Rail>(rail: &R, name: &str, arguments: &Map<String, Value>) -> Option<ToolResult> {
46 if !matches!(
47 name,
48 "plan_set" | "plan_step" | "create_site" | "bind_builder" | "unbind_builder" | "complete"
49 ) {
50 return None;
51 }
52 Some(forward(rail, name, arguments))
53}
54
55pub(super) fn forward<R: Rail>(rail: &R, name: &str, arguments: &Map<String, Value>) -> ToolResult {
56 let mut payload = arguments.clone();
57 let operation = match payload.remove("operation") {
58 Some(Value::String(operation))
59 if !operation.is_empty() && !operation.chars().any(char::is_control) =>
60 {
61 operation
62 }
63 Some(_) => {
64 return ToolResult::failed(
65 "operation must be a nonempty string without controls",
66 json!({"tool":name}),
67 );
68 }
69 None => {
70 let mut bytes = [0_u8; 16];
71 if std::fs::File::open("/dev/urandom")
72 .and_then(|mut file| file.read_exact(&mut bytes))
73 .is_err()
74 {
75 return ToolResult::failed(
76 "operation_randomness_unavailable",
77 json!({"tool":name}),
78 );
79 }
80 let digits: String = bytes
81 .iter()
82 .flat_map(|byte| {
83 [
84 char::from(HEX[usize::from(byte >> 4)]),
85 char::from(HEX[usize::from(byte & 0x0f)]),
86 ]
87 })
88 .collect();
89 format!("op-{digits}")
90 }
91 };
92 let input = json!({"operation":operation,"payload":payload});
93 match request(rail, &format!("services.{name}"), &input, true, None) {
94 Outcome::Reply(answer) => {
95 let Some(bound) = std::num::NonZeroUsize::new(rail.request_bytes()) else {
96 return ToolResult::failed("ticket_request_bound_is_zero", json!({"tool":name}));
97 };
98 match super::held::from_wire(&answer, bound, bound) {
99 Ok(Some(held)) => held,
100 Ok(None) => ToolResult::ok(answer),
101 Err(error) => ToolResult::failed(&error.to_string(), json!({"tool":name})),
102 }
103 }
104 Outcome::Sent => ToolResult::failed("the server sent no reply", json!({"tool":name})),
105 Outcome::Refused(words) => ToolResult::failed(&words, json!({"tool":name})),
106 }
107}
108
109#[cfg(test)]
110#[path = "plan_tools_tests.rs"]
111mod tests;