Skip to main content

wm_tools/expansion/
tasks.rs

1//! Task tools — distribute, status.
2
3#![forbid(unsafe_code)]
4
5use async_trait::async_trait;
6
7use serde_json::{Value, json};
8use std::sync::Arc;
9use wm_core::{Context, EffectRow, Galaxy, Gana, Resource, Tool, ToolStats};
10use wm_memory::{Memory, MemoryStore};
11
12pub struct TaskDistributeTool {
13    store: Arc<MemoryStore>,
14    stats: ToolStats,
15    effects: EffectRow,
16}
17
18impl TaskDistributeTool {
19    pub fn new(store: Arc<MemoryStore>) -> Self {
20        Self {
21            store,
22            stats: ToolStats::default(),
23            effects: EffectRow {
24                writes: vec![Resource::Galaxy("substrate".into())],
25                ..Default::default()
26            },
27        }
28    }
29}
30
31#[async_trait]
32impl Tool for TaskDistributeTool {
33    fn name(&self) -> &str {
34        "task.distribute"
35    }
36    fn gana(&self) -> Gana {
37        Gana::TurtleBeak
38    }
39    fn effects(&self) -> &EffectRow {
40        &self.effects
41    }
42    fn input_schema(&self) -> Value {
43        super::common::schema(
44            &json!({
45                "task": super::common::str_prop("Non-empty task description to record"),
46                "agent_id": super::common::str_prop("Requested assignee label; not a verified or contacted agent"),
47            }),
48            &["task"],
49        )
50    }
51    fn description(&self) -> &str {
52        "Record a task assignment memo; does not contact, verify, or execute an agent"
53    }
54    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
55        let task = args
56            .get("task")
57            .and_then(|v| v.as_str())
58            .map(str::trim)
59            .filter(|value| !value.is_empty())
60            .ok_or_else(|| {
61                wm_core::CoreError::InvalidArgs("Non-empty 'task' parameter required".into())
62            })?;
63        let agent_id = args
64            .get("agent_id")
65            .and_then(|v| v.as_str())
66            .map(str::trim)
67            .filter(|value| !value.is_empty())
68            .unwrap_or("any");
69        let mut mem = Memory::new(
70            Galaxy::Substrate,
71            json!({
72                "type": "task",
73                "task": task,
74                "agent_id": agent_id,
75                "status": "recorded",
76            })
77            .to_string(),
78        );
79        mem.metadata.tags = vec!["task".into(), "recorded".into()];
80        mem.metadata.importance = 0.7;
81        self.store.put(Galaxy::Substrate, &mem)?;
82        Ok(json!({
83            "status": "success",
84            "task_id": mem.metadata.id,
85            "task": task,
86            "agent_id": agent_id,
87            "assignment_state": "recorded_only",
88            "agent_verified": false,
89            "agent_contacted": false,
90            "execution_started": false,
91        }))
92    }
93    fn stats(&self) -> &ToolStats {
94        &self.stats
95    }
96}
97
98/// `task.status` — check task status.
99pub struct TaskStatusTool {
100    store: Arc<MemoryStore>,
101    stats: ToolStats,
102    effects: EffectRow,
103}
104
105impl TaskStatusTool {
106    pub fn new(store: Arc<MemoryStore>) -> Self {
107        Self {
108            store,
109            stats: ToolStats::default(),
110            effects: EffectRow::read_only(vec![Resource::Galaxy("substrate".into())]),
111        }
112    }
113}
114
115#[async_trait]
116impl Tool for TaskStatusTool {
117    fn name(&self) -> &str {
118        "task.status"
119    }
120    fn gana(&self) -> Gana {
121        Gana::TurtleBeak
122    }
123    fn effects(&self) -> &EffectRow {
124        &self.effects
125    }
126    fn input_schema(&self) -> Value {
127        super::common::schema(
128            &json!({
129                "task_id": super::common::str_prop("Optional exact task memo UUID"),
130            }),
131            &[],
132        )
133    }
134    fn description(&self) -> &str {
135        "List recorded task assignment memos; does not report agent execution state"
136    }
137    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
138        let task_id = args
139            .get("task_id")
140            .and_then(|v| v.as_str())
141            .map(uuid::Uuid::parse_str)
142            .transpose()
143            .map_err(|error| {
144                wm_core::CoreError::InvalidArgs(format!("Invalid task_id UUID: {error}"))
145            })?;
146        let memories = self.store.scan(Galaxy::Substrate, 500)?;
147        let tasks: Vec<Value> = memories
148            .iter()
149            .filter(|m| m.metadata.tags.contains(&"task".to_string()))
150            .filter(|m| task_id.is_none_or(|id| m.metadata.id == id))
151            .map(|m| {
152                json!({
153                    "id": m.metadata.id,
154                    "content": m.content,
155                    "tags": m.metadata.tags,
156                })
157            })
158            .collect();
159        Ok(json!({
160            "status": "success",
161            "count": tasks.len(),
162            "tasks": tasks,
163        }))
164    }
165    fn stats(&self) -> &ToolStats {
166        &self.stats
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    fn open_store() -> (tempfile::TempDir, Arc<MemoryStore>) {
175        let tmp = tempfile::tempdir().unwrap();
176        let store = Arc::new(MemoryStore::open_default(tmp.path()).unwrap());
177        (tmp, store)
178    }
179
180    #[tokio::test]
181    async fn distribute_records_memo_without_claiming_agent_execution() {
182        let (_tmp, store) = open_store();
183        let result = TaskDistributeTool::new(store.clone())
184            .call(
185                &mut Context::default(),
186                json!({"task": "inspect invented fixture", "agent_id": "agent-example"}),
187            )
188            .await
189            .unwrap();
190        assert_eq!(result["assignment_state"], "recorded_only");
191        assert_eq!(result["agent_verified"], false);
192        assert_eq!(result["agent_contacted"], false);
193        assert_eq!(result["execution_started"], false);
194
195        let saved = store.scan(Galaxy::Substrate, 10).unwrap();
196        assert_eq!(saved.len(), 1);
197        let body: Value = serde_json::from_str(&saved[0].content).unwrap();
198        assert_eq!(body["status"], "recorded");
199    }
200
201    #[tokio::test]
202    async fn distribute_rejects_missing_or_blank_task() {
203        let (_tmp, store) = open_store();
204        let tool = TaskDistributeTool::new(store);
205        for args in [json!({}), json!({"task": "  "})] {
206            let error = tool.call(&mut Context::default(), args).await.unwrap_err();
207            assert!(error.to_string().contains("Non-empty 'task'"));
208        }
209    }
210
211    #[tokio::test]
212    async fn status_reads_recorded_task_memos() {
213        let (_tmp, store) = open_store();
214        let created = TaskDistributeTool::new(store.clone())
215            .call(&mut Context::default(), json!({"task": "bounded audit"}))
216            .await
217            .unwrap();
218        let result = TaskStatusTool::new(store)
219            .call(
220                &mut Context::default(),
221                json!({"task_id": created["task_id"]}),
222            )
223            .await
224            .unwrap();
225        assert_eq!(result["count"], 1);
226        assert!(
227            result["tasks"][0]["content"]
228                .as_str()
229                .unwrap()
230                .contains("bounded audit")
231        );
232    }
233}