objective: add read tools
This commit is contained in:
parent
2200f60b94
commit
18b5cead7a
|
|
@ -85,6 +85,8 @@ pub struct FeatureConfigPartial {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub workers: Option<FeatureFlagConfigPartial>,
|
pub workers: Option<FeatureFlagConfigPartial>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub objective: Option<FeatureFlagConfigPartial>,
|
||||||
|
#[serde(default)]
|
||||||
pub ticket: Option<TicketFeatureConfigPartial>,
|
pub ticket: Option<TicketFeatureConfigPartial>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub plugins: Option<FeatureFlagConfigPartial>,
|
pub plugins: Option<FeatureFlagConfigPartial>,
|
||||||
|
|
@ -97,6 +99,11 @@ impl FeatureConfigPartial {
|
||||||
memory: merge_option(self.memory, other.memory, MemoryFeatureConfigPartial::merge),
|
memory: merge_option(self.memory, other.memory, MemoryFeatureConfigPartial::merge),
|
||||||
web: merge_option(self.web, other.web, FeatureFlagConfigPartial::merge),
|
web: merge_option(self.web, other.web, FeatureFlagConfigPartial::merge),
|
||||||
workers: merge_option(self.workers, other.workers, 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),
|
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
|
||||||
plugins: merge_option(self.plugins, other.plugins, FeatureFlagConfigPartial::merge),
|
plugins: merge_option(self.plugins, other.plugins, FeatureFlagConfigPartial::merge),
|
||||||
}
|
}
|
||||||
|
|
@ -169,6 +176,10 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
||||||
.workers
|
.workers
|
||||||
.map(FeatureFlagConfig::from)
|
.map(FeatureFlagConfig::from)
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
|
objective: value
|
||||||
|
.objective
|
||||||
|
.map(FeatureFlagConfig::from)
|
||||||
|
.unwrap_or_default(),
|
||||||
ticket: value
|
ticket: value
|
||||||
.ticket
|
.ticket
|
||||||
.map(TicketFeatureConfig::from)
|
.map(TicketFeatureConfig::from)
|
||||||
|
|
@ -246,6 +257,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
||||||
memory: Some(value.memory.into()),
|
memory: Some(value.memory.into()),
|
||||||
web: Some(value.web.into()),
|
web: Some(value.web.into()),
|
||||||
workers: Some(value.workers.into()),
|
workers: Some(value.workers.into()),
|
||||||
|
objective: Some(value.objective.into()),
|
||||||
ticket: Some(value.ticket.into()),
|
ticket: Some(value.ticket.into()),
|
||||||
plugins: Some(value.plugins.into()),
|
plugins: Some(value.plugins.into()),
|
||||||
}
|
}
|
||||||
|
|
@ -1791,6 +1803,7 @@ worker_max_turns = 7
|
||||||
assert!(!manifest.feature.memory.enabled);
|
assert!(!manifest.feature.memory.enabled);
|
||||||
assert!(!manifest.feature.web.enabled);
|
assert!(!manifest.feature.web.enabled);
|
||||||
assert!(!manifest.feature.workers.enabled);
|
assert!(!manifest.feature.workers.enabled);
|
||||||
|
assert!(!manifest.feature.objective.enabled);
|
||||||
assert!(!manifest.feature.ticket.enabled);
|
assert!(!manifest.feature.ticket.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1842,6 +1855,7 @@ orchestration_control = false
|
||||||
assert!(!manifest.feature.ticket.orchestration_control);
|
assert!(!manifest.feature.ticket.orchestration_control);
|
||||||
assert!(!manifest.feature.memory.enabled);
|
assert!(!manifest.feature.memory.enabled);
|
||||||
assert!(!manifest.feature.memory.staging);
|
assert!(!manifest.feature.memory.staging);
|
||||||
|
assert!(!manifest.feature.objective.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1869,6 +1883,9 @@ orchestration_control = true
|
||||||
[feature.memory]
|
[feature.memory]
|
||||||
staging = true
|
staging = true
|
||||||
|
|
||||||
|
[feature.objective]
|
||||||
|
enabled = true
|
||||||
|
|
||||||
[feature.web]
|
[feature.web]
|
||||||
enabled = true
|
enabled = true
|
||||||
"#,
|
"#,
|
||||||
|
|
@ -1906,6 +1923,7 @@ enabled = true
|
||||||
assert!(manifest.feature.ticket.thread);
|
assert!(manifest.feature.ticket.thread);
|
||||||
assert!(!manifest.feature.ticket.intake);
|
assert!(!manifest.feature.ticket.intake);
|
||||||
assert!(manifest.feature.ticket.orchestration_control);
|
assert!(manifest.feature.ticket.orchestration_control);
|
||||||
|
assert!(manifest.feature.objective.enabled);
|
||||||
assert!(manifest.feature.web.enabled);
|
assert!(manifest.feature.web.enabled);
|
||||||
assert!(!manifest.feature.workers.enabled);
|
assert!(!manifest.feature.workers.enabled);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,8 @@ pub struct FeatureConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub workers: FeatureFlagConfig,
|
pub workers: FeatureFlagConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub objective: FeatureFlagConfig,
|
||||||
|
#[serde(default)]
|
||||||
pub ticket: TicketFeatureConfig,
|
pub ticket: TicketFeatureConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub plugins: FeatureFlagConfig,
|
pub plugins: FeatureFlagConfig,
|
||||||
|
|
@ -125,6 +127,7 @@ impl Default for FeatureConfig {
|
||||||
memory: MemoryFeatureConfig::disabled(),
|
memory: MemoryFeatureConfig::disabled(),
|
||||||
web: FeatureFlagConfig::disabled(),
|
web: FeatureFlagConfig::disabled(),
|
||||||
workers: FeatureFlagConfig::disabled(),
|
workers: FeatureFlagConfig::disabled(),
|
||||||
|
objective: FeatureFlagConfig::disabled(),
|
||||||
ticket: TicketFeatureConfig::default(),
|
ticket: TicketFeatureConfig::default(),
|
||||||
plugins: FeatureFlagConfig::disabled(),
|
plugins: FeatureFlagConfig::disabled(),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -962,6 +962,7 @@ fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> {
|
||||||
value["feature"]["memory"] = serde_json::json!({ "enabled": true, "staging": true });
|
value["feature"]["memory"] = serde_json::json!({ "enabled": true, "staging": true });
|
||||||
value["feature"]["web"] = serde_json::json!({ "enabled": false });
|
value["feature"]["web"] = serde_json::json!({ "enabled": false });
|
||||||
value["feature"]["workers"] = 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 });
|
value["feature"]["ticket"] = serde_json::json!({ "enabled": false, "thread": false });
|
||||||
Some(value)
|
Some(value)
|
||||||
}
|
}
|
||||||
|
|
@ -987,6 +988,7 @@ fn builtin_default_profile_artifact() -> serde_json::Value {
|
||||||
"memory": { "enabled": true },
|
"memory": { "enabled": true },
|
||||||
"web": { "enabled": true },
|
"web": { "enabled": true },
|
||||||
"workers": { "enabled": true },
|
"workers": { "enabled": true },
|
||||||
|
"objective": { "enabled": true },
|
||||||
"ticket": { "enabled": true, "authoring": true, "thread": true }
|
"ticket": { "enabled": true, "authoring": true, "thread": true }
|
||||||
},
|
},
|
||||||
"memory": {
|
"memory": {
|
||||||
|
|
@ -1457,6 +1459,7 @@ mod tests {
|
||||||
assert_eq!(resolved.manifest.worker.name, "arbitrary-worker-name");
|
assert_eq!(resolved.manifest.worker.name, "arbitrary-worker-name");
|
||||||
assert!(resolved.manifest.feature.memory.enabled);
|
assert!(resolved.manifest.feature.memory.enabled);
|
||||||
assert!(resolved.manifest.feature.memory.staging);
|
assert!(resolved.manifest.feature.memory.staging);
|
||||||
|
assert!(!resolved.manifest.feature.objective.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1484,6 +1487,7 @@ mod tests {
|
||||||
assert!(companion.feature.ticket.enabled);
|
assert!(companion.feature.ticket.enabled);
|
||||||
assert!(companion.feature.ticket.authoring);
|
assert!(companion.feature.ticket.authoring);
|
||||||
assert!(companion.feature.ticket.thread);
|
assert!(companion.feature.ticket.thread);
|
||||||
|
assert!(companion.feature.objective.enabled);
|
||||||
assert!(!companion.feature.ticket.intake);
|
assert!(!companion.feature.ticket.intake);
|
||||||
assert!(!companion.feature.ticket.orchestration_control);
|
assert!(!companion.feature.ticket.orchestration_control);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -1510,6 +1514,7 @@ mod tests {
|
||||||
assert!(intake.feature.ticket.enabled);
|
assert!(intake.feature.ticket.enabled);
|
||||||
assert!(intake.feature.ticket.authoring);
|
assert!(intake.feature.ticket.authoring);
|
||||||
assert!(intake.feature.ticket.thread);
|
assert!(intake.feature.ticket.thread);
|
||||||
|
assert!(intake.feature.objective.enabled);
|
||||||
assert!(intake.feature.ticket.intake);
|
assert!(intake.feature.ticket.intake);
|
||||||
assert!(!intake.feature.ticket.orchestration_control);
|
assert!(!intake.feature.ticket.orchestration_control);
|
||||||
assert!(intake.scope.allow.is_empty());
|
assert!(intake.scope.allow.is_empty());
|
||||||
|
|
@ -1525,6 +1530,7 @@ mod tests {
|
||||||
assert!(orchestrator.feature.ticket.enabled);
|
assert!(orchestrator.feature.ticket.enabled);
|
||||||
assert!(!orchestrator.feature.ticket.authoring);
|
assert!(!orchestrator.feature.ticket.authoring);
|
||||||
assert!(orchestrator.feature.ticket.thread);
|
assert!(orchestrator.feature.ticket.thread);
|
||||||
|
assert!(orchestrator.feature.objective.enabled);
|
||||||
assert!(!orchestrator.feature.ticket.intake);
|
assert!(!orchestrator.feature.ticket.intake);
|
||||||
assert!(orchestrator.feature.ticket.orchestration_control);
|
assert!(orchestrator.feature.ticket.orchestration_control);
|
||||||
assert!(orchestrator.scope.allow.is_empty());
|
assert!(orchestrator.scope.allow.is_empty());
|
||||||
|
|
@ -1548,6 +1554,7 @@ mod tests {
|
||||||
assert!(coder.feature.ticket.enabled);
|
assert!(coder.feature.ticket.enabled);
|
||||||
assert!(!coder.feature.ticket.authoring);
|
assert!(!coder.feature.ticket.authoring);
|
||||||
assert!(coder.feature.ticket.thread);
|
assert!(coder.feature.ticket.thread);
|
||||||
|
assert!(coder.feature.objective.enabled);
|
||||||
assert!(!coder.feature.ticket.intake);
|
assert!(!coder.feature.ticket.intake);
|
||||||
assert!(!coder.feature.ticket.orchestration_control);
|
assert!(!coder.feature.ticket.orchestration_control);
|
||||||
let reviewer = resolve("reviewer");
|
let reviewer = resolve("reviewer");
|
||||||
|
|
@ -1557,6 +1564,7 @@ mod tests {
|
||||||
assert!(reviewer.feature.ticket.enabled);
|
assert!(reviewer.feature.ticket.enabled);
|
||||||
assert!(!reviewer.feature.ticket.authoring);
|
assert!(!reviewer.feature.ticket.authoring);
|
||||||
assert!(reviewer.feature.ticket.thread);
|
assert!(reviewer.feature.ticket.thread);
|
||||||
|
assert!(reviewer.feature.objective.enabled);
|
||||||
assert!(!reviewer.feature.ticket.intake);
|
assert!(!reviewer.feature.ticket.intake);
|
||||||
assert!(!reviewer.feature.ticket.orchestration_control);
|
assert!(!reviewer.feature.ticket.orchestration_control);
|
||||||
assert!(reviewer.scope.allow.is_empty());
|
assert!(reviewer.scope.allow.is_empty());
|
||||||
|
|
|
||||||
|
|
@ -630,6 +630,29 @@ where
|
||||||
let workspace_client = worker.workspace_client().clone();
|
let workspace_client = worker.workspace_client().clone();
|
||||||
let worker = worker.engine_mut();
|
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
|
// Memory tools require explicit feature exposure. Workspace memory access
|
||||||
// is authority-bound to the Backend Workspace API; the Worker must not
|
// is authority-bound to the Backend Workspace API; the Worker must not
|
||||||
// register local filesystem memory tools even when it has local cwd/root
|
// register local filesystem memory tools even when it has local cwd/root
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
//! an external plugin-loading surface.
|
//! an external plugin-loading surface.
|
||||||
|
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
|
pub mod objective;
|
||||||
pub mod session_explore;
|
pub mod session_explore;
|
||||||
pub mod task;
|
pub mod task;
|
||||||
pub mod ticket;
|
pub mod ticket;
|
||||||
|
|
|
||||||
282
crates/worker/src/feature/builtin/objective.rs
Normal file
282
crates/worker/src/feature/builtin/objective.rs
Normal file
|
|
@ -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<String>, base_url: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
workspace_id: workspace_id.into(),
|
||||||
|
base_url: base_url.into().trim_end_matches('/').to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(&self, input: ObjectiveListInput) -> Result<ToolOutput, ToolError> {
|
||||||
|
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::<ObjectiveListResponse>(&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<ToolOutput, ToolError> {
|
||||||
|
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::<ObjectiveDetail>(&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<T: for<'de> Deserialize<'de>>(
|
||||||
|
url: &str,
|
||||||
|
) -> Result<T, WorkspaceObjectiveBackendError> {
|
||||||
|
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<String>,
|
||||||
|
base_url: impl Into<String>,
|
||||||
|
) -> Vec<ToolDefinition> {
|
||||||
|
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<dyn Tool>,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct WorkspaceHttpObjectiveTool {
|
||||||
|
backend: WorkspaceHttpObjectiveBackend,
|
||||||
|
operation: ObjectiveOperation,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for WorkspaceHttpObjectiveTool {
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
input_json: &str,
|
||||||
|
_ctx: ToolExecutionContext,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
match self.operation {
|
||||||
|
ObjectiveOperation::List => {
|
||||||
|
let input = parse_input::<ObjectiveListInput>(input_json)?;
|
||||||
|
self.backend.list(input).await
|
||||||
|
}
|
||||||
|
ObjectiveOperation::Show => {
|
||||||
|
let input = parse_input::<ObjectiveShowInput>(input_json)?;
|
||||||
|
self.backend.show(input).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_input<T: for<'de> Deserialize<'de>>(input: &str) -> Result<T, ToolError> {
|
||||||
|
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<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ObjectiveShowInput {
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
struct ObjectiveListResponse {
|
||||||
|
items: Vec<ObjectiveSummary>,
|
||||||
|
invalid_records: Vec<InvalidProjectRecord>,
|
||||||
|
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<String>,
|
||||||
|
summary: String,
|
||||||
|
linked_tickets: Vec<String>,
|
||||||
|
record_source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
struct ObjectiveDetail {
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
state: String,
|
||||||
|
created_at: Option<String>,
|
||||||
|
updated_at: Option<String>,
|
||||||
|
linked_tickets: Vec<String>,
|
||||||
|
body: String,
|
||||||
|
body_truncated: bool,
|
||||||
|
record_source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use llm_engine::tool::ToolDefinition;
|
||||||
|
|
||||||
|
fn tool_names(definitions: Vec<ToolDefinition>) -> Vec<String> {
|
||||||
|
let mut names = definitions
|
||||||
|
.into_iter()
|
||||||
|
.map(|tool| tool().0.name)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1156,6 +1156,11 @@ struct MemoryStagingQuery {
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ObjectiveListQuery {
|
||||||
|
limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct TranscriptQuery {
|
struct TranscriptQuery {
|
||||||
start: Option<usize>,
|
start: Option<usize>,
|
||||||
|
|
@ -1726,9 +1731,10 @@ fn skill_api_error(error: skills::SkillError) -> ApiError {
|
||||||
async fn scoped_list_objectives(
|
async fn scoped_list_objectives(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
Query(query): Query<ObjectiveListQuery>,
|
||||||
) -> ApiResult<Json<ListResponse<crate::records::ObjectiveSummary>>> {
|
) -> ApiResult<Json<ListResponse<crate::records::ObjectiveSummary>>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
list_objectives(State(api)).await
|
list_objectives(State(api), Query(query)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_get_objective(
|
async fn scoped_get_objective(
|
||||||
|
|
@ -3661,8 +3667,9 @@ async fn get_ticket(
|
||||||
|
|
||||||
async fn list_objectives(
|
async fn list_objectives(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
|
Query(query): Query<ObjectiveListQuery>,
|
||||||
) -> ApiResult<Json<ListResponse<crate::records::ObjectiveSummary>>> {
|
) -> ApiResult<Json<ListResponse<crate::records::ObjectiveSummary>>> {
|
||||||
let limit = api.config.max_records.min(200);
|
let limit = query.limit.unwrap_or(api.config.max_records).min(1000);
|
||||||
let ProjectRecordList {
|
let ProjectRecordList {
|
||||||
items,
|
items,
|
||||||
invalid_records,
|
invalid_records,
|
||||||
|
|
@ -8413,6 +8420,12 @@ mod tests {
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(scoped_objectives["items"][0]["id"], "00000000001J3");
|
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(
|
let scoped_objective = get_json(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
&format!("/api/w/{TEST_WORKSPACE_ID}/objectives/00000000001J3"),
|
&format!("/api/w/{TEST_WORKSPACE_ID}/objectives/00000000001J3"),
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ feature = {
|
||||||
memory = { enabled = true; };
|
memory = { enabled = true; };
|
||||||
web = { enabled = true; };
|
web = { enabled = true; };
|
||||||
workers = { enabled = true; };
|
workers = { enabled = true; };
|
||||||
|
objective = { enabled = true; };
|
||||||
ticket = { enabled = true; authoring = true; thread = true; };
|
ticket = { enabled = true; authoring = true; thread = true; };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import "./default.dcdl" // {
|
||||||
memory = { enabled = true; staging = true; };
|
memory = { enabled = true; staging = true; };
|
||||||
web = { enabled = false; };
|
web = { enabled = false; };
|
||||||
workers = { enabled = false; };
|
workers = { enabled = false; };
|
||||||
|
objective = { enabled = false; };
|
||||||
ticket = { enabled = false; thread = false; };
|
ticket = { enabled = false; thread = false; };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user