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 narrate(
147 &self,
148 tool_call: &crate::tool_types::ToolCall,
149 phase: crate::tool_narration::ToolNarrationPhase,
150 locale: Option<&str>,
151 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
152 ) -> Option<String> {
153 crate::tool_narration::narrate_delegation_result(&tool_call.name, phase, locale)
154 }
155
156 fn name(&self) -> &str {
157 "report_result"
158 }
159
160 fn display_name(&self) -> Option<&str> {
161 Some("Report Result")
162 }
163
164 fn description(&self) -> &str {
165 "Submit the final structured result for this delegated task. The call arguments must match the declared result schema."
166 }
167
168 fn parameters_schema(&self) -> Value {
169 self.result_schema.clone()
170 }
171
172 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
173 ToolExecutionResult::tool_error(
174 "report_result requires context. This tool must be executed with session context.",
175 )
176 }
177
178 async fn execute_with_context(
179 &self,
180 arguments: Value,
181 context: &ToolContext,
182 ) -> ToolExecutionResult {
183 let errors = schema_validation_errors(&self.result_schema, &arguments);
184 if !errors.is_empty() {
185 return ToolExecutionResult::tool_error(format!(
186 "report_result arguments do not match result_schema: {}",
187 errors.join("; ")
188 ));
189 }
190 let Some(registry) = context.session_task_registry.as_ref() else {
191 return ToolExecutionResult::tool_error(
192 "report_result requires session_task_registry context",
193 );
194 };
195 let Some(file_store) = self.file_store.as_ref().or(context.file_store.as_ref()) else {
196 return ToolExecutionResult::tool_error("report_result requires file_store context");
197 };
198
199 if context.session_id != self.child_session_id {
200 return ToolExecutionResult::tool_error(
201 "report_result can only be called from the linked child session",
202 );
203 }
204 let task = match registry.get(self.parent_session_id, &self.task_id).await {
205 Ok(Some(task)) => task,
206 Ok(None) => return ToolExecutionResult::tool_error("report_result task was not found"),
207 Err(error) => return ToolExecutionResult::internal_error(error),
208 };
209 if task.links.child_session_id != Some(self.child_session_id) {
210 return ToolExecutionResult::tool_error(
211 "report_result child session is no longer linked to this task",
212 );
213 }
214 if task.state.is_terminal() {
215 return ToolExecutionResult::tool_error(
216 "report_result is closed because the subagent task is terminal",
217 );
218 }
219 if task.result_path.is_some() {
220 return ToolExecutionResult::tool_error(
221 "report_result was already recorded for this subagent task",
222 );
223 }
224
225 let path = task_result_path(&self.task_id);
226 let content = match serde_json::to_string_pretty(&arguments) {
227 Ok(content) => content,
228 Err(error) => return ToolExecutionResult::internal_error(error),
229 };
230 let parent_workspace_key = SessionId::from_uuid(self.parent_workspace_id.uuid());
231 if let Err(error) = file_store
232 .write_file(parent_workspace_key, &path, &content, "utf-8")
233 .await
234 {
235 return ToolExecutionResult::internal_error(error);
236 }
237 if let Err(error) = registry
238 .update(
239 self.parent_session_id,
240 &self.task_id,
241 SessionTaskUpdate {
242 expected_attempt: Some(task.attempt),
246 state: Some(task.state),
247 result_path: Some(path.clone()),
248 summary: Some(truncate_summary(&content)),
249 ..Default::default()
250 },
251 )
252 .await
253 {
254 return ToolExecutionResult::internal_error(error);
255 }
256 ToolExecutionResult::success(json!({
257 "status": "recorded",
258 "task_id": self.task_id,
259 "result_path": path,
260 }))
261 }
262
263 fn requires_context(&self) -> bool {
264 true
265 }
266}
267
268pub struct ReportTaskProgressTool {
269 parent_session_id: SessionId,
270 task_id: String,
271 task_attempt: i32,
272 message_schema: Value,
273}
274
275impl ReportTaskProgressTool {
276 pub fn new(
277 parent_session_id: SessionId,
278 task_id: String,
279 task_attempt: i32,
280 message_schema: Value,
281 ) -> Self {
282 Self {
283 parent_session_id,
284 task_id,
285 task_attempt,
286 message_schema,
287 }
288 }
289}
290
291#[async_trait]
292impl Tool for ReportTaskProgressTool {
293 fn narrate(
294 &self,
295 tool_call: &crate::tool_types::ToolCall,
296 phase: crate::tool_narration::ToolNarrationPhase,
297 locale: Option<&str>,
298 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
299 ) -> Option<String> {
300 crate::tool_narration::narrate_delegation_result(&tool_call.name, phase, locale)
301 }
302
303 fn name(&self) -> &str {
304 "report_task_progress"
305 }
306
307 fn display_name(&self) -> Option<&str> {
308 Some("Report Task Progress")
309 }
310
311 fn description(&self) -> &str {
312 "Post a structured progress message for this delegated task. The call arguments must match the declared message schema."
313 }
314
315 fn parameters_schema(&self) -> Value {
316 self.message_schema.clone()
317 }
318
319 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
320 ToolExecutionResult::tool_error(
321 "report_task_progress requires context. This tool must be executed with session context.",
322 )
323 }
324
325 async fn execute_with_context(
326 &self,
327 arguments: Value,
328 context: &ToolContext,
329 ) -> ToolExecutionResult {
330 let errors = schema_validation_errors(&self.message_schema, &arguments);
331 if !errors.is_empty() {
332 return ToolExecutionResult::tool_error(format!(
333 "report_task_progress arguments do not match message_schema: {}",
334 errors.join("; ")
335 ));
336 }
337 let Some(registry) = context.session_task_registry.as_ref() else {
338 return ToolExecutionResult::tool_error(
339 "report_task_progress requires session_task_registry context",
340 );
341 };
342 let stored = match registry
343 .record_message(
344 self.parent_session_id,
345 &self.task_id,
346 NewTaskMessage {
347 direction: TaskMessageDirection::Outbound,
348 content: vec![TaskMessagePart::Data {
349 data: arguments.clone(),
350 }],
351 in_reply_to: None,
352 expected_attempt: Some(self.task_attempt),
353 },
354 )
355 .await
356 {
357 Ok(stored) => stored,
358 Err(error) => return ToolExecutionResult::internal_error(error),
359 };
360 ToolExecutionResult::success(json!({
361 "status": "posted",
362 "task_id": self.task_id,
363 "message_id": stored.id,
364 }))
365 }
366
367 fn requires_context(&self) -> bool {
368 true
369 }
370}
371
372pub async fn report_result_tool_for_child_session(
373 child_session_id: SessionId,
374 session_store: &dyn SessionStore,
375 task_registry: &dyn crate::session_task::SessionTaskRegistry,
376) -> crate::error::Result<Option<ReportResultTool>> {
377 let Some((task, parent_workspace_id)) =
378 task_for_child_session(child_session_id, session_store, task_registry).await?
379 else {
380 return Ok(None);
381 };
382 let Some(schema) = declared_result_schema(&task).cloned() else {
383 return Ok(None);
384 };
385 Ok(Some(ReportResultTool::new(
386 task.session_id,
387 parent_workspace_id,
388 child_session_id,
389 task.id,
390 schema,
391 )))
392}
393
394pub async fn report_task_progress_tool_for_child_session(
395 child_session_id: SessionId,
396 session_store: &dyn SessionStore,
397 task_registry: &dyn crate::session_task::SessionTaskRegistry,
398) -> crate::error::Result<Option<ReportTaskProgressTool>> {
399 let Some((task, _)) =
400 task_for_child_session(child_session_id, session_store, task_registry).await?
401 else {
402 return Ok(None);
403 };
404 if task.state.is_terminal() {
405 return Ok(None);
406 }
407 let Some(schema) = declared_message_schema(&task).cloned() else {
408 return Ok(None);
409 };
410 Ok(Some(ReportTaskProgressTool::new(
411 task.session_id,
412 task.id,
413 task.attempt,
414 schema,
415 )))
416}
417
418pub(crate) async fn result_value_for_task(
419 context: &ToolContext,
420 task_id: Option<&str>,
421) -> Option<Value> {
422 let task_id = task_id?;
423 let registry = context.session_task_registry.as_ref()?;
424 let task = registry
425 .get(context.session_id, task_id)
426 .await
427 .ok()
428 .flatten()?;
429 declared_result_schema(&task)?;
430 let result_path = task.result_path.as_deref()?;
431 let file_store = context.file_store.as_ref()?;
432 let file = file_store
433 .read_file(context.workspace_fs_key(), result_path)
434 .await
435 .ok()
436 .flatten()?;
437 serde_json::from_str(file.content.as_deref()?).ok()
438}
439
440pub(crate) async fn required_result_is_missing(
441 context: &ToolContext,
442 task_id: Option<&str>,
443) -> bool {
444 let Some(task_id) = task_id else {
445 return false;
446 };
447 let Some(registry) = context.session_task_registry.as_ref() else {
448 return false;
449 };
450 registry
451 .get(context.session_id, task_id)
452 .await
453 .ok()
454 .flatten()
455 .is_some_and(|task| declared_result_schema(&task).is_some() && task.result_path.is_none())
456}
457
458pub(crate) async fn write_task_result_value(
459 context: &ToolContext,
460 task_id: &str,
461 value: &Value,
462) -> crate::error::Result<Option<String>> {
463 let Some(file_store) = context.file_store.as_ref() else {
464 return Ok(None);
465 };
466 let path = task_result_path(task_id);
467 let content = serde_json::to_string_pretty(value).map_err(|error| {
468 crate::error::AgentLoopError::store(format!("failed to serialize task result: {error}"))
469 })?;
470 file_store
471 .write_file(context.workspace_fs_key(), &path, &content, "utf-8")
472 .await?;
473 Ok(Some(path))
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479
480 #[test]
481 fn shared_validator_reports_required_type_and_extra_property_errors() {
482 let schema = json!({
483 "type": "object",
484 "properties": {
485 "answer": {"type": "string"},
486 "count": {"type": "integer"}
487 },
488 "required": ["answer", "count"],
489 "additionalProperties": false
490 });
491 let errors =
492 schema_validation_errors(&schema, &json!({"count": "not-an-integer", "extra": true}));
493 assert!(
494 errors
495 .iter()
496 .any(|error| error.contains("answer") && error.contains("required"))
497 );
498 assert!(
499 errors
500 .iter()
501 .any(|error| error.contains("count") && error.contains("integer"))
502 );
503 assert!(errors.iter().any(|error| error.contains("extra")
504 && (error.contains("additional") || error.contains("not allowed"))));
505 }
506
507 #[test]
508 fn shared_validator_enforces_full_json_schema_constraints() {
509 let schema = json!({
510 "type": "object",
511 "properties": {
512 "echo": {"type": "string", "pattern": "^[0-9]+$", "minLength": 2},
513 "score": {"type": "integer", "minimum": 0},
514 "tags": {
515 "type": "array",
516 "minItems": 2,
517 "items": {"type": "string"}
518 },
519 "choice": {
520 "oneOf": [
521 {"const": "alpha"},
522 {"const": "beta"}
523 ]
524 }
525 },
526 "required": ["echo", "score", "tags", "choice"],
527 "additionalProperties": false
528 });
529
530 let errors = schema_validation_errors(
531 &schema,
532 &json!({
533 "echo": "x",
534 "score": -5,
535 "tags": ["solo"],
536 "choice": "gamma"
537 }),
538 );
539
540 assert!(errors.iter().any(|error| error.contains("echo")
541 && (error.contains("pattern") || error.contains("^[0-9]+$"))));
542 assert!(
543 errors
544 .iter()
545 .any(|error| error.contains("score") && error.contains("0"))
546 );
547 assert!(
548 errors
549 .iter()
550 .any(|error| error.contains("tags") && error.contains("2"))
551 );
552 assert!(
553 errors
554 .iter()
555 .any(|error| error.contains("choice") && error.contains("oneOf"))
556 );
557 }
558
559 #[test]
560 fn shared_schema_normalization_rejects_non_objects() {
561 let ToolExecutionResult::ToolError(error) =
562 normalize_result_schema(&json!({"result_schema": "object"})).unwrap_err()
563 else {
564 panic!("expected tool error");
565 };
566 assert!(error.contains("result_schema must be a JSON Schema object"));
567 }
568}