1use std::sync::Arc;
2
3use roder_api::extension::{
4 ExtensionManifest, ExtensionRegistryBuilder, ProvidedService, RoderExtension, ToolProviderId,
5};
6use roder_api::task_ledger::{TaskLedgerItem, TaskLedgerSnapshot};
7use roder_api::tools::{
8 ToolCall, ToolContributor, ToolExecutor, ToolRegistry, ToolResult, ToolSpec,
9};
10use serde::Deserialize;
11use serde_json::json;
12use tokio::sync::Mutex;
13
14pub struct TaskLedgerExtension;
15
16impl RoderExtension for TaskLedgerExtension {
17 fn manifest(&self) -> ExtensionManifest {
18 ExtensionManifest {
19 id: "roder-ext-task-ledger".to_string(),
20 name: "Task Ledger".to_string(),
21 version: semver::Version::new(0, 1, 0),
22 api_version: "0.1.0".to_string(),
23 description: Some("Model-visible task ledger tool".to_string()),
24 provides: vec![ProvidedService::ToolProvider("task-ledger".to_string())],
25 required_capabilities: Vec::new(),
26 }
27 }
28
29 fn install(&self, registry: &mut ExtensionRegistryBuilder) -> anyhow::Result<()> {
30 registry.tool_contributor(Arc::new(TaskLedgerToolContributor::default()));
31 Ok(())
32 }
33}
34
35#[derive(Debug, Default)]
36pub struct TaskLedgerToolContributor {
37 state: Arc<Mutex<TaskLedgerSnapshot>>,
38}
39
40impl ToolContributor for TaskLedgerToolContributor {
41 fn id(&self) -> ToolProviderId {
42 "task-ledger".to_string()
43 }
44
45 fn contribute(&self, registry: &mut ToolRegistry) -> anyhow::Result<()> {
46 registry.register(Arc::new(TaskLedgerUpdateTool {
47 state: self.state.clone(),
48 }))
49 }
50}
51
52#[derive(Debug)]
53struct TaskLedgerUpdateTool {
54 state: Arc<Mutex<TaskLedgerSnapshot>>,
55}
56
57#[derive(Debug, Deserialize)]
58#[serde(rename_all = "camelCase")]
59struct TaskLedgerUpdateArgs {
60 tasks: Vec<TaskLedgerItem>,
61 #[serde(default)]
62 require_completion_evidence: bool,
63}
64
65#[async_trait::async_trait]
66impl ToolExecutor for TaskLedgerUpdateTool {
67 fn spec(&self) -> ToolSpec {
68 ToolSpec {
69 name: "task_ledger.update".to_string(),
70 description: "Update the durable task ledger for decomposed work.".to_string(),
71 parameters: json!({
72 "type": "object",
73 "properties": {
74 "tasks": {
75 "type": "array",
76 "items": {
77 "type": "object",
78 "properties": {
79 "id": { "type": "string" },
80 "content": { "type": "string" },
81 "status": {
82 "type": "string",
83 "enum": ["pending", "in_progress", "completed", "blocked"]
84 },
85 "evidence": { "type": "string" }
86 },
87 "required": ["id", "content", "status"],
88 "additionalProperties": false
89 }
90 },
91 "requireCompletionEvidence": { "type": "boolean" }
92 },
93 "required": ["tasks"],
94 "additionalProperties": false
95 }),
96 }
97 }
98
99 async fn execute(
100 &self,
101 _ctx: roder_api::tools::ToolExecutionContext,
102 call: ToolCall,
103 ) -> anyhow::Result<ToolResult> {
104 let args: TaskLedgerUpdateArgs = serde_json::from_value(call.arguments.clone())?;
105 let snapshot = TaskLedgerSnapshot { tasks: args.tasks };
106 if let Err(err) = snapshot.validate(args.require_completion_evidence) {
107 return Ok(ToolResult {
108 id: call.id,
109 name: call.name,
110 text: err.to_string(),
111 data: json!({ "error": { "kind": "task_ledger_validation", "message": err.to_string() } }),
112 is_error: true,
113 });
114 }
115 *self.state.lock().await = snapshot.clone();
116 Ok(ToolResult {
117 id: call.id,
118 name: call.name,
119 text: format_task_ledger(&snapshot),
120 data: json!({
121 "taskLedger": snapshot,
122 }),
123 is_error: false,
124 })
125 }
126}
127
128fn format_task_ledger(snapshot: &TaskLedgerSnapshot) -> String {
129 let mut lines = vec![format!(
130 "Task ledger: {}/{} completed",
131 snapshot.completed_count(),
132 snapshot.tasks.len()
133 )];
134 for task in &snapshot.tasks {
135 let evidence = task
136 .evidence
137 .as_deref()
138 .filter(|value| !value.trim().is_empty())
139 .map(|value| format!(" evidence: {value}"))
140 .unwrap_or_default();
141 lines.push(format!(
142 "- {}: {} [{}]{}",
143 task.status.as_str(),
144 task.content,
145 task.id,
146 evidence
147 ));
148 }
149 lines.join("\n")
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use roder_api::task_ledger::TaskLedgerStatus;
156 use roder_api::tools::ToolExecutionContext;
157
158 #[tokio::test]
159 async fn task_ledger_update_validates_and_returns_panel_friendly_text() {
160 let contributor = TaskLedgerToolContributor::default();
161 let mut registry = ToolRegistry::default();
162 contributor.contribute(&mut registry).unwrap();
163 let tool = registry.get("task_ledger.update").unwrap();
164 let result = tool
165 .execute(
166 ToolExecutionContext::new(
167 "thread".to_string(),
168 "turn".to_string(),
169 roder_api::policy_mode::PolicyMode::Default,
170 ),
171 ToolCall {
172 id: "ledger-1".to_string(),
173 name: "task_ledger.update".to_string(),
174 raw_arguments: "{}".to_string(),
175 arguments: json!({
176 "tasks": [
177 { "id": "inspect", "content": "Inspect", "status": "completed", "evidence": "tests" },
178 { "id": "verify", "content": "Verify", "status": "in_progress" }
179 ],
180 "requireCompletionEvidence": true
181 }),
182 thread_id: "thread".to_string(),
183 turn_id: "turn".to_string(),
184 },
185 )
186 .await
187 .unwrap();
188
189 assert!(!result.is_error);
190 assert!(result.text.contains("- completed: Inspect [inspect]"));
191 let snapshot: TaskLedgerSnapshot =
192 serde_json::from_value(result.data["taskLedger"].clone()).unwrap();
193 assert_eq!(snapshot.tasks[0].status, TaskLedgerStatus::Completed);
194 }
195
196 #[tokio::test]
197 async fn task_ledger_update_rejects_completed_without_evidence_when_required() {
198 let contributor = TaskLedgerToolContributor::default();
199 let mut registry = ToolRegistry::default();
200 contributor.contribute(&mut registry).unwrap();
201 let tool = registry.get("task_ledger.update").unwrap();
202 let result = tool
203 .execute(
204 ToolExecutionContext::new(
205 "thread".to_string(),
206 "turn".to_string(),
207 roder_api::policy_mode::PolicyMode::Default,
208 ),
209 ToolCall {
210 id: "ledger-1".to_string(),
211 name: "task_ledger.update".to_string(),
212 raw_arguments: "{}".to_string(),
213 arguments: json!({
214 "tasks": [
215 { "id": "done", "content": "Done", "status": "completed" }
216 ],
217 "requireCompletionEvidence": true
218 }),
219 thread_id: "thread".to_string(),
220 turn_id: "turn".to_string(),
221 },
222 )
223 .await
224 .unwrap();
225
226 assert!(result.is_error);
227 assert!(result.text.contains("requires evidence"));
228 }
229}