Skip to main content

quorum_rs/cli/
request.rs

1use serde::Serialize;
2
3use crate::cli::workspace::PolicyConfig;
4
5/// Generate a random u64 from OS entropy (no extra deps).
6fn rand_u64() -> u64 {
7    use std::hash::{BuildHasher, Hasher};
8    std::collections::hash_map::RandomState::new()
9        .build_hasher()
10        .finish()
11}
12
13/// Deliberation request matching the orchestrator's POST /deliberation JSON contract.
14#[derive(Debug, Serialize)]
15pub struct DeliberationRequest {
16    pub room_id: String,
17    /// Stable conversation key (the thread id) shared across a thread's turns —
18    /// the `room_id` above carries a per-run nonce, so this is what lets the
19    /// agents resume the same claude session turn to turn. `None` for ad-hoc runs.
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub conversation_id: Option<String>,
22    pub user_query: String,
23    pub deliberation_rounds: u32,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub agent_names: Option<Vec<String>>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub policy_id: Option<String>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub effort: Option<f32>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub scope: Option<String>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub timeout_seconds: Option<u64>,
34    /// Human-in-the-loop tools offered to the agents. The TUI ships `ask_user`
35    /// so an agent can ask the operator a clarifying question mid-deliberation
36    /// (and the TUI surfaces it + posts the answer back).
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub user_tools: Option<Vec<crate::agents::UserToolDefinition>>,
39    /// The new turn only (this send's message). Sent so a resumed thread session's
40    /// delta prompt carries just this instead of the whole flattened `user_query`
41    /// (which the session already holds). `None` for the first turn / non-thread.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub new_turn: Option<String>,
44}
45
46/// The `ask_user` HITL tool: an agent asks the operator a clarifying question,
47/// optionally offering a short list of `options` for a quick choice (they may
48/// also answer freely). Request-response — the agent blocks on the answer (up to
49/// its finalization reserve).
50pub fn ask_user_tool() -> crate::agents::UserToolDefinition {
51    crate::agents::UserToolDefinition {
52        name: "ask_user".to_string(),
53        description: "Ask the human operator a question and WAIT for their answer. \
54             This tool is the ONLY channel to the operator — a question written \
55             anywhere else (your proposal `reply`, a commit message, prose) is never \
56             delivered and never answered, so you must call THIS tool to actually ask. \
57             Use it when you genuinely need their input to proceed: ambiguous \
58             requirements, a decision only they can make, or missing context. \
59             Optionally pass a short `options` list for a quick choice; they may also \
60             answer freely. Use sparingly — only when their answer materially changes \
61             your output."
62            .to_string(),
63        parameters: Some(serde_json::json!({
64            "type": "object",
65            "properties": {
66                "question": {
67                    "type": "string",
68                    "description": "The question to ask the operator."
69                },
70                "options": {
71                    "type": "array",
72                    "items": { "type": "string" },
73                    "description": "Optional short list of choices to offer."
74                }
75            },
76            "required": ["question"]
77        })),
78        strict: Some(false),
79    }
80}
81
82/// Build a `DeliberationRequest` from a raw policy_id hash (ad-hoc run).
83///
84/// Used when the user passes a 64-char hex policy_id directly via `--policy`,
85/// bypassing local policy lookup. Uses defaults for rounds/effort since
86/// the orchestrator owns the policy config.
87pub fn build_request_raw_policy_id(policy_id: &str, task: &str) -> DeliberationRequest {
88    let nonce: u64 = rand_u64();
89    let room_id = format!("adhoc_{nonce:016x}");
90
91    DeliberationRequest {
92        room_id,
93        conversation_id: None,
94        user_query: task.to_string(),
95        deliberation_rounds: 3,
96        agent_names: None,
97        policy_id: Some(policy_id.to_string()),
98        effort: None,
99        scope: None,
100        timeout_seconds: None,
101        user_tools: Some(vec![ask_user_tool()]),
102        new_turn: None,
103    }
104}
105
106/// Build a `DeliberationRequest` from workspace policy config.
107///
108/// - Static policies (`agents` field) → sends `agent_names` directly.
109/// - Role-based policies (`roles` field) → computes `policy_id` (content hash)
110///   and sends it to the orchestrator for server-side agent resolution.
111pub fn build_request(
112    room_name: &str,
113    policy: &PolicyConfig,
114    task: &str,
115) -> Result<DeliberationRequest, String> {
116    let (agent_names, policy_id) = if let Some(agents) = &policy.agents {
117        if agents.len() < 2 {
118            return Err("policy must specify at least two agents for deliberation".to_string());
119        }
120        (Some(agents.clone()), None)
121    } else if policy.roles.is_some() {
122        let id = policy.policy_id();
123        (None, Some(id))
124    } else {
125        return Err("policy must specify either agents or roles".to_string());
126    };
127
128    // Forward the policy SLA's whole-job budget verbatim as the client-side
129    // wall-clock deadline. `job_timeout_secs` is the JIT contract — "when
130    // will this query be guaranteed to answer" — so adding an overhead
131    // buffer here would silently extend that contract; any overhead budget
132    // (e.g. HITL release time) must be carved out of the whole-job envelope
133    // itself, not bolted on top. `PolicySla::job_timeout()` also maps the
134    // `0` sentinel to `None` so we never forward a fake deadline.
135    let timeout_seconds = policy
136        .sla
137        .as_ref()
138        .and_then(|sla| sla.job_timeout())
139        .map(|d| d.as_secs());
140
141    // Generate a unique room_id per run to avoid 409 conflicts.
142    // Format: {room_name}_{nonce} — human-readable prefix + random suffix.
143    let nonce: u64 = rand_u64();
144    let room_id = format!("{room_name}_{nonce:016x}");
145
146    Ok(DeliberationRequest {
147        room_id,
148        // The pre-nonce room name is the stable thread key — carry it so every
149        // turn of the thread resumes the same claude session.
150        conversation_id: Some(room_name.to_string()),
151        user_query: task.to_string(),
152        deliberation_rounds: policy.max_rounds,
153        agent_names,
154        policy_id,
155        effort: Some(policy.effort),
156        scope: None,
157        timeout_seconds,
158        user_tools: Some(vec![ask_user_tool()]),
159        new_turn: None,
160    })
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::cli::workspace::PolicyConfig;
167    use crate::scheduling::PolicySla;
168
169    fn static_policy() -> PolicyConfig {
170        PolicyConfig {
171            agents: Some(vec!["agent-a".into(), "agent-b".into()]),
172            roles: None,
173            max_rounds: 3,
174            effort: 0.85,
175            sla: None,
176            capabilities: None,
177            tags: None,
178            mode: Default::default(),
179        }
180    }
181
182    fn roles_policy() -> PolicyConfig {
183        PolicyConfig {
184            agents: None,
185            roles: Some(vec![crate::cli::workspace::RoleConfig {
186                role: "reviewer".into(),
187                count: 2,
188                capabilities: vec!["lang:rust".into()],
189                context: None,
190                pinned_agents: None,
191                moderator: false,
192            }]),
193            max_rounds: 3,
194            effort: 0.85,
195            sla: None,
196            capabilities: None,
197            tags: None,
198            mode: Default::default(),
199        }
200    }
201
202    #[test]
203    fn requests_offer_the_ask_user_tool() {
204        let req = build_request("r", &static_policy(), "q").unwrap();
205        let tools = req.user_tools.expect("ships ask_user");
206        assert_eq!(tools.len(), 1);
207        assert_eq!(tools[0].name, "ask_user");
208        // question required, options optional — expressed in the JSON schema.
209        let params = tools[0].parameters.as_ref().unwrap();
210        assert_eq!(params["required"][0], "question");
211        assert!(params["properties"]["options"].is_object());
212        // Ad-hoc requests carry it too.
213        assert!(
214            build_request_raw_policy_id("p", "q")
215                .user_tools
216                .is_some_and(|t| t[0].name == "ask_user")
217        );
218    }
219
220    #[test]
221    fn build_request_static_agents() {
222        let req = build_request("my-room", &static_policy(), "audit this code").unwrap();
223        assert!(
224            req.room_id.starts_with("my-room_"),
225            "room_id should be prefixed with room name, got: {}",
226            req.room_id
227        );
228        assert_eq!(req.user_query, "audit this code");
229        assert_eq!(req.deliberation_rounds, 3);
230        assert_eq!(
231            req.agent_names.as_deref(),
232            Some(&["agent-a".to_string(), "agent-b".to_string()][..])
233        );
234        assert!(req.policy_id.is_none(), "static should not send policy_id");
235        assert_eq!(req.effort, Some(0.85));
236        assert!(req.scope.is_none());
237        assert!(req.timeout_seconds.is_none());
238    }
239
240    #[test]
241    fn build_request_single_agent_rejected() {
242        let mut policy = static_policy();
243        policy.agents = Some(vec!["only-one".into()]);
244        let err = build_request("room", &policy, "task").unwrap_err();
245        assert!(
246            err.contains("at least two"),
247            "expected min-agents error, got: {err}"
248        );
249    }
250
251    #[test]
252    fn build_request_roles_sends_policy_id() {
253        let policy = roles_policy();
254        let req = build_request("room", &policy, "task").unwrap();
255        assert!(
256            req.agent_names.is_none(),
257            "role-based should not send agent_names"
258        );
259        assert!(req.policy_id.is_some(), "role-based should send policy_id");
260        assert_eq!(req.policy_id.unwrap(), policy.policy_id());
261    }
262
263    #[test]
264    fn build_request_effort_passthrough() {
265        let mut policy = static_policy();
266        policy.effort = 0.42;
267        let req = build_request("room", &policy, "task").unwrap();
268        assert_eq!(req.effort, Some(0.42));
269    }
270
271    #[test]
272    fn build_request_sla_maps_to_timeout() {
273        let mut policy = static_policy();
274        policy.sla = Some(PolicySla {
275            job_timeout_secs: 600,
276            response_sla_secs: None,
277            max_tokens: None,
278        });
279        let req = build_request("room", &policy, "task").unwrap();
280        // `job_timeout_secs` is the JIT contract and is forwarded verbatim —
281        // no buffer, no scaling.
282        assert_eq!(req.timeout_seconds, Some(600));
283    }
284
285    #[test]
286    fn build_request_timeout_does_not_scale_with_max_rounds() {
287        // Regression guard: `job_timeout_secs` is the whole-job wall-clock
288        // envelope, NOT a per-phase budget. Changing `max_rounds` must not
289        // change the client-side timeout.
290        let sla = PolicySla {
291            job_timeout_secs: 300,
292            response_sla_secs: None,
293            max_tokens: None,
294        };
295
296        let mut policy_1 = static_policy();
297        policy_1.max_rounds = 1;
298        policy_1.sla = Some(sla.clone());
299
300        let mut policy_10 = static_policy();
301        policy_10.max_rounds = 10;
302        policy_10.sla = Some(sla);
303
304        let req_1 = build_request("r1", &policy_1, "task").unwrap();
305        let req_10 = build_request("r10", &policy_10, "task").unwrap();
306
307        assert_eq!(req_1.timeout_seconds, Some(300));
308        assert_eq!(req_10.timeout_seconds, Some(300));
309        assert_eq!(req_1.timeout_seconds, req_10.timeout_seconds);
310    }
311
312    #[test]
313    fn build_request_timeout_is_none_when_sla_job_timeout_is_zero() {
314        // The `0` sentinel on `job_timeout_secs` means "no explicit budget"
315        // and must be surfaced as `None`, not forwarded as a real deadline.
316        // Workspace validation rejects a zero SLA at load time, but other
317        // code paths (raw API payloads) could still hit `build_request`
318        // with a zero value, so the conversion must be defensive here.
319        let mut policy = static_policy();
320        policy.sla = Some(PolicySla {
321            job_timeout_secs: 0,
322            response_sla_secs: None,
323            max_tokens: None,
324        });
325        let req = build_request("room", &policy, "task").unwrap();
326        assert_eq!(req.timeout_seconds, None);
327    }
328
329    #[test]
330    fn build_request_timeout_handles_u64_max_without_overflow() {
331        // Regression guard: the previous `x + x/10` overhead buffer could
332        // overflow on extreme values. The new implementation forwards the
333        // value verbatim, so `u64::MAX` must round-trip unchanged.
334        let mut policy = static_policy();
335        policy.sla = Some(PolicySla {
336            job_timeout_secs: u64::MAX,
337            response_sla_secs: None,
338            max_tokens: None,
339        });
340        let req = build_request("room", &policy, "task").unwrap();
341        assert_eq!(req.timeout_seconds, Some(u64::MAX));
342    }
343
344    #[test]
345    fn build_request_serializes_to_expected_json() {
346        let req = build_request("room", &static_policy(), "task").unwrap();
347        let json = serde_json::to_value(&req).unwrap();
348        assert!(json["room_id"].as_str().unwrap().starts_with("room_"));
349        assert_eq!(json["agent_names"][0], "agent-a");
350        // effort should be serialized under its new key
351        assert!(json.get("effort").is_some(), "effort field must be present");
352        let effort_val = json["effort"].as_f64().unwrap();
353        assert!(
354            (effort_val - 0.85).abs() < 1e-6,
355            "effort should be ~0.85, got {effort_val}"
356        );
357        // Optional None fields should be absent
358        assert!(json.get("policy_id").is_none());
359        assert!(json.get("scope").is_none());
360        assert!(json.get("timeout_seconds").is_none());
361    }
362
363    #[test]
364    fn build_request_roles_serializes_policy_id() {
365        let req = build_request("room", &roles_policy(), "task").unwrap();
366        let json = serde_json::to_value(&req).unwrap();
367        assert!(
368            json.get("agent_names").is_none(),
369            "role-based should omit agent_names"
370        );
371        assert!(
372            json["policy_id"].is_string(),
373            "role-based should have policy_id"
374        );
375    }
376
377    #[test]
378    fn build_request_raw_policy_id_creates_adhoc_room() {
379        let hash = "a".repeat(64);
380        let req = build_request_raw_policy_id(&hash, "audit this");
381        assert!(
382            req.room_id.starts_with("adhoc_"),
383            "room_id should start with 'adhoc_', got: {}",
384            req.room_id
385        );
386        assert_eq!(req.user_query, "audit this");
387        assert_eq!(req.policy_id.as_deref(), Some(hash.as_str()));
388        assert!(req.agent_names.is_none());
389        assert_eq!(req.deliberation_rounds, 3);
390        assert!(req.effort.is_none());
391    }
392
393    #[test]
394    fn build_request_adhoc_named_policy() {
395        let policy = roles_policy();
396        let req = build_request("adhoc", &policy, "review this").unwrap();
397        assert!(req.room_id.starts_with("adhoc_"));
398        assert!(req.policy_id.is_some());
399        assert_eq!(req.policy_id.unwrap(), policy.policy_id());
400    }
401}