1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
//! `tasks_set`: whole-list replace of the session task list.
//!
//! The one session-metadata write the model reaches for: one idempotent call
//! carrying the **whole list**, not per-item add/complete/delete — a
//! whole-list replace has no delete-versus-complete ambiguity and no
//! partial-update race, and it is one schema instead of four.
//!
//! The payload is untrusted model input, so it validates through
//! [`SessionTaskList::validate`] before storing — the `RunPlan::validate`
//! discipline. A rejected write stores nothing: the stored list is replaced
//! only by a list that passed. Validation failures are typed, payload-free,
//! and name the bound breached so the model can correct itself.
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use saya_agent::{ToolError, ToolExecutor};
use saya_types::{SessionTaskError, SessionTaskList};
/// The session's live task list: the in-memory working copy the `tasks_set`
/// executor reads and writes. A plain mutex-guarded cell — a panicked holder
/// cannot have left it in a state recovery must fear, so a poisoned lock
/// keeps serving (the engine's own rule for its grant store).
#[derive(Debug, Clone, Default)]
pub(crate) struct SessionTasks(Arc<Mutex<SessionTaskList>>);
impl SessionTasks {
/// The current list, cloned out — what a turn's context block renders
/// and what the record sync writes back.
pub(crate) fn current(&self) -> SessionTaskList {
self.locked().clone()
}
/// Replaces the stored list with one the caller already validated — the
/// executor validates before calling, so this never sees a bad list.
pub(crate) fn replace(&self, list: SessionTaskList) {
*self.locked() = list;
}
fn locked(&self) -> MutexGuard<'_, SessionTaskList> {
self.0.lock().unwrap_or_else(PoisonError::into_inner)
}
}
/// The `tasks_set` executor member: whole-list replace against the shared
/// cell. Runs through [`ToolExecutor`] so the session's composite can route
/// the name to it like every other member.
pub(crate) struct TasksSet {
tasks: SessionTasks,
}
impl TasksSet {
pub(crate) fn new(tasks: SessionTasks) -> Self {
Self { tasks }
}
/// Validates the call arguments' shape: one object with exactly the
/// `tasks` array. Typed errors name which argument is wrong — the model
/// fixes the right one — and the rejected payload is never echoed.
fn validate_arguments(arguments: &serde_json::Value) -> Result<(), ToolError> {
let object = arguments.as_object().ok_or(ToolError::ArgumentsNotObject)?;
if object.keys().any(|key| key != "tasks") {
return Err(ToolError::UnsupportedProperty);
}
if !object.get("tasks").is_some_and(serde_json::Value::is_array) {
return Err(ToolError::TasksNotArray);
}
Ok(())
}
}
#[async_trait::async_trait]
impl ToolExecutor for TasksSet {
async fn execute(
&self,
name: &str,
arguments: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
if name != "tasks_set" {
return Err(ToolError::UnsupportedTool);
}
Self::validate_arguments(&arguments)?;
// Deserialize first — a non-deserializing payload is a shape error,
// not a bound error — then validate through the contract's own gate
// before storing anything. The whole arguments object deserializes:
// the contract's list shape is `{"tasks": [...]}`, exactly this
// tool's schema.
let list: SessionTaskList =
serde_json::from_value(arguments).map_err(|_| ToolError::TasksNotArray)?;
list.validate().map_err(|error| match error {
SessionTaskError::TooManyTasks(_) => ToolError::TooManyTasks,
SessionTaskError::TooManyInProgress(_) => ToolError::TooManyInProgress,
SessionTaskError::EmptyTitle => ToolError::TaskTitleEmpty,
SessionTaskError::TitleTooLong => ToolError::TaskTitleTooLong,
SessionTaskError::TitleControlCharacter => ToolError::TaskTitleControl,
SessionTaskError::NoteTooLong => ToolError::TaskNoteTooLong,
SessionTaskError::NoteControlCharacter => ToolError::TaskNoteControl,
// `SessionTaskError` is `#[non_exhaustive]`: a future variant
// refuses as a shape error rather than failing to compile — the
// call still stores nothing.
_ => ToolError::TasksNotArray,
})?;
let count = list.tasks.len();
self.tasks.replace(list);
Ok(serde_json::json!({"tasks": count}))
}
}