1use serde::Serialize;
2
3use crate::cli::workspace::PolicyConfig;
4
5fn rand_u64() -> u64 {
7 use std::hash::{BuildHasher, Hasher};
8 std::collections::hash_map::RandomState::new()
9 .build_hasher()
10 .finish()
11}
12
13#[derive(Debug, Serialize)]
15pub struct DeliberationRequest {
16 pub room_id: String,
17 #[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 #[serde(skip_serializing_if = "Option::is_none")]
38 pub user_tools: Option<Vec<crate::agents::UserToolDefinition>>,
39 #[serde(skip_serializing_if = "Option::is_none")]
43 pub new_turn: Option<String>,
44}
45
46pub 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
82pub 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
106pub 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 let timeout_seconds = policy
136 .sla
137 .as_ref()
138 .and_then(|sla| sla.job_timeout())
139 .map(|d| d.as_secs());
140
141 let nonce: u64 = rand_u64();
144 let room_id = format!("{room_name}_{nonce:016x}");
145
146 Ok(DeliberationRequest {
147 room_id,
148 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 let params = tools[0].parameters.as_ref().unwrap();
210 assert_eq!(params["required"][0], "question");
211 assert!(params["properties"]["options"].is_object());
212 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 assert_eq!(req.timeout_seconds, Some(600));
283 }
284
285 #[test]
286 fn build_request_timeout_does_not_scale_with_max_rounds() {
287 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 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 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 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 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}