//! //! Objective tool registration backed by Workspace API authority. //! //! Objectives are project-level planning context. Runtime Workers may not know //! local `.yoi/objectives` paths, so model-visible Objective tools go through //! the scoped Workspace API. use std::sync::Arc; use async_trait::async_trait; use llm_engine::tool::{ Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, }; use serde::{Deserialize, Serialize}; use serde_json::json; use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod}; #[derive(Clone, Debug)] pub struct WorkspaceHttpObjectiveBackend { client: Arc, } impl WorkspaceHttpObjectiveBackend { pub fn new(client: Arc) -> Self { Self { client } } async fn list(&self, input: ObjectiveListInput) -> Result { let mut url = format!( "/api/w/{}/objectives", self.client.workspace_id().unwrap_or_default() ); if let Some(limit) = input.limit { url.push_str(&format!("?limit={}", limit.min(1000))); } let response = get_json::(self.client.as_ref(), &url) .await .map_err(backend_error)?; let count = response.items.len(); Ok(ToolOutput { summary: format!("Listed {count} objective(s)"), content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?), }) } async fn show(&self, input: ObjectiveShowInput) -> Result { let id = validate_id(&input.id, "ObjectiveShow")?; let url = self.objective_url(id); let response = get_json::(self.client.as_ref(), &url) .await .map_err(backend_error)?; Ok(objective_output( format!("Read objective {}", response.id), response, )?) } async fn create(&self, input: ObjectiveCreateInput) -> Result { if input.title.trim().is_empty() { return Err(ToolError::InvalidArgument( "ObjectiveCreate requires non-empty title".to_string(), )); } let url = format!( "/api/w/{}/objectives", self.client.workspace_id().unwrap_or_default() ); let response = send_json::( self.client.as_ref(), reqwest::Method::POST, &url, &input, ) .await .map_err(backend_error)?; Ok(objective_output( format!("Created objective {}", response.id), response, )?) } async fn edit(&self, input: ObjectiveEditInput) -> Result { let id = validate_id(&input.id, "ObjectiveEdit")?; if input.title.is_none() && input.old_string.is_none() && input.new_string.is_none() { return Err(ToolError::InvalidArgument( "ObjectiveEdit requires title or old_string/new_string".to_string(), )); } let url = self.objective_url(id); let body = ObjectiveEditRequest { title: input.title, old_string: input.old_string, new_string: input.new_string, replace_all: input.replace_all, }; let response = send_json::( self.client.as_ref(), reqwest::Method::PATCH, &url, &body, ) .await .map_err(backend_error)?; Ok(objective_output( format!("Edited objective {}", response.id), response, )?) } async fn set_state(&self, input: ObjectiveSetStateInput) -> Result { let id = validate_id(&input.id, "ObjectiveSetState")?; if input.state.trim().is_empty() { return Err(ToolError::InvalidArgument( "ObjectiveSetState requires non-empty state".to_string(), )); } let url = format!("{}/state", self.objective_url(id)); let response = send_json::( self.client.as_ref(), reqwest::Method::POST, &url, &ObjectiveSetStateRequest { state: input.state }, ) .await .map_err(backend_error)?; Ok(objective_output( format!("Updated objective {} state", response.id), response, )?) } async fn link_ticket(&self, input: ObjectiveLinkTicketInput) -> Result { let id = validate_id(&input.id, "ObjectiveLinkTicket")?; let ticket_id = validate_id(&input.ticket_id, "ObjectiveLinkTicket")?; let url = format!("{}/ticket-links", self.objective_url(id)); let response = send_json::( self.client.as_ref(), reqwest::Method::POST, &url, &ObjectiveLinkTicketRequest { ticket_id: ticket_id.to_string(), }, ) .await .map_err(backend_error)?; Ok(objective_output( format!("Linked ticket {ticket_id} to objective {}", response.id), response, )?) } async fn unlink_ticket( &self, input: ObjectiveUnlinkTicketInput, ) -> Result { let id = validate_id(&input.id, "ObjectiveUnlinkTicket")?; let ticket_id = validate_id(&input.ticket_id, "ObjectiveUnlinkTicket")?; let url = format!("{}/ticket-links/{}", self.objective_url(id), ticket_id); let response = delete_json::(self.client.as_ref(), &url) .await .map_err(backend_error)?; Ok(objective_output( format!("Unlinked ticket {ticket_id} from objective {}", response.id), response, )?) } fn objective_url(&self, id: &str) -> String { let workspace_id = self.client.workspace_id().unwrap_or_default(); format!("/api/w/{workspace_id}/objectives/{id}") } } #[derive(Debug, thiserror::Error)] pub enum WorkspaceObjectiveBackendError { #[error("workspace objective backend request failed: {0}")] Request(#[from] crate::worker::WorkspaceClientError), #[error("workspace objective backend returned HTTP {status}: {body}")] Http { status: reqwest::StatusCode, body: String, }, #[error("decode objective backend response: {0}")] Decode(#[from] serde_json::Error), } fn decode_error(error: serde_json::Error) -> ToolError { ToolError::ExecutionFailed(format!("decode objective backend response: {error}")) } fn backend_error(error: WorkspaceObjectiveBackendError) -> ToolError { ToolError::ExecutionFailed(error.to_string()) } async fn get_json Deserialize<'de>>( client: &dyn WorkspaceClient, path: &str, ) -> Result { decode_response(client.execute(WorkspaceRequest::get(path))?) } async fn send_json Deserialize<'de>>( client: &dyn WorkspaceClient, method: reqwest::Method, path: &str, body: &B, ) -> Result { let method = match method { reqwest::Method::POST => WorkspaceRequestMethod::Post, reqwest::Method::PUT => WorkspaceRequestMethod::Put, reqwest::Method::PATCH => WorkspaceRequestMethod::Patch, reqwest::Method::DELETE => WorkspaceRequestMethod::Delete, _ => WorkspaceRequestMethod::Get, }; decode_response(client.execute(WorkspaceRequest::json( method, path, serde_json::to_string(body)?, ))?) } async fn delete_json Deserialize<'de>>( client: &dyn WorkspaceClient, path: &str, ) -> Result { decode_response(client.execute(WorkspaceRequest { method: WorkspaceRequestMethod::Delete, path: path.to_string(), body: None, })?) } fn decode_response Deserialize<'de>>( response: crate::worker::WorkspaceResponse, ) -> Result { let status = reqwest::StatusCode::from_u16(response.status) .unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR); if !response.is_success() { return Err(WorkspaceObjectiveBackendError::Http { status, body: response.body, }); } serde_json::from_str(&response.body).map_err(Into::into) } fn objective_output(summary: String, response: ObjectiveDetail) -> Result { Ok(ToolOutput { summary, content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?), }) } fn validate_id<'a>(id: &'a str, tool_name: &str) -> Result<&'a str, ToolError> { let id = id.trim(); if id.is_empty() || id.contains('/') { return Err(ToolError::InvalidArgument(format!( "{tool_name} requires non-empty canonical id without '/'" ))); } Ok(id) } pub fn workspace_http_objective_tools(client: Arc) -> Vec { let backend = WorkspaceHttpObjectiveBackend::new(client); vec![ objective_tool( "ObjectiveList", LIST_DESCRIPTION, list_schema(), backend.clone(), ObjectiveOperation::List, ), objective_tool( "ObjectiveShow", SHOW_DESCRIPTION, show_schema(), backend.clone(), ObjectiveOperation::Show, ), objective_tool( "ObjectiveCreate", CREATE_DESCRIPTION, create_schema(), backend.clone(), ObjectiveOperation::Create, ), objective_tool( "ObjectiveEdit", EDIT_DESCRIPTION, edit_schema(), backend.clone(), ObjectiveOperation::Edit, ), objective_tool( "ObjectiveSetState", SET_STATE_DESCRIPTION, set_state_schema(), backend.clone(), ObjectiveOperation::SetState, ), objective_tool( "ObjectiveLinkTicket", LINK_TICKET_DESCRIPTION, link_ticket_schema(), backend.clone(), ObjectiveOperation::LinkTicket, ), objective_tool( "ObjectiveUnlinkTicket", UNLINK_TICKET_DESCRIPTION, unlink_ticket_schema(), backend, ObjectiveOperation::UnlinkTicket, ), ] } #[derive(Clone, Copy)] enum ObjectiveOperation { List, Show, Create, Edit, SetState, LinkTicket, UnlinkTicket, } fn objective_tool( name: &'static str, description: &'static str, schema: serde_json::Value, backend: WorkspaceHttpObjectiveBackend, operation: ObjectiveOperation, ) -> ToolDefinition { Arc::new(move || { ( ToolMeta::new(name) .description(description) .input_schema(schema.clone()), Arc::new(WorkspaceHttpObjectiveTool { backend: backend.clone(), operation, }) as Arc, ) }) } #[derive(Clone)] struct WorkspaceHttpObjectiveTool { backend: WorkspaceHttpObjectiveBackend, operation: ObjectiveOperation, } #[async_trait] impl Tool for WorkspaceHttpObjectiveTool { async fn execute( &self, input_json: &str, _ctx: ToolExecutionContext, ) -> Result { match self.operation { ObjectiveOperation::List => { let input = parse_input::(input_json)?; self.backend.list(input).await } ObjectiveOperation::Show => { let input = parse_input::(input_json)?; self.backend.show(input).await } ObjectiveOperation::Create => { let input = parse_input::(input_json)?; self.backend.create(input).await } ObjectiveOperation::Edit => { let input = parse_input::(input_json)?; self.backend.edit(input).await } ObjectiveOperation::SetState => { let input = parse_input::(input_json)?; self.backend.set_state(input).await } ObjectiveOperation::LinkTicket => { let input = parse_input::(input_json)?; self.backend.link_ticket(input).await } ObjectiveOperation::UnlinkTicket => { let input = parse_input::(input_json)?; self.backend.unlink_ticket(input).await } } } } fn parse_input Deserialize<'de>>(input: &str) -> Result { serde_json::from_str(input).map_err(|error| ToolError::InvalidArgument(error.to_string())) } const LIST_DESCRIPTION: &str = "List Objective records through Backend Workspace API authority as bounded summaries."; const SHOW_DESCRIPTION: &str = "Show one Objective record by canonical id through Backend Workspace API authority."; const CREATE_DESCRIPTION: &str = "Create an Objective record through Backend Workspace API authority."; const EDIT_DESCRIPTION: &str = "Partially edit an Objective title and/or body through Backend Workspace API authority."; const SET_STATE_DESCRIPTION: &str = "Set an Objective state through Backend Workspace API authority."; const LINK_TICKET_DESCRIPTION: &str = "Link a Ticket id to an Objective through Backend Workspace API authority."; const UNLINK_TICKET_DESCRIPTION: &str = "Unlink a Ticket id from an Objective through Backend Workspace API authority."; fn list_schema() -> serde_json::Value { json!({ "type":"object", "additionalProperties": false, "properties":{ "limit":{"type":["integer","null"],"minimum":0,"maximum":1000} } }) } fn show_schema() -> serde_json::Value { id_schema(&["id"]) } fn create_schema() -> serde_json::Value { json!({ "type":"object", "additionalProperties": false, "required":["title"], "properties":{ "title":{"type":"string","minLength":1}, "body_md":{"type":"string"}, "state":{"type":"string","default":"active"}, "linked_tickets":{"type":"array","items":{"type":"string"}} } }) } fn edit_schema() -> serde_json::Value { json!({ "type":"object", "additionalProperties": false, "required":["id"], "properties":{ "id":{"type":"string"}, "title":{"type":["string","null"]}, "old_string":{"type":["string","null"]}, "new_string":{"type":["string","null"]}, "replace_all":{"type":"boolean","default":false} } }) } fn set_state_schema() -> serde_json::Value { json!({ "type":"object", "additionalProperties": false, "required":["id","state"], "properties":{ "id":{"type":"string"}, "state":{"type":"string","minLength":1} } }) } fn link_ticket_schema() -> serde_json::Value { id_ticket_schema(&["id", "ticket_id"]) } fn unlink_ticket_schema() -> serde_json::Value { id_ticket_schema(&["id", "ticket_id"]) } fn id_schema(required: &[&str]) -> serde_json::Value { json!({ "type":"object", "additionalProperties": false, "required": required, "properties":{ "id":{"type":"string"} } }) } fn id_ticket_schema(required: &[&str]) -> serde_json::Value { json!({ "type":"object", "additionalProperties": false, "required": required, "properties":{ "id":{"type":"string"}, "ticket_id":{"type":"string"} } }) } #[derive(Debug, Deserialize)] struct ObjectiveListInput { limit: Option, } #[derive(Debug, Deserialize)] struct ObjectiveShowInput { id: String, } #[derive(Debug, Serialize, Deserialize)] struct ObjectiveCreateInput { title: String, #[serde(default)] body_md: String, #[serde(default = "default_state")] state: String, #[serde(default)] linked_tickets: Vec, } #[derive(Debug, Deserialize)] struct ObjectiveEditInput { id: String, title: Option, old_string: Option, new_string: Option, #[serde(default)] replace_all: bool, } #[derive(Debug, Serialize)] struct ObjectiveEditRequest { title: Option, old_string: Option, new_string: Option, replace_all: bool, } #[derive(Debug, Deserialize)] struct ObjectiveSetStateInput { id: String, state: String, } #[derive(Debug, Serialize)] struct ObjectiveSetStateRequest { state: String, } #[derive(Debug, Deserialize)] struct ObjectiveLinkTicketInput { id: String, ticket_id: String, } #[derive(Debug, Deserialize)] struct ObjectiveUnlinkTicketInput { id: String, ticket_id: String, } #[derive(Debug, Serialize)] struct ObjectiveLinkTicketRequest { ticket_id: String, } fn default_state() -> String { "active".to_string() } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] struct ObjectiveListResponse { items: Vec, invalid_records: Vec, record_authority: String, } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] struct InvalidProjectRecord { label: String, reason: String, } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] struct ObjectiveSummary { id: String, title: String, state: String, updated_at: Option, summary: String, linked_tickets: Vec, record_source: String, } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] struct ObjectiveDetail { id: String, title: String, state: String, created_at: Option, updated_at: Option, linked_tickets: Vec, body: String, body_truncated: bool, record_source: String, } #[cfg(test)] mod tests { use super::*; use llm_engine::tool::ToolDefinition; fn tool_names(definitions: Vec) -> Vec { let mut names = definitions .into_iter() .map(|tool| tool().0.name) .collect::>(); names.sort(); names } #[test] fn workspace_http_objective_tools_include_objective_crud_tools() { let names = tool_names(workspace_http_objective_tools(Arc::new( crate::worker::RuntimeWorkspaceHttpClient::new( "workspace", "http://backend", "test-worker", ), ))); assert_eq!( names, vec![ "ObjectiveCreate", "ObjectiveEdit", "ObjectiveLinkTicket", "ObjectiveList", "ObjectiveSetState", "ObjectiveShow", "ObjectiveUnlinkTicket", ] ); } #[test] fn objective_tool_schemas_are_bounded_and_mutation_scoped() { let list = list_schema(); assert_eq!(list["properties"]["limit"]["maximum"], 1000); let show = show_schema(); assert_eq!(show["required"][0], "id"); let create = create_schema(); assert_eq!(create["required"][0], "title"); let edit = edit_schema(); assert_eq!(edit["required"][0], "id"); let link = link_ticket_schema(); assert_eq!(link["required"], json!(["id", "ticket_id"])); } }