Skip to main content

oxi/tools/
issue_tool.rs

1//! `issue` agent tool — agent-driven local issue management.
2//!
3//! Implements [`oxi_agent::AgentTool`] against the [`FileIssueStore`]. One
4//! tool with an `action` discriminator (matches the pattern used by the
5//! `github` tool, see `oxi-agent/src/tools/github.rs`).
6//!
7//! Actions: `list`, `read`, `create`, `update`, `start`, `release`, `close`,
8//! `link_session`. See [`AgentTool::parameters_schema`] for the exact JSON
9//! schema each action accepts.
10//!
11//! Design notes:
12//! - The tool holds an `Arc<FileIssueStore>` so it can be cheaply cloned when
13//!   the tool is registered in the live `ToolRegistry` (mirrors how
14//!   `McpTool`/`WasmTool` hold their managers).
15//! - The "current session id" used for assignment / session-linking is taken
16//!   from `ToolContext.session_id`. The agent loop fills this in from the
17//!   active session.
18
19use std::sync::Arc;
20
21use async_trait::async_trait;
22use oxi_agent::{AgentTool, AgentToolResult, ToolContext};
23use serde_json::{Value, json};
24
25use crate::store::issues::{
26    FileIssueStore, Issue, IssueError, IssueFilter, IssuePatch, Priority, Status,
27};
28
29/// The `issue` tool. One registration, multiple actions.
30#[derive(Debug, Clone)]
31pub struct IssueTool {
32    store: Arc<FileIssueStore>,
33}
34
35impl IssueTool {
36    /// Construct a new `issue` tool backed by `store`.
37    pub fn new(store: FileIssueStore) -> Self {
38        Self {
39            store: Arc::new(store),
40        }
41    }
42}
43
44#[async_trait]
45impl AgentTool for IssueTool {
46    fn name(&self) -> &str {
47        "issue"
48    }
49
50    fn label(&self) -> &str {
51        "Issue"
52    }
53
54    fn description(&self) -> &str {
55        "Manage local issues stored as markdown files in `.oxi/issues/`. \
56         Before editing, call `start` to claim the issue — this prevents other \
57         agents/sessions from concurrently working on the same issue. Always \
58         call `list` first to see existing issues and avoid duplicates. \
59         Use `release` to give up a claim, or `close` to finish the work. \
60         For `update`: every field is optional — omit to keep, provide to replace; \
61         `labels: []` clears all labels (omit to keep). Prefer the dedicated \
62         `close`/`reopen`/`start`/`release` actions over `update { status }`. \
63         To resume a closed issue, call `reopen`, then `start`. Concurrent edits \
64         are auto-reconciled (up to 4 retries), so a stale `content_hash` from \
65         an earlier `read` still succeeds."
66    }
67
68    fn parameters_schema(&self) -> Value {
69        json!({
70            "type": "object",
71            "properties": {
72                "action": {
73                    "type": "string",
74                    "enum": ["list", "read", "create", "update", "reopen", "start", "release", "close", "link_session"],
75                    "description": "Issue operation. For `update`, every field is optional — omit to keep, provide to replace. Concurrent edits are auto-reconciled (up to 4 retries)."
76                },
77                "id": {"type": "integer", "description": "Issue id (for read/update/reopen/start/release/close/link_session)."},
78                "title": {"type": "string", "description": "create: required. update: replaces the title. Max 512 chars."},
79                "body": {"type": "string", "description": "create: optional (defaults empty). update: replaces the body. Max 256 KiB."},
80                "priority": {"type": "string", "enum": ["low", "medium", "high", "critical"], "description": "create/update: new priority. list: filter to this priority."},
81                "labels": {"type": "array", "items": {"type": "string"}, "description": "create/update: REPLACES labels entirely. Omit to keep; pass [] to clear all. Max 32 labels, 64 chars each."},
82                "status": {"type": "string", "enum": ["open", "closed"], "description": "list: filter by status. update: new status (prefer the `close`/`reopen` actions for clarity)."},
83                "label": {"type": "string", "description": "list: filter to issues with this label."},
84                "text": {"type": "string", "description": "list: case-insensitive substring filter on the title."},
85                "content_hash": {"type": "string", "description": "Hash from the last `read`. ADVISORY: the tool auto re-reads and retries on conflict, so a stale hash still succeeds."},
86                "github": {"type": "object", "readOnly": true, "description": "READ-ONLY. Populated by GitHub sync (Phase 6); cannot be set via this tool."}
87            },
88            "required": ["action"]
89        })
90    }
91
92    fn essential(&self) -> bool {
93        false
94    }
95
96    async fn execute(
97        &self,
98        _tool_call_id: &str,
99        params: Value,
100        _signal: Option<tokio::sync::oneshot::Receiver<()>>,
101        ctx: &ToolContext,
102    ) -> Result<AgentToolResult, String> {
103        let action = match params.get("action").and_then(|v| v.as_str()) {
104            Some(a) => a.to_string(),
105            None => return Ok(AgentToolResult::error("missing required field: action")),
106        };
107
108        // Guard the disk before dispatch: reject oversize payloads early (#5).
109        if let Err(e) = validate_size(&params, &action) {
110            return Ok(AgentToolResult::error(e));
111        }
112
113        let session = ctx.session_id.clone().unwrap_or_default();
114        let result: Result<String, String> = match action.as_str() {
115            "list" => self.list(params),
116            "read" => self.read(params).await,
117            "create" => self.create(params, &session).await,
118            "update" => self.update(params, &session).await,
119            "start" => self.start(params, &session).await,
120            "release" => self.release(params, &session).await,
121            "close" => self.close(params, &session).await,
122            "reopen" => self.reopen(params).await,
123            "link_session" => self.link_session(params, &session).await,
124            other => Err(format!("unknown action: {other}")),
125        };
126
127        Ok(match result {
128            Ok(text) => AgentToolResult::success(text),
129            Err(e) => AgentToolResult::error(e),
130        })
131    }
132}
133
134impl IssueTool {
135    fn list(&self, params: Value) -> Result<String, String> {
136        let status = parse_status_opt(params.get("status"))?;
137        let priority = parse_priority_opt(params.get("priority"))?;
138        let label = params
139            .get("label")
140            .and_then(|v| v.as_str())
141            .map(String::from);
142        let text = params
143            .get("text")
144            .and_then(|v| v.as_str())
145            .map(String::from);
146        let filter = IssueFilter {
147            status,
148            priority,
149            label,
150            assigned_to_session: None,
151            text,
152        };
153        let issues = self.store.list(&filter).map_err(|e| e.to_string())?;
154        if issues.is_empty() {
155            return Ok("no issues match the filter".to_string());
156        }
157        Ok(issues
158            .iter()
159            .map(format_issue_line)
160            .collect::<Vec<_>>()
161            .join("\n"))
162    }
163
164    async fn read(&self, params: Value) -> Result<String, String> {
165        let id = require_u32(params.get("id"), "id")?;
166        self.store
167            .read(id)
168            .map(|(issue, hash)| format_issue_full(&issue, &hash))
169            .map_err(|e| e.to_string())
170    }
171
172    async fn create(&self, params: Value, session: &str) -> Result<String, String> {
173        let title = require_string(params.get("title"), "title")?;
174        let body = params
175            .get("body")
176            .and_then(|v| v.as_str())
177            .unwrap_or("")
178            .to_string();
179        let priority = parse_priority_opt(params.get("priority"))?.unwrap_or(Priority::Medium);
180        let labels = parse_labels(params.get("labels"))?;
181        let session_opt = if session.is_empty() {
182            None
183        } else {
184            Some(session)
185        };
186        let issue = self
187            .store
188            .create(title, body, priority, labels, session_opt)
189            .map_err(|e| e.to_string())?;
190        Ok(format!(
191            "created issue #{}: {}",
192            issue.meta.id, issue.meta.title
193        ))
194    }
195
196    async fn update(&self, params: Value, session: &str) -> Result<String, String> {
197        let id = require_u32(params.get("id"), "id")?;
198        let agent_hash = hash_param(params.get("content_hash"));
199        // IssuePatch makes absent vs [] unambiguous: labels absent → None (keep),
200        // labels: [] → Some(vec![]) (clear). Resolves #3.
201        let patch = IssuePatch {
202            title: params
203                .get("title")
204                .and_then(|v| v.as_str())
205                .map(String::from),
206            body: params
207                .get("body")
208                .and_then(|v| v.as_str())
209                .map(String::from),
210            status: parse_status_opt(params.get("status"))?,
211            priority: parse_priority_opt(params.get("priority"))?,
212            labels: params
213                .get("labels")
214                .map(|v| parse_labels(Some(v)))
215                .transpose()?,
216        };
217        let caller = if session.is_empty() {
218            None
219        } else {
220            Some(session.to_string())
221        };
222        let store = self.store.clone();
223        cas_retry(&store, id, agent_hash, |hash| {
224            let store = store.clone();
225            let patch = patch.clone();
226            let caller = caller.clone();
227            async move { store.apply_patch(id, patch, caller, hash).await }
228        })
229        .await
230        .map(|issue| format!("updated issue #{}", issue.meta.id))
231        .map_err(|e| e.to_string())
232    }
233
234    async fn start(&self, params: Value, session: &str) -> Result<String, String> {
235        let id = require_u32(params.get("id"), "id")?;
236        if session.is_empty() {
237            return Err("cannot start: no active session id in context".to_string());
238        }
239        let agent_hash = hash_param(params.get("content_hash"));
240        let store = self.store.clone();
241        let session = session.to_string();
242        cas_retry(&store, id, agent_hash, |hash| {
243            let store = store.clone();
244            let session = session.clone();
245            async move { store.start(id, &session, hash).await }
246        })
247        .await
248        .map(|issue| format!("assigned issue #{} to session {}", issue.meta.id, session))
249        .map_err(|e| e.to_string())
250    }
251
252    async fn release(&self, params: Value, session: &str) -> Result<String, String> {
253        let id = require_u32(params.get("id"), "id")?;
254        if session.is_empty() {
255            return Err("cannot release: no active session id in context".to_string());
256        }
257        let agent_hash = hash_param(params.get("content_hash"));
258        let store = self.store.clone();
259        let session = session.to_string();
260        cas_retry(&store, id, agent_hash, |hash| {
261            let store = store.clone();
262            let session = session.clone();
263            async move { store.release(id, &session, hash).await }
264        })
265        .await
266        .map(|_| format!("released issue #{id}"))
267        .map_err(|e| e.to_string())
268    }
269
270    async fn close(&self, params: Value, session: &str) -> Result<String, String> {
271        let id = require_u32(params.get("id"), "id")?;
272        if session.is_empty() {
273            return Err("cannot close: no active session id in context".to_string());
274        }
275        let agent_hash = hash_param(params.get("content_hash"));
276        let store = self.store.clone();
277        let session = session.to_string();
278        cas_retry(&store, id, agent_hash, |hash| {
279            let store = store.clone();
280            let session = session.clone();
281            async move { store.close(id, &session, hash).await }
282        })
283        .await
284        .map(|issue| format!("closed issue #{}: {}", issue.meta.id, issue.meta.title))
285        .map_err(|e| e.to_string())
286    }
287
288    async fn reopen(&self, params: Value) -> Result<String, String> {
289        let id = require_u32(params.get("id"), "id")?;
290        let agent_hash = hash_param(params.get("content_hash"));
291        let store = self.store.clone();
292        cas_retry(&store, id, agent_hash, |hash| {
293            let store = store.clone();
294            async move { store.reopen(id, hash).await }
295        })
296        .await
297        .map(|issue| format!("reopened issue #{}: {}", issue.meta.id, issue.meta.title))
298        .map_err(|e| e.to_string())
299    }
300
301    async fn link_session(&self, params: Value, session: &str) -> Result<String, String> {
302        let id = require_u32(params.get("id"), "id")?;
303        if session.is_empty() {
304            return Err("cannot link_session: no active session id in context".to_string());
305        }
306        let agent_hash = hash_param(params.get("content_hash"));
307        let store = self.store.clone();
308        let session = session.to_string();
309        cas_retry(&store, id, agent_hash, |hash| {
310            let store = store.clone();
311            let session = session.clone();
312            async move { store.link_session(id, &session, hash).await }
313        })
314        .await
315        .map(|_| format!("linked session to issue #{id}"))
316        .map_err(|e| e.to_string())
317    }
318}
319
320// ── Formatting helpers (shared with the CLI subcommand in Phase 1.5) ─────
321
322/// Render an issue as a single summary line (id, status, priority, lock,
323/// title, labels, assignee). Used by both the agent tool and the CLI.
324pub fn format_issue_line(i: &Issue) -> String {
325    let lock = if i.meta.assigned_to.is_some() {
326        "🔒"
327    } else {
328        " "
329    };
330    let assignee = i
331        .meta
332        .assigned_to
333        .as_ref()
334        .map(|a| format!(" (assigned: {})", short_session(&a.session)))
335        .unwrap_or_default();
336    format!(
337        "#{:<4} [{}] {:8} {}{} {}{}",
338        i.meta.id,
339        i.meta.status,
340        i.meta.priority,
341        lock,
342        i.meta.title,
343        i.meta.labels.join(","),
344        assignee,
345    )
346}
347
348/// Render a full issue view (summary line + meta + body). Used by both the
349/// agent tool and the CLI.
350pub fn format_issue_full(i: &Issue, hash: &str) -> String {
351    let mut s = format_issue_line(i);
352    s.push('\n');
353    s.push_str(&format!("  id: {}\n", i.meta.id));
354    s.push_str(&format!("  created: {}\n", i.meta.created_at));
355    s.push_str(&format!("  updated: {}\n", i.meta.updated_at));
356    if let Some(c) = i.meta.closed_at {
357        s.push_str(&format!("  closed: {}\n", c));
358    }
359    s.push_str(&format!("  sessions: {:?}\n", i.meta.sessions));
360    if let Some(a) = &i.meta.assigned_to {
361        s.push_str(&format!(
362            "  assigned: {} (since {})\n",
363            short_session(&a.session),
364            a.acquired_at
365        ));
366    }
367    s.push_str(&format!("  content_hash: {}\n", hash));
368    s.push('\n');
369    s.push_str(&i.body);
370    s
371}
372
373fn short_session(s: &str) -> String {
374    if s.len() <= 8 {
375        s.to_string()
376    } else {
377        format!("{}…", &s[..8])
378    }
379}
380
381// ── Param parsers (centralized so each action doesn't repeat) ────────────
382
383/// Maximum CAS attempts before giving up (#2). The first attempt uses the
384/// agent's `content_hash` (fast path); on each [`IssueError::Conflict`] we
385/// re-read a fresh hash and retry. So a stale hash from the agent still
386/// succeeds as long as contention resolves within the bound. See
387/// `docs/designs/2026-06-17-issue-system-hardening.md` §1.4 for why the hash
388/// gate is *required* for safe automatic recovery.
389const MAX_CAS_ATTEMPTS: u32 = 4;
390
391/// Run an issue store mutation under bounded compare-and-set retry.
392///
393/// The store is deliberately strict: it returns raw [`IssueError::Conflict`]
394/// and never retries on its own (principle: strictness in the store, recovery
395/// in the tool). This helper owns the recovery — re-reading a fresh hash after
396/// each conflict and retrying up to [`MAX_CAS_ATTEMPTS`] times.
397async fn cas_retry<T, F, Fut>(
398    store: &FileIssueStore,
399    id: u32,
400    agent_hash: Option<String>,
401    mut op: F,
402) -> Result<T, IssueError>
403where
404    F: FnMut(Option<String>) -> Fut,
405    Fut: std::future::Future<Output = Result<T, IssueError>> + Send,
406    T: Send,
407{
408    let mut hash = agent_hash;
409    for attempt in 0..MAX_CAS_ATTEMPTS {
410        match op(hash.clone()).await {
411            Ok(v) => return Ok(v),
412            Err(IssueError::Conflict { .. }) if attempt + 1 < MAX_CAS_ATTEMPTS => {
413                tracing::debug!(
414                    id,
415                    attempt = attempt + 1,
416                    "issue CAS conflict, re-reading fresh hash"
417                );
418                hash = store.read(id).ok().map(|(_, h)| h);
419                continue;
420            }
421            Err(e) => return Err(e),
422        }
423    }
424    Err(IssueError::Conflict { id })
425}
426
427/// Extract a non-empty `content_hash` from params (absent/empty → `None`).
428fn hash_param(v: Option<&Value>) -> Option<String> {
429    v.and_then(|x| x.as_str())
430        .filter(|s| !s.is_empty())
431        .map(String::from)
432}
433
434// ── Size limits (#5) — early rejection to prevent disk fill / oversized docs ──
435
436/// Maximum title length, in characters.
437const MAX_TITLE_LEN: usize = 512;
438/// Maximum body length, in bytes (256 KiB).
439const MAX_BODY_LEN: usize = 256 * 1024;
440/// Maximum number of labels per issue.
441const MAX_LABELS: usize = 32;
442/// Maximum length of a single label, in characters.
443const MAX_LABEL_LEN: usize = 64;
444
445/// Reject oversized `create`/`update` payloads before they touch the store.
446///
447/// Size is enforced only for the actions that accept free-form text
448/// (`create`, `update`); read-only actions (`list`/`read`) are unaffected.
449/// `title`/`label` length is measured in `char`s (grapheme-safe enough for a
450/// bound); `body` in bytes (the on-disk cost).
451fn validate_size(params: &Value, action: &str) -> Result<(), String> {
452    if !matches!(action, "create" | "update") {
453        return Ok(());
454    }
455    if let Some(t) = params.get("title").and_then(|v| v.as_str())
456        && t.chars().count() > MAX_TITLE_LEN
457    {
458        return Err(format!("title too long (max {MAX_TITLE_LEN} chars)"));
459    }
460    if let Some(b) = params.get("body").and_then(|v| v.as_str())
461        && b.len() > MAX_BODY_LEN
462    {
463        return Err(format!("body too large (max {MAX_BODY_LEN} bytes)"));
464    }
465    if let Some(l) = params.get("labels").and_then(|v| v.as_array()) {
466        if l.len() > MAX_LABELS {
467            return Err(format!("too many labels (max {MAX_LABELS})"));
468        }
469        for item in l {
470            if item.as_str().map(|s| s.chars().count()).unwrap_or(0) > MAX_LABEL_LEN {
471                return Err(format!("label too long (max {MAX_LABEL_LEN} chars)"));
472            }
473        }
474    }
475    Ok(())
476}
477
478fn require_string(v: Option<&Value>, name: &str) -> Result<String, String> {
479    v.and_then(|x| x.as_str())
480        .map(String::from)
481        .ok_or_else(|| format!("missing required field: {name}"))
482}
483
484fn require_u32(v: Option<&Value>, name: &str) -> Result<u32, String> {
485    v.and_then(|x| x.as_u64())
486        .and_then(|n| u32::try_from(n).ok())
487        .ok_or_else(|| format!("missing or invalid field: {name}"))
488}
489
490fn parse_status_opt(v: Option<&Value>) -> Result<Option<Status>, String> {
491    let Some(v) = v else { return Ok(None) };
492    let s = v
493        .as_str()
494        .ok_or_else(|| "status must be a string".to_string())?;
495    match s {
496        "open" => Ok(Some(Status::Open)),
497        "closed" => Ok(Some(Status::Closed)),
498        other => Err(format!("invalid status: {other}")),
499    }
500}
501
502fn parse_priority_opt(v: Option<&Value>) -> Result<Option<Priority>, String> {
503    let Some(v) = v else { return Ok(None) };
504    let s = v
505        .as_str()
506        .ok_or_else(|| "priority must be a string".to_string())?;
507    match s {
508        "low" => Ok(Some(Priority::Low)),
509        "medium" => Ok(Some(Priority::Medium)),
510        "high" => Ok(Some(Priority::High)),
511        "critical" => Ok(Some(Priority::Critical)),
512        other => Err(format!("invalid priority: {other}")),
513    }
514}
515
516fn parse_labels(v: Option<&Value>) -> Result<Vec<String>, String> {
517    let Some(v) = v else { return Ok(vec![]) };
518    let arr = v
519        .as_array()
520        .ok_or_else(|| "labels must be an array of strings".to_string())?;
521    let mut out = Vec::with_capacity(arr.len());
522    for item in arr {
523        let s = item
524            .as_str()
525            .ok_or_else(|| "labels must be an array of strings".to_string())?;
526        out.push(s.to_string());
527    }
528    Ok(out)
529}
530
531#[cfg(test)]
532mod tests {
533    //! Phase 2 coverage for the tool-layer CAS retry helper (#2). The store
534    //! is strict (raw Conflict, no retry); `cas_retry` owns recovery.
535    use super::*;
536
537    fn tmp_store() -> (tempfile::TempDir, FileIssueStore) {
538        let tmp = tempfile::tempdir().unwrap();
539        let dir = tmp.path().join(".oxi").join("issues");
540        std::fs::create_dir_all(&dir).unwrap();
541        (tmp, FileIssueStore::open(dir).unwrap())
542    }
543
544    #[tokio::test]
545    async fn cas_retry_recovers_from_stale_hash() {
546        // The agent passes a stale/wrong content_hash. First attempt conflicts;
547        // cas_retry re-reads a fresh hash and retries → succeeds. This is the
548        // whole point of #2: a stale hash is advisory, not fatal.
549        let (_tmp, store) = tmp_store();
550        store
551            .create("T".into(), "b".into(), Priority::Low, vec![], None)
552            .unwrap();
553        let id = 1;
554
555        let result: Result<Issue, _> = cas_retry(
556            &store,
557            id,
558            Some("deadbeefdeadbeef".to_string()), // deliberately wrong
559            |hash| {
560                let store = store.clone();
561                async move {
562                    store
563                        .apply_patch(
564                            id,
565                            IssuePatch {
566                                title: Some("Patched".into()),
567                                ..Default::default()
568                            },
569                            None,
570                            hash,
571                        )
572                        .await
573                }
574            },
575        )
576        .await;
577        let issue = result.expect("cas_retry should recover from a stale hash");
578        assert_eq!(issue.meta.title, "Patched");
579    }
580
581    #[tokio::test]
582    async fn cas_retry_gives_up_after_bound() {
583        // If contention never resolves, cas_retry surfaces Conflict after
584        // MAX_CAS_ATTEMPTS — it never loops forever.
585        let (_tmp, store) = tmp_store();
586        store
587            .create("T".into(), "b".into(), Priority::Low, vec![], None)
588            .unwrap();
589        let id = 1;
590
591        let result: Result<Issue, _> = cas_retry(&store, id, None, |_hash| async move {
592            Err(IssueError::Conflict { id })
593        })
594        .await;
595        assert!(
596            matches!(result, Err(IssueError::Conflict { id: 1 })),
597            "must give up with Conflict after the bound, got: {result:?}"
598        );
599    }
600
601    // ── Phase 3 coverage: size limits (#5) ──
602
603    #[test]
604    fn validate_size_passes_small_payload() {
605        let p = json!({"title": "ok", "body": "short", "labels": ["a", "b"]});
606        assert!(validate_size(&p, "create").is_ok());
607        assert!(validate_size(&p, "update").is_ok());
608    }
609
610    #[test]
611    fn validate_size_skips_non_text_actions() {
612        // list/read/start/etc. never hit the size gate even with huge values.
613        let p = json!({"body": "x".repeat(300_000)});
614        assert!(validate_size(&p, "list").is_ok());
615        assert!(validate_size(&p, "start").is_ok());
616    }
617
618    #[test]
619    fn validate_size_rejects_oversize_body() {
620        let p = json!({"body": "x".repeat(MAX_BODY_LEN + 1)});
621        let err = validate_size(&p, "create").unwrap_err();
622        assert!(err.contains("body too large"), "got: {err}");
623    }
624
625    #[test]
626    fn validate_size_rejects_oversize_title() {
627        let p = json!({"title": "x".repeat(MAX_TITLE_LEN + 1)});
628        let err = validate_size(&p, "update").unwrap_err();
629        assert!(err.contains("title too long"), "got: {err}");
630    }
631
632    #[test]
633    fn validate_size_rejects_too_many_labels() {
634        let labels: Vec<&str> = (0..(MAX_LABELS + 1)).map(|_| "l").collect();
635        let p = json!({"labels": labels});
636        let err = validate_size(&p, "create").unwrap_err();
637        assert!(err.contains("too many labels"), "got: {err}");
638    }
639
640    #[test]
641    fn validate_size_rejects_long_label() {
642        let p = json!({"labels": ["x".repeat(MAX_LABEL_LEN + 1)]});
643        let err = validate_size(&p, "create").unwrap_err();
644        assert!(err.contains("label too long"), "got: {err}");
645    }
646}