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 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}