Skip to main content

max / goingson

4.2 KB · 143 lines History Blame Raw
1 //! Project tools: `list_projects` (read) and `create_project` (write,
2 //! idempotent on name).
3
4 use std::sync::Arc;
5
6 use async_trait::async_trait;
7 use goingson_core::repository::ProjectRepository;
8 use goingson_core::models::ParseableEnum;
9 use goingson_core::{NewProject, ProjectStatus, ProjectType};
10 use kberg::{Error, Result, Tool, ToolCallResult, ToolKind};
11 use serde_json::{Value, json};
12
13 use crate::caps;
14 use crate::context::Ctx;
15 use crate::convert::req_str;
16
17 fn fail(tool: &str, e: impl std::fmt::Display) -> Error {
18 Error::ToolFailed {
19 tool: tool.to_string(),
20 message: e.to_string(),
21 }
22 }
23
24 pub struct ListProjects(pub Arc<Ctx>);
25
26 #[async_trait]
27 impl Tool for ListProjects {
28 fn name(&self) -> &str {
29 "list_projects"
30 }
31 fn description(&self) -> &str {
32 "List all projects (id, name, type, status). Use to resolve a project name to its id before creating tasks."
33 }
34 fn kind(&self) -> ToolKind {
35 ToolKind::Read
36 }
37 fn small_model_safe(&self) -> bool {
38 true
39 }
40 fn input_schema(&self) -> Value {
41 json!({ "type": "object", "properties": {} })
42 }
43 async fn call(&self, _args: Value) -> Result<ToolCallResult> {
44 let projects = self
45 .0
46 .projects()
47 .list_all(self.0.user_id)
48 .await
49 .map_err(|e| fail(self.name(), e))?;
50 let rows: Vec<Value> = projects
51 .iter()
52 .map(|p| {
53 json!({
54 "id": p.id.to_string(),
55 "name": p.name,
56 "type": p.project_type.as_str(),
57 "status": p.status.as_str(),
58 })
59 })
60 .collect();
61 Ok(ToolCallResult::text(
62 serde_json::to_string(&json!({ "projects": rows })).unwrap(),
63 ))
64 }
65 }
66
67 pub struct CreateProject(pub Arc<Ctx>);
68
69 #[async_trait]
70 impl Tool for CreateProject {
71 fn name(&self) -> &str {
72 "create_project"
73 }
74 fn description(&self) -> &str {
75 "Create a project. Idempotent on name: if a project with the same name exists, its id is returned instead of creating a duplicate. `type` is one of Job, SideProject, Company, Writing (defaults to SideProject)."
76 }
77 fn kind(&self) -> ToolKind {
78 ToolKind::Write(caps::project_create())
79 }
80 fn input_schema(&self) -> Value {
81 json!({
82 "type": "object",
83 "properties": {
84 "name": { "type": "string" },
85 "type": { "type": "string", "description": "Job | SideProject | Company | Writing" },
86 "description": { "type": "string" }
87 },
88 "required": ["name"]
89 })
90 }
91 async fn call(&self, args: Value) -> Result<ToolCallResult> {
92 let name = req_str(self.name(), &args, "name")?;
93 let repo = self.0.projects();
94
95 // Idempotency: return the existing project rather than duplicating.
96 if let Some(existing) = repo
97 .find_by_name(self.0.user_id, name)
98 .await
99 .map_err(|e| fail(self.name(), e))?
100 {
101 return Ok(ToolCallResult::text(
102 serde_json::to_string(&json!({
103 "id": existing.id.to_string(),
104 "created": false,
105 }))
106 .unwrap(),
107 ));
108 }
109
110 let project_type = args
111 .get("type")
112 .and_then(Value::as_str)
113 .map(ProjectType::from_str_or_default)
114 .unwrap_or(ProjectType::SideProject);
115 let description = args
116 .get("description")
117 .and_then(Value::as_str)
118 .unwrap_or_default()
119 .to_string();
120
121 let created = repo
122 .create(
123 self.0.user_id,
124 NewProject {
125 name: name.to_string(),
126 description,
127 project_type,
128 status: ProjectStatus::default(),
129 },
130 )
131 .await
132 .map_err(|e| fail(self.name(), e))?;
133
134 Ok(ToolCallResult::text(
135 serde_json::to_string(&json!({
136 "id": created.id.to_string(),
137 "created": true,
138 }))
139 .unwrap(),
140 ))
141 }
142 }
143