objective: add read tools

This commit is contained in:
Keisuke Hirata 2026-07-26 02:21:22 +09:00
parent 2200f60b94
commit 18b5cead7a
No known key found for this signature in database
9 changed files with 352 additions and 2 deletions

View File

@ -85,6 +85,8 @@ pub struct FeatureConfigPartial {
#[serde(default)]
pub workers: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub objective: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub ticket: Option<TicketFeatureConfigPartial>,
#[serde(default)]
pub plugins: Option<FeatureFlagConfigPartial>,
@ -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<FeatureConfigPartial> 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<FeatureConfig> 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);
}

View File

@ -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(),
}

View File

@ -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"]["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());

View File

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

View File

@ -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;

View 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");
}
}

View File

@ -1156,6 +1156,11 @@ struct MemoryStagingQuery {
limit: Option<usize>,
}
#[derive(Debug, Deserialize)]
struct ObjectiveListQuery {
limit: Option<usize>,
}
#[derive(Debug, Deserialize)]
struct TranscriptQuery {
start: Option<usize>,
@ -1726,9 +1731,10 @@ fn skill_api_error(error: skills::SkillError) -> ApiError {
async fn scoped_list_objectives(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
Query(query): Query<ObjectiveListQuery>,
) -> ApiResult<Json<ListResponse<crate::records::ObjectiveSummary>>> {
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<WorkspaceApi>,
Query(query): Query<ObjectiveListQuery>,
) -> 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 {
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"),

View File

@ -26,6 +26,7 @@ feature = {
memory = { enabled = true; };
web = { enabled = true; };
workers = { enabled = true; };
objective = { enabled = true; };
ticket = { enabled = true; authoring = true; thread = true; };
};

View File

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