1use crate::session_task::{
2 NewTaskMessage, SessionTask, SessionTaskUpdate, TaskMessageDirection, TaskMessagePart,
3 task_result_path,
4};
5use crate::tools::{Tool, ToolExecutionResult};
6use crate::traits::{SessionFileSystem, SessionStore, ToolContext};
7use crate::typed_id::{SessionId, WorkspaceId};
8use async_trait::async_trait;
9use serde_json::{Value, json};
10use std::sync::Arc;
11
12pub(crate) const RESULT_SCHEMA_SPEC_KEY: &str = "result_schema";
13pub(crate) const MESSAGE_SCHEMA_SPEC_KEY: &str = "message_schema";
14
15pub(crate) fn declared_result_schema(task: &SessionTask) -> Option<&Value> {
16 task.spec
17 .get(RESULT_SCHEMA_SPEC_KEY)
18 .filter(|schema| schema.is_object())
19}
20
21pub(crate) fn declared_message_schema(task: &SessionTask) -> Option<&Value> {
22 task.spec
23 .get(MESSAGE_SCHEMA_SPEC_KEY)
24 .filter(|schema| schema.is_object())
25}
26
27fn normalize_optional_schema(
28 arguments: &Value,
29 key: &str,
30) -> Result<Option<Value>, ToolExecutionResult> {
31 let Some(schema) = arguments.get(key).filter(|value| !value.is_null()) else {
32 return Ok(None);
33 };
34 if !schema.is_object() {
35 return Err(ToolExecutionResult::tool_error(format!(
36 "{key} must be a JSON Schema object when provided."
37 )));
38 }
39 Ok(Some(schema.clone()))
40}
41
42pub(crate) fn normalize_result_schema(
43 arguments: &Value,
44) -> Result<Option<Value>, ToolExecutionResult> {
45 normalize_optional_schema(arguments, RESULT_SCHEMA_SPEC_KEY)
46}
47
48pub(crate) fn normalize_message_schema(
49 arguments: &Value,
50) -> Result<Option<Value>, ToolExecutionResult> {
51 normalize_optional_schema(arguments, MESSAGE_SCHEMA_SPEC_KEY)
52}
53
54pub(crate) fn schema_validation_errors(schema: &Value, value: &Value) -> Vec<String> {
55 let validator = match jsonschema::draft202012::options()
56 .should_validate_formats(true)
57 .build(schema)
58 {
59 Ok(validator) => validator,
60 Err(error) => return vec![format!("result schema is invalid: {error}")],
61 };
62
63 validator
64 .iter_errors(value)
65 .map(|error| {
66 let path = error.instance_path().to_string();
67 let path = if path.is_empty() {
68 "$".to_string()
69 } else {
70 path
71 };
72 format!("{path} {error}")
73 })
74 .collect()
75}
76
77const MAX_TASK_SUMMARY_CHARS: usize = 2_048;
78
79pub(crate) fn truncate_summary(text: &str) -> String {
80 let mut chars = text.chars();
81 let truncated: String = chars.by_ref().take(MAX_TASK_SUMMARY_CHARS).collect();
82 if chars.next().is_some() {
83 format!("{truncated}\n[truncated]")
84 } else {
85 truncated
86 }
87}
88
89pub(crate) async fn task_for_child_session(
90 child_session_id: SessionId,
91 session_store: &dyn SessionStore,
92 task_registry: &dyn crate::session_task::SessionTaskRegistry,
93) -> crate::error::Result<Option<(SessionTask, WorkspaceId)>> {
94 let Some(child) = session_store.get_session(child_session_id).await? else {
95 return Ok(None);
96 };
97 let Some(parent_session_id) = child.parent_session_id.or(child.forked_from_session_id) else {
98 return Ok(None);
99 };
100 let Some(parent) = session_store.get_session(parent_session_id).await? else {
101 return Ok(None);
102 };
103 let task = task_registry
104 .list(parent_session_id, None)
105 .await?
106 .into_iter()
107 .find(|task| task.links.child_session_id == Some(child_session_id));
108 Ok(task.map(|task| (task, parent.workspace_id)))
109}
110
111pub struct ReportResultTool {
112 parent_session_id: SessionId,
113 parent_workspace_id: WorkspaceId,
114 child_session_id: SessionId,
115 task_id: String,
116 result_schema: Value,
117 file_store: Option<Arc<dyn SessionFileSystem>>,
118}
119
120impl ReportResultTool {
121 pub fn new(
122 parent_session_id: SessionId,
123 parent_workspace_id: WorkspaceId,
124 child_session_id: SessionId,
125 task_id: String,
126 result_schema: Value,
127 ) -> Self {
128 Self {
129 parent_session_id,
130 parent_workspace_id,
131 child_session_id,
132 task_id,
133 result_schema,
134 file_store: None,
135 }
136 }
137
138 pub fn with_file_store(mut self, file_store: Arc<dyn SessionFileSystem>) -> Self {
139 self.file_store = Some(file_store);
140 self
141 }
142}
143
144#[async_trait]
145impl Tool for ReportResultTool {
146 fn name(&self) -> &str {
147 "report_result"
148 }
149
150 fn display_name(&self) -> Option<&str> {
151 Some("Report Result")
152 }
153
154 fn description(&self) -> &str {
155 "Submit the final structured result for this delegated task. The call arguments must match the declared result schema."
156 }
157
158 fn parameters_schema(&self) -> Value {
159 self.result_schema.clone()
160 }
161
162 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
163 ToolExecutionResult::tool_error(
164 "report_result requires context. This tool must be executed with session context.",
165 )
166 }
167
168 async fn execute_with_context(
169 &self,
170 arguments: Value,
171 context: &ToolContext,
172 ) -> ToolExecutionResult {
173 let errors = schema_validation_errors(&self.result_schema, &arguments);
174 if !errors.is_empty() {
175 return ToolExecutionResult::tool_error(format!(
176 "report_result arguments do not match result_schema: {}",
177 errors.join("; ")
178 ));
179 }
180 let Some(registry) = context.session_task_registry.as_ref() else {
181 return ToolExecutionResult::tool_error(
182 "report_result requires session_task_registry context",
183 );
184 };
185 let Some(file_store) = self.file_store.as_ref().or(context.file_store.as_ref()) else {
186 return ToolExecutionResult::tool_error("report_result requires file_store context");
187 };
188
189 if context.session_id != self.child_session_id {
190 return ToolExecutionResult::tool_error(
191 "report_result can only be called from the linked child session",
192 );
193 }
194 let task = match registry.get(self.parent_session_id, &self.task_id).await {
195 Ok(Some(task)) => task,
196 Ok(None) => return ToolExecutionResult::tool_error("report_result task was not found"),
197 Err(error) => return ToolExecutionResult::internal_error(error),
198 };
199 if task.links.child_session_id != Some(self.child_session_id) {
200 return ToolExecutionResult::tool_error(
201 "report_result child session is no longer linked to this task",
202 );
203 }
204 if task.state.is_terminal() {
205 return ToolExecutionResult::tool_error(
206 "report_result is closed because the subagent task is terminal",
207 );
208 }
209 if task.result_path.is_some() {
210 return ToolExecutionResult::tool_error(
211 "report_result was already recorded for this subagent task",
212 );
213 }
214
215 let path = task_result_path(&self.task_id);
216 let content = match serde_json::to_string_pretty(&arguments) {
217 Ok(content) => content,
218 Err(error) => return ToolExecutionResult::internal_error(error),
219 };
220 let parent_workspace_key = SessionId::from_uuid(self.parent_workspace_id.uuid());
221 if let Err(error) = file_store
222 .write_file(parent_workspace_key, &path, &content, "utf-8")
223 .await
224 {
225 return ToolExecutionResult::internal_error(error);
226 }
227 if let Err(error) = registry
228 .update(
229 self.parent_session_id,
230 &self.task_id,
231 SessionTaskUpdate {
232 expected_attempt: Some(task.attempt),
236 state: Some(task.state),
237 result_path: Some(path.clone()),
238 summary: Some(truncate_summary(&content)),
239 ..Default::default()
240 },
241 )
242 .await
243 {
244 return ToolExecutionResult::internal_error(error);
245 }
246 ToolExecutionResult::success(json!({
247 "status": "recorded",
248 "task_id": self.task_id,
249 "result_path": path,
250 }))
251 }
252
253 fn requires_context(&self) -> bool {
254 true
255 }
256}
257
258pub struct ReportTaskProgressTool {
259 parent_session_id: SessionId,
260 task_id: String,
261 task_attempt: i32,
262 message_schema: Value,
263}
264
265impl ReportTaskProgressTool {
266 pub fn new(
267 parent_session_id: SessionId,
268 task_id: String,
269 task_attempt: i32,
270 message_schema: Value,
271 ) -> Self {
272 Self {
273 parent_session_id,
274 task_id,
275 task_attempt,
276 message_schema,
277 }
278 }
279}
280
281#[async_trait]
282impl Tool for ReportTaskProgressTool {
283 fn name(&self) -> &str {
284 "report_task_progress"
285 }
286
287 fn display_name(&self) -> Option<&str> {
288 Some("Report Task Progress")
289 }
290
291 fn description(&self) -> &str {
292 "Post a structured progress message for this delegated task. The call arguments must match the declared message schema."
293 }
294
295 fn parameters_schema(&self) -> Value {
296 self.message_schema.clone()
297 }
298
299 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
300 ToolExecutionResult::tool_error(
301 "report_task_progress requires context. This tool must be executed with session context.",
302 )
303 }
304
305 async fn execute_with_context(
306 &self,
307 arguments: Value,
308 context: &ToolContext,
309 ) -> ToolExecutionResult {
310 let errors = schema_validation_errors(&self.message_schema, &arguments);
311 if !errors.is_empty() {
312 return ToolExecutionResult::tool_error(format!(
313 "report_task_progress arguments do not match message_schema: {}",
314 errors.join("; ")
315 ));
316 }
317 let Some(registry) = context.session_task_registry.as_ref() else {
318 return ToolExecutionResult::tool_error(
319 "report_task_progress requires session_task_registry context",
320 );
321 };
322 let stored = match registry
323 .record_message(
324 self.parent_session_id,
325 &self.task_id,
326 NewTaskMessage {
327 direction: TaskMessageDirection::Outbound,
328 content: vec![TaskMessagePart::Data {
329 data: arguments.clone(),
330 }],
331 in_reply_to: None,
332 expected_attempt: Some(self.task_attempt),
333 },
334 )
335 .await
336 {
337 Ok(stored) => stored,
338 Err(error) => return ToolExecutionResult::internal_error(error),
339 };
340 ToolExecutionResult::success(json!({
341 "status": "posted",
342 "task_id": self.task_id,
343 "message_id": stored.id,
344 }))
345 }
346
347 fn requires_context(&self) -> bool {
348 true
349 }
350}
351
352pub async fn report_result_tool_for_child_session(
353 child_session_id: SessionId,
354 session_store: &dyn SessionStore,
355 task_registry: &dyn crate::session_task::SessionTaskRegistry,
356) -> crate::error::Result<Option<ReportResultTool>> {
357 let Some((task, parent_workspace_id)) =
358 task_for_child_session(child_session_id, session_store, task_registry).await?
359 else {
360 return Ok(None);
361 };
362 let Some(schema) = declared_result_schema(&task).cloned() else {
363 return Ok(None);
364 };
365 Ok(Some(ReportResultTool::new(
366 task.session_id,
367 parent_workspace_id,
368 child_session_id,
369 task.id,
370 schema,
371 )))
372}
373
374pub async fn report_task_progress_tool_for_child_session(
375 child_session_id: SessionId,
376 session_store: &dyn SessionStore,
377 task_registry: &dyn crate::session_task::SessionTaskRegistry,
378) -> crate::error::Result<Option<ReportTaskProgressTool>> {
379 let Some((task, _)) =
380 task_for_child_session(child_session_id, session_store, task_registry).await?
381 else {
382 return Ok(None);
383 };
384 if task.state.is_terminal() {
385 return Ok(None);
386 }
387 let Some(schema) = declared_message_schema(&task).cloned() else {
388 return Ok(None);
389 };
390 Ok(Some(ReportTaskProgressTool::new(
391 task.session_id,
392 task.id,
393 task.attempt,
394 schema,
395 )))
396}
397
398pub(crate) async fn result_value_for_task(
399 context: &ToolContext,
400 task_id: Option<&str>,
401) -> Option<Value> {
402 let task_id = task_id?;
403 let registry = context.session_task_registry.as_ref()?;
404 let task = registry
405 .get(context.session_id, task_id)
406 .await
407 .ok()
408 .flatten()?;
409 declared_result_schema(&task)?;
410 let result_path = task.result_path.as_deref()?;
411 let file_store = context.file_store.as_ref()?;
412 let file = file_store
413 .read_file(context.workspace_fs_key(), result_path)
414 .await
415 .ok()
416 .flatten()?;
417 serde_json::from_str(file.content.as_deref()?).ok()
418}
419
420pub(crate) async fn required_result_is_missing(
421 context: &ToolContext,
422 task_id: Option<&str>,
423) -> bool {
424 let Some(task_id) = task_id else {
425 return false;
426 };
427 let Some(registry) = context.session_task_registry.as_ref() else {
428 return false;
429 };
430 registry
431 .get(context.session_id, task_id)
432 .await
433 .ok()
434 .flatten()
435 .is_some_and(|task| declared_result_schema(&task).is_some() && task.result_path.is_none())
436}
437
438pub(crate) async fn write_task_result_value(
439 context: &ToolContext,
440 task_id: &str,
441 value: &Value,
442) -> crate::error::Result<Option<String>> {
443 let Some(file_store) = context.file_store.as_ref() else {
444 return Ok(None);
445 };
446 let path = task_result_path(task_id);
447 let content = serde_json::to_string_pretty(value).map_err(|error| {
448 crate::error::AgentLoopError::store(format!("failed to serialize task result: {error}"))
449 })?;
450 file_store
451 .write_file(context.workspace_fs_key(), &path, &content, "utf-8")
452 .await?;
453 Ok(Some(path))
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459
460 #[test]
461 fn shared_validator_reports_required_type_and_extra_property_errors() {
462 let schema = json!({
463 "type": "object",
464 "properties": {
465 "answer": {"type": "string"},
466 "count": {"type": "integer"}
467 },
468 "required": ["answer", "count"],
469 "additionalProperties": false
470 });
471 let errors =
472 schema_validation_errors(&schema, &json!({"count": "not-an-integer", "extra": true}));
473 assert!(
474 errors
475 .iter()
476 .any(|error| error.contains("answer") && error.contains("required"))
477 );
478 assert!(
479 errors
480 .iter()
481 .any(|error| error.contains("count") && error.contains("integer"))
482 );
483 assert!(errors.iter().any(|error| error.contains("extra")
484 && (error.contains("additional") || error.contains("not allowed"))));
485 }
486
487 #[test]
488 fn shared_validator_enforces_full_json_schema_constraints() {
489 let schema = json!({
490 "type": "object",
491 "properties": {
492 "echo": {"type": "string", "pattern": "^[0-9]+$", "minLength": 2},
493 "score": {"type": "integer", "minimum": 0},
494 "tags": {
495 "type": "array",
496 "minItems": 2,
497 "items": {"type": "string"}
498 },
499 "choice": {
500 "oneOf": [
501 {"const": "alpha"},
502 {"const": "beta"}
503 ]
504 }
505 },
506 "required": ["echo", "score", "tags", "choice"],
507 "additionalProperties": false
508 });
509
510 let errors = schema_validation_errors(
511 &schema,
512 &json!({
513 "echo": "x",
514 "score": -5,
515 "tags": ["solo"],
516 "choice": "gamma"
517 }),
518 );
519
520 assert!(errors.iter().any(|error| error.contains("echo")
521 && (error.contains("pattern") || error.contains("^[0-9]+$"))));
522 assert!(
523 errors
524 .iter()
525 .any(|error| error.contains("score") && error.contains("0"))
526 );
527 assert!(
528 errors
529 .iter()
530 .any(|error| error.contains("tags") && error.contains("2"))
531 );
532 assert!(
533 errors
534 .iter()
535 .any(|error| error.contains("choice") && error.contains("oneOf"))
536 );
537 }
538
539 #[test]
540 fn shared_schema_normalization_rejects_non_objects() {
541 let ToolExecutionResult::ToolError(error) =
542 normalize_result_schema(&json!({"result_schema": "object"})).unwrap_err()
543 else {
544 panic!("expected tool error");
545 };
546 assert!(error.contains("result_schema must be a JSON Schema object"));
547 }
548}