diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index 54cf647d..d488cdb3 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -85,6 +85,8 @@ pub struct FeatureConfigPartial { #[serde(default)] pub workers: Option, #[serde(default)] + pub objective: Option, + #[serde(default)] pub ticket: Option, #[serde(default)] pub plugins: Option, @@ -97,6 +99,11 @@ impl FeatureConfigPartial { memory: merge_option(self.memory, other.memory, MemoryFeatureConfigPartial::merge), web: merge_option(self.web, other.web, FeatureFlagConfigPartial::merge), workers: merge_option(self.workers, other.workers, FeatureFlagConfigPartial::merge), + objective: merge_option( + self.objective, + other.objective, + FeatureFlagConfigPartial::merge, + ), ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge), plugins: merge_option(self.plugins, other.plugins, FeatureFlagConfigPartial::merge), } @@ -169,6 +176,10 @@ impl From for FeatureConfig { .workers .map(FeatureFlagConfig::from) .unwrap_or_default(), + objective: value + .objective + .map(FeatureFlagConfig::from) + .unwrap_or_default(), ticket: value .ticket .map(TicketFeatureConfig::from) @@ -246,6 +257,7 @@ impl From for FeatureConfigPartial { memory: Some(value.memory.into()), web: Some(value.web.into()), workers: Some(value.workers.into()), + objective: Some(value.objective.into()), ticket: Some(value.ticket.into()), plugins: Some(value.plugins.into()), } @@ -1791,6 +1803,7 @@ worker_max_turns = 7 assert!(!manifest.feature.memory.enabled); assert!(!manifest.feature.web.enabled); assert!(!manifest.feature.workers.enabled); + assert!(!manifest.feature.objective.enabled); assert!(!manifest.feature.ticket.enabled); } @@ -1842,6 +1855,7 @@ orchestration_control = false assert!(!manifest.feature.ticket.orchestration_control); assert!(!manifest.feature.memory.enabled); assert!(!manifest.feature.memory.staging); + assert!(!manifest.feature.objective.enabled); } #[test] @@ -1869,6 +1883,9 @@ orchestration_control = true [feature.memory] staging = true +[feature.objective] +enabled = true + [feature.web] enabled = true "#, @@ -1906,6 +1923,7 @@ enabled = true assert!(manifest.feature.ticket.thread); assert!(!manifest.feature.ticket.intake); assert!(manifest.feature.ticket.orchestration_control); + assert!(manifest.feature.objective.enabled); assert!(manifest.feature.web.enabled); assert!(!manifest.feature.workers.enabled); } diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 538db092..1db252aa 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -113,6 +113,8 @@ pub struct FeatureConfig { #[serde(default)] pub workers: FeatureFlagConfig, #[serde(default)] + pub objective: FeatureFlagConfig, + #[serde(default)] pub ticket: TicketFeatureConfig, #[serde(default)] pub plugins: FeatureFlagConfig, @@ -125,6 +127,7 @@ impl Default for FeatureConfig { memory: MemoryFeatureConfig::disabled(), web: FeatureFlagConfig::disabled(), workers: FeatureFlagConfig::disabled(), + objective: FeatureFlagConfig::disabled(), ticket: TicketFeatureConfig::default(), plugins: FeatureFlagConfig::disabled(), } diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 62eb337f..1be68f5b 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -962,6 +962,7 @@ fn builtin_profile_artifact(label: &str) -> Option { value["feature"]["memory"] = serde_json::json!({ "enabled": true, "staging": true }); value["feature"]["web"] = serde_json::json!({ "enabled": false }); value["feature"]["workers"] = serde_json::json!({ "enabled": false }); + value["feature"]["objective"] = serde_json::json!({ "enabled": false }); value["feature"]["ticket"] = serde_json::json!({ "enabled": false, "thread": false }); Some(value) } @@ -987,6 +988,7 @@ fn builtin_default_profile_artifact() -> serde_json::Value { "memory": { "enabled": true }, "web": { "enabled": true }, "workers": { "enabled": true }, + "objective": { "enabled": true }, "ticket": { "enabled": true, "authoring": true, "thread": true } }, "memory": { @@ -1457,6 +1459,7 @@ mod tests { assert_eq!(resolved.manifest.worker.name, "arbitrary-worker-name"); assert!(resolved.manifest.feature.memory.enabled); assert!(resolved.manifest.feature.memory.staging); + assert!(!resolved.manifest.feature.objective.enabled); } #[test] @@ -1484,6 +1487,7 @@ mod tests { assert!(companion.feature.ticket.enabled); assert!(companion.feature.ticket.authoring); assert!(companion.feature.ticket.thread); + assert!(companion.feature.objective.enabled); assert!(!companion.feature.ticket.intake); assert!(!companion.feature.ticket.orchestration_control); assert_eq!( @@ -1510,6 +1514,7 @@ mod tests { assert!(intake.feature.ticket.enabled); assert!(intake.feature.ticket.authoring); assert!(intake.feature.ticket.thread); + assert!(intake.feature.objective.enabled); assert!(intake.feature.ticket.intake); assert!(!intake.feature.ticket.orchestration_control); assert!(intake.scope.allow.is_empty()); @@ -1525,6 +1530,7 @@ mod tests { assert!(orchestrator.feature.ticket.enabled); assert!(!orchestrator.feature.ticket.authoring); assert!(orchestrator.feature.ticket.thread); + assert!(orchestrator.feature.objective.enabled); assert!(!orchestrator.feature.ticket.intake); assert!(orchestrator.feature.ticket.orchestration_control); assert!(orchestrator.scope.allow.is_empty()); @@ -1548,6 +1554,7 @@ mod tests { assert!(coder.feature.ticket.enabled); assert!(!coder.feature.ticket.authoring); assert!(coder.feature.ticket.thread); + assert!(coder.feature.objective.enabled); assert!(!coder.feature.ticket.intake); assert!(!coder.feature.ticket.orchestration_control); let reviewer = resolve("reviewer"); @@ -1557,6 +1564,7 @@ mod tests { assert!(reviewer.feature.ticket.enabled); assert!(!reviewer.feature.ticket.authoring); assert!(reviewer.feature.ticket.thread); + assert!(reviewer.feature.objective.enabled); assert!(!reviewer.feature.ticket.intake); assert!(!reviewer.feature.ticket.orchestration_control); assert!(reviewer.scope.allow.is_empty()); diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 780f48ec..0ec1277d 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -630,6 +630,29 @@ where let workspace_client = worker.workspace_client().clone(); let worker = worker.engine_mut(); + // Objective tools expose read-only project Objective context through the + // Backend Workspace API. Workers must not guess local `.yoi/objectives` + // paths or read Objective files directly. + if feature_config.objective.enabled { + if let WorkspaceClient::Http { + workspace_id, + base_url, + } = &workspace_client + { + for definition in crate::feature::builtin::objective::workspace_http_objective_tools( + workspace_id.clone(), + base_url.clone(), + ) { + worker.register_tool(definition); + } + } else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "objective tools require Backend Workspace API authority", + )); + } + } + // Memory tools require explicit feature exposure. Workspace memory access // is authority-bound to the Backend Workspace API; the Worker must not // register local filesystem memory tools even when it has local cwd/root diff --git a/crates/worker/src/feature/builtin.rs b/crates/worker/src/feature/builtin.rs index c82c7c3d..1cc2f109 100644 --- a/crates/worker/src/feature/builtin.rs +++ b/crates/worker/src/feature/builtin.rs @@ -5,6 +5,7 @@ //! an external plugin-loading surface. pub mod memory; +pub mod objective; pub mod session_explore; pub mod task; pub mod ticket; diff --git a/crates/worker/src/feature/builtin/objective.rs b/crates/worker/src/feature/builtin/objective.rs new file mode 100644 index 00000000..9e1b14a8 --- /dev/null +++ b/crates/worker/src/feature/builtin/objective.rs @@ -0,0 +1,282 @@ +//! Backend Workspace API backed Objective read tools. +//! +//! 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; + +#[derive(Clone, Debug)] +pub struct WorkspaceHttpObjectiveBackend { + workspace_id: String, + base_url: String, +} + +impl WorkspaceHttpObjectiveBackend { + pub fn new(workspace_id: impl Into, base_url: impl Into) -> Self { + Self { + workspace_id: workspace_id.into(), + base_url: base_url.into().trim_end_matches('/').to_string(), + } + } + + async fn list(&self, input: ObjectiveListInput) -> Result { + let mut url = format!("{}/api/w/{}/objectives", self.base_url, self.workspace_id); + if let Some(limit) = input.limit { + url.push_str(&format!("?limit={}", limit.min(1000))); + } + let response = get_json::(&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 = input.id.trim(); + if id.is_empty() || id.contains('/') { + return Err(ToolError::InvalidArgument( + "ObjectiveShow requires non-empty canonical id without '/'".to_string(), + )); + } + let url = format!( + "{}/api/w/{}/objectives/{}", + self.base_url, self.workspace_id, id + ); + let response = get_json::(&url) + .await + .map_err(backend_error)?; + Ok(ToolOutput { + summary: format!("Read objective {}", response.id), + content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?), + }) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum WorkspaceObjectiveBackendError { + #[error("workspace objective backend request failed: {0}")] + Request(#[from] reqwest::Error), + #[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>>( + url: &str, +) -> Result { + let response = reqwest::Client::new().get(url).send().await?; + let status = response.status(); + let body = response.text().await?; + if !status.is_success() { + return Err(WorkspaceObjectiveBackendError::Http { status, body }); + } + serde_json::from_str(&body).map_err(Into::into) +} + +pub fn workspace_http_objective_tools( + workspace_id: impl Into, + base_url: impl Into, +) -> Vec { + let backend = WorkspaceHttpObjectiveBackend::new(workspace_id, base_url); + vec![ + objective_tool( + "ObjectiveList", + LIST_DESCRIPTION, + list_schema(), + backend.clone(), + ObjectiveOperation::List, + ), + objective_tool( + "ObjectiveShow", + SHOW_DESCRIPTION, + show_schema(), + backend, + ObjectiveOperation::Show, + ), + ] +} + +#[derive(Clone, Copy)] +enum ObjectiveOperation { + List, + Show, +} + +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 + } + } + } +} + +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."; + +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 { + json!({ + "type":"object", + "additionalProperties": false, + "required":["id"], + "properties":{ + "id":{"type":"string"} + } + }) +} + +#[derive(Debug, Deserialize)] +struct ObjectiveListInput { + limit: Option, +} + +#[derive(Debug, Deserialize)] +struct ObjectiveShowInput { + id: 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_read_only_objective_tools() { + let names = tool_names(workspace_http_objective_tools( + "workspace".to_string(), + "http://backend".to_string(), + )); + + assert_eq!(names, vec!["ObjectiveList", "ObjectiveShow"]); + } + + #[test] + fn objective_tool_schemas_are_bounded_and_read_only() { + let list = list_schema(); + assert_eq!(list["properties"]["limit"]["maximum"], 1000); + let show = show_schema(); + assert_eq!(show["required"][0], "id"); + } +} diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index b4eee0ba..8f6ad640 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1156,6 +1156,11 @@ struct MemoryStagingQuery { limit: Option, } +#[derive(Debug, Deserialize)] +struct ObjectiveListQuery { + limit: Option, +} + #[derive(Debug, Deserialize)] struct TranscriptQuery { start: Option, @@ -1726,9 +1731,10 @@ fn skill_api_error(error: skills::SkillError) -> ApiError { async fn scoped_list_objectives( State(api): State, AxumPath(path): AxumPath, + Query(query): Query, ) -> ApiResult>> { validate_workspace_scope(&api, &path.workspace_id)?; - list_objectives(State(api)).await + list_objectives(State(api), Query(query)).await } async fn scoped_get_objective( @@ -3661,8 +3667,9 @@ async fn get_ticket( async fn list_objectives( State(api): State, + Query(query): Query, ) -> ApiResult>> { - let limit = api.config.max_records.min(200); + let limit = query.limit.unwrap_or(api.config.max_records).min(1000); let ProjectRecordList { items, invalid_records, @@ -8413,6 +8420,12 @@ mod tests { ) .await; assert_eq!(scoped_objectives["items"][0]["id"], "00000000001J3"); + let limited_objectives = get_json( + app.clone(), + &format!("/api/w/{TEST_WORKSPACE_ID}/objectives?limit=0"), + ) + .await; + assert_eq!(limited_objectives["items"].as_array().unwrap().len(), 0); let scoped_objective = get_json( app.clone(), &format!("/api/w/{TEST_WORKSPACE_ID}/objectives/00000000001J3"), diff --git a/resources/profiles/default.dcdl b/resources/profiles/default.dcdl index 41b3b763..a8bb72ec 100644 --- a/resources/profiles/default.dcdl +++ b/resources/profiles/default.dcdl @@ -26,6 +26,7 @@ feature = { memory = { enabled = true; }; web = { enabled = true; }; workers = { enabled = true; }; + objective = { enabled = true; }; ticket = { enabled = true; authoring = true; thread = true; }; }; diff --git a/resources/profiles/memory-consolidation.dcdl b/resources/profiles/memory-consolidation.dcdl index 092cc24f..349893ac 100644 --- a/resources/profiles/memory-consolidation.dcdl +++ b/resources/profiles/memory-consolidation.dcdl @@ -8,6 +8,7 @@ import "./default.dcdl" // { memory = { enabled = true; staging = true; }; web = { enabled = false; }; workers = { enabled = false; }; + objective = { enabled = false; }; ticket = { enabled = false; thread = false; }; }; }