Skip to main content

roder_ext_task_ledger/
lib.rs

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}