Skip to main content

roder_api/
goals.rs

1use serde::{Deserialize, Serialize};
2use time::OffsetDateTime;
3
4use crate::events::ThreadId;
5
6pub const MAX_THREAD_GOAL_OBJECTIVE_CHARS: usize = 4000;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "camelCase")]
10pub enum ThreadGoalStatus {
11    Active,
12    Paused,
13    Blocked,
14    UsageLimited,
15    BudgetLimited,
16    Complete,
17}
18
19impl ThreadGoalStatus {
20    pub fn as_str(self) -> &'static str {
21        match self {
22            Self::Active => "active",
23            Self::Paused => "paused",
24            Self::Blocked => "blocked",
25            Self::UsageLimited => "usageLimited",
26            Self::BudgetLimited => "budgetLimited",
27            Self::Complete => "complete",
28        }
29    }
30
31    pub fn is_active(self) -> bool {
32        matches!(self, Self::Active)
33    }
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct ThreadGoal {
39    pub thread_id: ThreadId,
40    pub objective: String,
41    pub status: ThreadGoalStatus,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub token_budget: Option<i64>,
44    #[serde(default)]
45    pub tokens_used: i64,
46    #[serde(default)]
47    pub time_used_seconds: i64,
48    #[serde(with = "time::serde::rfc3339")]
49    pub created_at: OffsetDateTime,
50    #[serde(with = "time::serde::rfc3339")]
51    pub updated_at: OffsetDateTime,
52}
53
54#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
55pub struct ThreadGoalPatch {
56    pub objective: Option<String>,
57    pub status: Option<ThreadGoalStatus>,
58    pub token_budget: Option<Option<i64>>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct ThreadGoalUpdated {
63    pub thread_id: ThreadId,
64    pub goal: ThreadGoal,
65    #[serde(with = "time::serde::rfc3339")]
66    pub timestamp: OffsetDateTime,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct ThreadGoalCleared {
71    pub thread_id: ThreadId,
72    #[serde(with = "time::serde::rfc3339")]
73    pub timestamp: OffsetDateTime,
74}
75
76pub fn validate_thread_goal_objective(objective: &str) -> anyhow::Result<()> {
77    let objective = objective.trim();
78    if objective.is_empty() {
79        anyhow::bail!("goal objective cannot be empty");
80    }
81    let count = objective.chars().count();
82    if count > MAX_THREAD_GOAL_OBJECTIVE_CHARS {
83        anyhow::bail!("goal objective cannot exceed {MAX_THREAD_GOAL_OBJECTIVE_CHARS} characters");
84    }
85    Ok(())
86}
87
88pub fn validate_thread_goal_budget(token_budget: Option<i64>) -> anyhow::Result<()> {
89    if let Some(token_budget) = token_budget
90        && token_budget <= 0
91    {
92        anyhow::bail!("goal token budget must be positive");
93    }
94    Ok(())
95}
96
97#[async_trait::async_trait]
98pub trait ThreadGoalController: Send + Sync + 'static {
99    async fn get_thread_goal(&self, thread_id: &ThreadId) -> anyhow::Result<Option<ThreadGoal>>;
100
101    /// Create a new active thread goal, replacing any existing goal for the thread.
102    async fn create_thread_goal(
103        &self,
104        thread_id: &ThreadId,
105        objective: String,
106        token_budget: Option<i64>,
107    ) -> anyhow::Result<ThreadGoal>;
108
109    async fn set_thread_goal(
110        &self,
111        thread_id: &ThreadId,
112        patch: ThreadGoalPatch,
113    ) -> anyhow::Result<Option<ThreadGoal>>;
114
115    async fn clear_thread_goal(&self, thread_id: &ThreadId) -> anyhow::Result<bool>;
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn validates_thread_goal_objective() {
124        assert!(validate_thread_goal_objective("ship it").is_ok());
125        assert!(validate_thread_goal_objective("  ").is_err());
126        assert!(
127            validate_thread_goal_objective(&"a".repeat(MAX_THREAD_GOAL_OBJECTIVE_CHARS + 1))
128                .is_err()
129        );
130    }
131
132    #[test]
133    fn validates_thread_goal_budget() {
134        assert!(validate_thread_goal_budget(None).is_ok());
135        assert!(validate_thread_goal_budget(Some(1)).is_ok());
136        assert!(validate_thread_goal_budget(Some(0)).is_err());
137    }
138}