Track task progress and organize Galaxy Drive plans
This commit is contained in:
@@ -6,10 +6,12 @@
|
||||
|
||||
mod provider_run;
|
||||
mod runtime;
|
||||
mod task_list;
|
||||
mod tool_policy;
|
||||
mod types;
|
||||
|
||||
pub use provider_run::*;
|
||||
pub use runtime::*;
|
||||
pub use task_list::*;
|
||||
pub use tool_policy::*;
|
||||
pub use types::*;
|
||||
|
||||
@@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
AgentError, ContentPart, ConversationMessage, MessageContent, MessageRole, PermissionDecision,
|
||||
PermissionRequest, StopReason, ToolCall, ToolCallDecision, ToolPolicy, ToolResult,
|
||||
ToolResultStatus, Usage,
|
||||
PermissionRequest, StopReason, TaskListSnapshot, ToolCall, ToolCallDecision, ToolPolicy,
|
||||
ToolResult, ToolResultStatus, UPDATE_TASK_LIST_NAME, Usage, task_list_from_tool_result,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
@@ -357,6 +357,9 @@ pub enum ProviderRunProtocolError {
|
||||
InvalidTranscriptCompaction {
|
||||
message: String,
|
||||
},
|
||||
InvalidTaskList {
|
||||
message: String,
|
||||
},
|
||||
InvalidRestoredState {
|
||||
message: String,
|
||||
},
|
||||
@@ -422,6 +425,7 @@ impl fmt::Display for ProviderRunProtocolError {
|
||||
Self::InvalidTranscriptCompaction { message } => {
|
||||
write!(f, "invalid transcript compaction: {message}")
|
||||
}
|
||||
Self::InvalidTaskList { message } => write!(f, "invalid task list: {message}"),
|
||||
Self::InvalidRestoredState { message } => {
|
||||
write!(f, "invalid restored provider run: {message}")
|
||||
}
|
||||
@@ -439,6 +443,8 @@ pub struct ProviderRun {
|
||||
epoch: RunEpoch,
|
||||
transcript: Vec<ConversationMessage>,
|
||||
tool_result_archive: Vec<ConversationMessage>,
|
||||
#[serde(default)]
|
||||
task_list: Option<TaskListSnapshot>,
|
||||
usage: Usage,
|
||||
model_turns: u32,
|
||||
model_retries: u32,
|
||||
@@ -460,6 +466,7 @@ impl ProviderRun {
|
||||
epoch: RunEpoch::default(),
|
||||
transcript,
|
||||
tool_result_archive: Vec::new(),
|
||||
task_list: None,
|
||||
usage: Usage::default(),
|
||||
model_turns: 0,
|
||||
model_retries: 0,
|
||||
@@ -489,6 +496,23 @@ impl ProviderRun {
|
||||
&self.transcript
|
||||
}
|
||||
|
||||
pub fn task_list(&self) -> Option<&TaskListSnapshot> {
|
||||
self.task_list.as_ref()
|
||||
}
|
||||
|
||||
pub fn set_task_list(
|
||||
&mut self,
|
||||
task_list: Option<TaskListSnapshot>,
|
||||
) -> Result<(), ProviderRunProtocolError> {
|
||||
if let Some(snapshot) = &task_list {
|
||||
snapshot
|
||||
.validate()
|
||||
.map_err(|message| ProviderRunProtocolError::InvalidTaskList { message })?;
|
||||
}
|
||||
self.task_list = task_list;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn tool_result_archive(&self) -> &[ConversationMessage] {
|
||||
&self.tool_result_archive
|
||||
}
|
||||
@@ -564,6 +588,11 @@ impl ProviderRun {
|
||||
if self.profile.as_str().is_empty() {
|
||||
return Err(invalid("request profile must not be empty".to_string()));
|
||||
}
|
||||
if let Some(snapshot) = &self.task_list {
|
||||
snapshot
|
||||
.validate()
|
||||
.map_err(|error| invalid(format!("invalid task list: {error}")))?;
|
||||
}
|
||||
if self.limits.max_model_turns == Some(0) {
|
||||
return Err(invalid("model-turn limit must be at least one".to_string()));
|
||||
}
|
||||
@@ -1273,6 +1302,20 @@ impl ProviderRun {
|
||||
});
|
||||
};
|
||||
let next_epoch = self.next_epoch()?;
|
||||
let mut task_list = self.task_list.clone();
|
||||
for pending in &batch.calls {
|
||||
if pending.call.name != UPDATE_TASK_LIST_NAME {
|
||||
continue;
|
||||
}
|
||||
let result = pending.state.result().expect("validated complete tool batch");
|
||||
if result.status == ToolResultStatus::Success {
|
||||
task_list = Some(task_list_from_tool_result(&result.content).ok_or_else(|| {
|
||||
ProviderRunProtocolError::InvalidTaskList {
|
||||
message: "successful task update has no valid snapshot result".to_string(),
|
||||
}
|
||||
})?);
|
||||
}
|
||||
}
|
||||
let yielded_to_children = batch.calls.iter().any(|pending| {
|
||||
is_local_agent_launch(&pending.call)
|
||||
&& pending
|
||||
@@ -1281,6 +1324,7 @@ impl ProviderRun {
|
||||
.is_some_and(|result| result.status == ToolResultStatus::Success)
|
||||
});
|
||||
self.transcript.push(tool_result_message(results));
|
||||
self.task_list = task_list;
|
||||
self.epoch = next_epoch;
|
||||
// Launch acknowledgement is a yield, not an invitation to issue more tools.
|
||||
// The client persists child membership and supplies a new input once the group finishes.
|
||||
@@ -1634,6 +1678,18 @@ fn validate_pending_tool_state(pending: &PendingToolCall) -> Result<(), String>
|
||||
| PendingToolCallState::RecoveryPending
|
||||
| PendingToolCallState::Resolved { .. } => {}
|
||||
}
|
||||
if pending.call.name == UPDATE_TASK_LIST_NAME {
|
||||
if let Some(result) = pending.state.result() {
|
||||
if result.status == ToolResultStatus::Success {
|
||||
let snapshot = task_list_from_tool_result(&result.content)
|
||||
.ok_or_else(|| "successful task update has no valid snapshot result".to_string())?;
|
||||
let expected = TaskListSnapshot::from_arguments(&pending.call.arguments)?;
|
||||
if snapshot != expected {
|
||||
return Err("task update result does not match its arguments".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::{AgentErrorKind, PermissionKind};
|
||||
use crate::{AgentErrorKind, PermissionKind, TaskListItem, TaskListStatus};
|
||||
|
||||
fn initial_messages() -> Vec<ConversationMessage> {
|
||||
vec![ConversationMessage {
|
||||
@@ -354,6 +354,201 @@ fn unknown_malformed_and_inline_tools_are_pre_resolved_in_the_same_batch() {
|
||||
assert_eq!(batch.unresolved_call_ids(), vec!["external"]);
|
||||
}
|
||||
|
||||
fn task_snapshot(status: TaskListStatus) -> TaskListSnapshot {
|
||||
TaskListSnapshot {
|
||||
tasks: vec![TaskListItem {
|
||||
id: "fix".to_string(),
|
||||
title: "Fix the failure".to_string(),
|
||||
status,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn update_task_call(id: &str, snapshot: &TaskListSnapshot) -> ToolCall {
|
||||
ToolCall {
|
||||
id: id.to_string(),
|
||||
name: UPDATE_TASK_LIST_NAME.to_string(),
|
||||
arguments: serde_json::to_value(snapshot).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_task_updates_commit_atomically_in_call_order_with_other_tools() {
|
||||
let mut run = run();
|
||||
let initial = task_snapshot(TaskListStatus::Pending);
|
||||
let working = task_snapshot(TaskListStatus::InProgress);
|
||||
let completed = task_snapshot(TaskListStatus::Completed);
|
||||
run.set_task_list(Some(initial.clone())).unwrap();
|
||||
let batch = accept_tool_turn(
|
||||
&mut run,
|
||||
tool_turn(
|
||||
vec![
|
||||
update_task_call("tasks-start", &working),
|
||||
tool_call("external", "read_files"),
|
||||
update_task_call("tasks-finish", &completed),
|
||||
],
|
||||
&[UPDATE_TASK_LIST_NAME, "read_files"],
|
||||
),
|
||||
);
|
||||
|
||||
assert_eq!(batch.unresolved_call_ids(), vec!["external"]);
|
||||
assert_eq!(run.task_list(), Some(&initial));
|
||||
assert!(matches!(
|
||||
run.commit_tool_batch(&batch.work_id),
|
||||
Err(ProviderRunProtocolError::IncompleteToolBatch { .. }),
|
||||
));
|
||||
assert_eq!(run.task_list(), Some(&initial));
|
||||
|
||||
run.complete_tool(&batch.work_id, successful_result("external", "contents"))
|
||||
.unwrap();
|
||||
run.commit_tool_batch(&batch.work_id).unwrap();
|
||||
|
||||
assert_eq!(run.task_list(), Some(&completed));
|
||||
assert_eq!(run.state().phase(), ProviderRunPhase::ReadyToCallModel);
|
||||
assert_serialization_round_trip(&run);
|
||||
let committed = run.clone();
|
||||
assert!(run.commit_tool_batch(&batch.work_id).is_err());
|
||||
assert_eq!(run, committed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_task_updates_do_not_replace_durable_progress() {
|
||||
let mut run = run();
|
||||
let initial = task_snapshot(TaskListStatus::InProgress);
|
||||
run.set_task_list(Some(initial.clone())).unwrap();
|
||||
let batch = accept_tool_turn(
|
||||
&mut run,
|
||||
tool_turn(
|
||||
vec![ToolCall {
|
||||
id: "invalid-task-update".to_string(),
|
||||
name: UPDATE_TASK_LIST_NAME.to_string(),
|
||||
arguments: json!({"tasks": []}),
|
||||
}],
|
||||
&[UPDATE_TASK_LIST_NAME],
|
||||
),
|
||||
);
|
||||
assert_eq!(
|
||||
batch.calls[0].state.result().unwrap().status,
|
||||
ToolResultStatus::Error,
|
||||
);
|
||||
run.commit_tool_batch(&batch.work_id).unwrap();
|
||||
assert_eq!(run.task_list(), Some(&initial));
|
||||
|
||||
let batch = accept_tool_turn(
|
||||
&mut run,
|
||||
tool_turn(
|
||||
vec![update_task_call(
|
||||
"unadvertised-update",
|
||||
&task_snapshot(TaskListStatus::Completed),
|
||||
)],
|
||||
&[],
|
||||
),
|
||||
);
|
||||
run.commit_tool_batch(&batch.work_id).unwrap();
|
||||
assert_eq!(run.task_list(), Some(&initial));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_progress_survives_compaction_archive_eviction_and_restore() {
|
||||
let mut run = run();
|
||||
let snapshot = task_snapshot(TaskListStatus::InProgress);
|
||||
let batch = accept_tool_turn(
|
||||
&mut run,
|
||||
tool_turn(
|
||||
vec![update_task_call("tasks", &snapshot)],
|
||||
&[UPDATE_TASK_LIST_NAME],
|
||||
),
|
||||
);
|
||||
assert!(batch.is_complete());
|
||||
run.commit_tool_batch(&batch.work_id).unwrap();
|
||||
run.compact_transcript_at_model_boundary(
|
||||
run.transcript().len(),
|
||||
vec![ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text("Earlier conversation summary".to_string()),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut archive = run.tool_result_archive().to_vec();
|
||||
for index in 0..450 {
|
||||
archive.extend([
|
||||
ConversationMessage {
|
||||
role: MessageRole::Assistant,
|
||||
content: MessageContent::ToolUse {
|
||||
tool_use_id: format!("read-{index}"),
|
||||
name: "read_files".to_string(),
|
||||
input: json!({}),
|
||||
},
|
||||
},
|
||||
ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::ToolResult {
|
||||
tool_use_id: format!("read-{index}"),
|
||||
content: "contents".to_string(),
|
||||
is_error: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
run.replace_tool_result_archive(archive);
|
||||
assert!(!serde_json::to_string(run.tool_result_archive())
|
||||
.unwrap()
|
||||
.contains("task_list_snapshot"));
|
||||
assert_eq!(run.task_list(), Some(&snapshot));
|
||||
assert_serialization_round_trip(&run);
|
||||
run.validate_restored_state().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_list_setter_and_restored_snapshots_validate_before_mutation() {
|
||||
let mut run = run();
|
||||
let initial = task_snapshot(TaskListStatus::InProgress);
|
||||
run.set_task_list(Some(initial.clone())).unwrap();
|
||||
assert!(matches!(
|
||||
run.set_task_list(Some(TaskListSnapshot { tasks: Vec::new() })),
|
||||
Err(ProviderRunProtocolError::InvalidTaskList { .. }),
|
||||
));
|
||||
assert_eq!(run.task_list(), Some(&initial));
|
||||
|
||||
let invalid = mutate_run_json(&run, |value| value["task_list"] = json!({"tasks": []}));
|
||||
assert!(restored_state_error(&invalid).contains("invalid task list"));
|
||||
|
||||
let legacy = mutate_run_json(&run, |value| {
|
||||
value.as_object_mut().unwrap().remove("task_list");
|
||||
});
|
||||
assert_eq!(legacy.task_list(), None);
|
||||
legacy.validate_restored_state().unwrap();
|
||||
run.set_task_list(None).unwrap();
|
||||
assert_eq!(run.task_list(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restoring_a_completed_inline_task_batch_commits_once_without_external_work() {
|
||||
let mut run = run();
|
||||
let snapshot = task_snapshot(TaskListStatus::InProgress);
|
||||
accept_tool_turn(
|
||||
&mut run,
|
||||
tool_turn(
|
||||
vec![update_task_call("tasks", &snapshot)],
|
||||
&[UPDATE_TASK_LIST_NAME],
|
||||
),
|
||||
);
|
||||
let mut restored: ProviderRun = serde_json::from_str(&serde_json::to_string(&run).unwrap()).unwrap();
|
||||
restored.validate_restored_state().unwrap();
|
||||
assert_eq!(restored.task_list(), None);
|
||||
assert!(restored.normalize_after_restore().unwrap().committed_tool_batch);
|
||||
assert_eq!(restored.task_list(), Some(&snapshot));
|
||||
assert!(!restored.normalize_after_restore().unwrap().committed_tool_batch);
|
||||
assert_eq!(restored.task_list(), Some(&snapshot));
|
||||
|
||||
let corrupted = mutate_run_json(&run, |value| {
|
||||
value["state"]["AwaitingTools"]["batch"]["calls"][0]["state"]["Resolved"]["result"]["content"] =
|
||||
json!(task_snapshot(TaskListStatus::Completed).to_tool_result_content());
|
||||
});
|
||||
assert!(restored_state_error(&corrupted).contains("does not match its arguments"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_tool_results_commit_atomically_in_original_call_order() {
|
||||
let mut run = run();
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
pub const UPDATE_TASK_LIST_NAME: &str = "update_task_list";
|
||||
pub const MAX_TASK_LIST_ITEMS: usize = 50;
|
||||
pub const MAX_TASK_ID_CHARS: usize = 200;
|
||||
pub const MAX_TASK_TITLE_CHARS: usize = 1_000;
|
||||
|
||||
/// A complete replacement of the task list. Callers retain task IDs across updates.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TaskListSnapshot {
|
||||
pub tasks: Vec<TaskListItem>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TaskListItem {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub status: TaskListStatus,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaskListStatus {
|
||||
Pending,
|
||||
InProgress,
|
||||
Completed,
|
||||
}
|
||||
|
||||
impl TaskListSnapshot {
|
||||
pub fn from_arguments(arguments: &JsonValue) -> Result<Self, String> {
|
||||
let mut arguments = arguments.clone();
|
||||
if let Some(fields) = arguments.as_object_mut() {
|
||||
if let Some(title) = fields.remove("_galaxy_tool_title") {
|
||||
if !title.is_string() {
|
||||
return Err("The reserved tool title must be a string.".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
let snapshot: Self = serde_json::from_value(arguments)
|
||||
.map_err(|error| format!("Invalid task list arguments: {error}"))?;
|
||||
snapshot.validate()?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.tasks.is_empty() || self.tasks.len() > MAX_TASK_LIST_ITEMS {
|
||||
return Err(format!(
|
||||
"Task lists must contain between 1 and {MAX_TASK_LIST_ITEMS} tasks."
|
||||
));
|
||||
}
|
||||
|
||||
let mut ids = HashSet::new();
|
||||
let mut has_in_progress = false;
|
||||
let mut has_pending = false;
|
||||
for task in &self.tasks {
|
||||
if task.id.trim().is_empty() || task.id.chars().count() > MAX_TASK_ID_CHARS {
|
||||
return Err(format!(
|
||||
"Task IDs must be nonempty and at most {MAX_TASK_ID_CHARS} characters."
|
||||
));
|
||||
}
|
||||
if !ids.insert(task.id.as_str()) {
|
||||
return Err(format!("Duplicate task ID: '{}'.", task.id));
|
||||
}
|
||||
if task.title.trim().is_empty() || task.title.chars().count() > MAX_TASK_TITLE_CHARS
|
||||
{
|
||||
return Err(format!(
|
||||
"Task titles must be nonempty and at most {MAX_TASK_TITLE_CHARS} characters."
|
||||
));
|
||||
}
|
||||
match task.status {
|
||||
TaskListStatus::Pending => has_pending = true,
|
||||
TaskListStatus::InProgress => {
|
||||
if has_in_progress {
|
||||
return Err("Only one task may be in progress at a time.".to_string());
|
||||
}
|
||||
if has_pending {
|
||||
return Err(
|
||||
"The task in progress must precede all pending tasks; reorder the task list to reflect the current work."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
has_in_progress = true;
|
||||
}
|
||||
TaskListStatus::Completed => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn to_tool_result_content(&self) -> String {
|
||||
serde_json::to_string(&TaskListToolResult {
|
||||
kind: TaskListResultKind::Snapshot,
|
||||
tasks: self.tasks.clone(),
|
||||
})
|
||||
.expect("task list snapshots contain only serializable strings and statuses")
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognizes a canonical snapshot result. The caller must first verify the owning
|
||||
/// tool name and successful result status when reading conversation history.
|
||||
pub fn task_list_from_tool_result(content: &str) -> Option<TaskListSnapshot> {
|
||||
let result: TaskListToolResult = serde_json::from_str(content).ok()?;
|
||||
let snapshot = TaskListSnapshot { tasks: result.tasks };
|
||||
snapshot.validate().ok()?;
|
||||
Some(snapshot)
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct TaskListToolResult {
|
||||
#[serde(rename = "type")]
|
||||
kind: TaskListResultKind,
|
||||
tasks: Vec<TaskListItem>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
enum TaskListResultKind {
|
||||
#[serde(rename = "task_list_snapshot")]
|
||||
Snapshot,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "task_list_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,157 @@
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn task(id: &str, status: TaskListStatus) -> TaskListItem {
|
||||
TaskListItem {
|
||||
id: id.to_string(),
|
||||
title: format!("Task {id}"),
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_round_trips_through_a_recognizable_canonical_result() {
|
||||
let arguments = json!({"tasks": [
|
||||
{"id": "inspect", "title": "Inspect the failure", "status": "completed"},
|
||||
{"id": "fix", "title": "Fix the failure", "status": "in_progress"},
|
||||
{"id": "review", "title": "Review the fix", "status": "pending"},
|
||||
]});
|
||||
let snapshot = TaskListSnapshot::from_arguments(&arguments).unwrap();
|
||||
let content = snapshot.to_tool_result_content();
|
||||
|
||||
assert_eq!(task_list_from_tool_result(&content), Some(snapshot.clone()));
|
||||
assert_eq!(snapshot.to_tool_result_content(), content);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<JsonValue>(&content).unwrap(),
|
||||
json!({"type": "task_list_snapshot", "tasks": arguments["tasks"]}),
|
||||
);
|
||||
assert_eq!(snapshot.tasks[1].id, "fix");
|
||||
assert_eq!(snapshot.tasks[1].status, TaskListStatus::InProgress);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_rejects_invented_statuses_partial_arguments_and_unknown_fields() {
|
||||
for arguments in [
|
||||
json!({"tasks": []}),
|
||||
json!({"tasks": [{"id": "one", "title": "Inspect", "status": "started"}]}),
|
||||
json!({"tasks": [{"id": "one", "title": "Inspect"}]}),
|
||||
json!({"tasks": [{"id": "one", "title": "Inspect", "status": "pending", "done": false}]}),
|
||||
json!({"tasks": [{"id": "one", "title": "Inspect", "status": "pending"}], "append": true}),
|
||||
json!([{"id": "one", "title": "Inspect", "status": "pending"}]),
|
||||
] {
|
||||
assert!(TaskListSnapshot::from_arguments(&arguments).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn argument_parser_accepts_only_the_reserved_tool_title_outside_the_snapshot() {
|
||||
let tasks = json!([{"id": "one", "title": "Inspect", "status": "in_progress"}]);
|
||||
let plain = TaskListSnapshot::from_arguments(&json!({"tasks": tasks})).unwrap();
|
||||
assert_eq!(
|
||||
TaskListSnapshot::from_arguments(&json!({
|
||||
"tasks": tasks,
|
||||
"_galaxy_tool_title": "Update progress",
|
||||
})),
|
||||
Ok(plain),
|
||||
);
|
||||
assert!(TaskListSnapshot::from_arguments(&json!({
|
||||
"tasks": tasks,
|
||||
"_galaxy_tool_title": false,
|
||||
})).is_err());
|
||||
assert!(TaskListSnapshot::from_arguments(&json!({
|
||||
"tasks": tasks,
|
||||
"_galaxy_tool_title": "Update progress",
|
||||
"other": "unsupported",
|
||||
})).is_err());
|
||||
assert!(serde_json::from_value::<TaskListSnapshot>(json!({
|
||||
"tasks": tasks,
|
||||
"_galaxy_tool_title": "Update progress",
|
||||
})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_enforces_stable_unique_nonempty_ids_and_nonempty_titles() {
|
||||
for tasks in [
|
||||
vec![task(" ", TaskListStatus::Pending)],
|
||||
vec![task("", TaskListStatus::Pending)],
|
||||
vec![
|
||||
task("one", TaskListStatus::Completed),
|
||||
task("one", TaskListStatus::Pending),
|
||||
],
|
||||
vec![TaskListItem {
|
||||
id: "one".to_string(),
|
||||
title: "\t\n".to_string(),
|
||||
status: TaskListStatus::Pending,
|
||||
}],
|
||||
] {
|
||||
assert!(TaskListSnapshot { tasks }.validate().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_bounds_count_and_unicode_character_lengths() {
|
||||
let mut snapshot = TaskListSnapshot {
|
||||
tasks: (0..MAX_TASK_LIST_ITEMS)
|
||||
.map(|index| task(&index.to_string(), TaskListStatus::Pending))
|
||||
.collect(),
|
||||
};
|
||||
assert!(snapshot.validate().is_ok());
|
||||
snapshot.tasks.push(task("extra", TaskListStatus::Pending));
|
||||
assert!(snapshot.validate().is_err());
|
||||
|
||||
let mut snapshot = TaskListSnapshot {
|
||||
tasks: vec![TaskListItem {
|
||||
id: "🦀".repeat(MAX_TASK_ID_CHARS),
|
||||
title: "界".repeat(MAX_TASK_TITLE_CHARS),
|
||||
status: TaskListStatus::Pending,
|
||||
}],
|
||||
};
|
||||
assert!(snapshot.validate().is_ok());
|
||||
snapshot.tasks[0].id.push('🦀');
|
||||
assert!(snapshot.validate().is_err());
|
||||
snapshot.tasks[0].id.pop();
|
||||
snapshot.tasks[0].title.push('界');
|
||||
assert!(snapshot.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_work_precedes_pending_tasks_while_completed_tasks_may_stay_in_place() {
|
||||
let snapshot = TaskListSnapshot {
|
||||
tasks: vec![
|
||||
task("done", TaskListStatus::Completed),
|
||||
task("working", TaskListStatus::InProgress),
|
||||
task("also-done", TaskListStatus::Completed),
|
||||
task("next", TaskListStatus::Pending),
|
||||
],
|
||||
};
|
||||
assert!(snapshot.validate().is_ok());
|
||||
|
||||
for tasks in [
|
||||
vec![
|
||||
task("later", TaskListStatus::Pending),
|
||||
task("done", TaskListStatus::Completed),
|
||||
task("working", TaskListStatus::InProgress),
|
||||
],
|
||||
vec![
|
||||
task("one", TaskListStatus::InProgress),
|
||||
task("two", TaskListStatus::InProgress),
|
||||
],
|
||||
] {
|
||||
assert!(TaskListSnapshot { tasks }.validate().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_parser_requires_marker_and_validates_snapshot_instead_of_trusting_content() {
|
||||
let tasks = json!([{"id": "one", "title": "Inspect", "status": "pending"}]);
|
||||
for content in [
|
||||
json!({"tasks": tasks}).to_string(),
|
||||
json!({"type": "something_else", "tasks": tasks}).to_string(),
|
||||
json!({"type": "task_list_snapshot", "tasks": []}).to_string(),
|
||||
json!({"type": "task_list_snapshot", "tasks": tasks, "unrelated": true}).to_string(),
|
||||
"not JSON".to_string(),
|
||||
] {
|
||||
assert_eq!(task_list_from_tool_result(&content), None);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ use std::collections::{BTreeSet, HashMap, VecDeque};
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
use crate::{
|
||||
ContentPart, ConversationMessage, MessageContent, ToolCall, ToolDefinition, ToolResult,
|
||||
ToolResultStatus,
|
||||
ContentPart, ConversationMessage, MessageContent, TaskListSnapshot, ToolCall, ToolDefinition,
|
||||
ToolResult, ToolResultStatus, UPDATE_TASK_LIST_NAME,
|
||||
};
|
||||
|
||||
pub const RECALL_TOOL_HISTORY_NAME: &str = "recall_tool_history";
|
||||
@@ -145,6 +145,21 @@ impl ToolPolicy {
|
||||
});
|
||||
}
|
||||
|
||||
if call.name == UPDATE_TASK_LIST_NAME {
|
||||
return match TaskListSnapshot::from_arguments(&call.arguments) {
|
||||
Ok(snapshot) => ToolCallDecision::Inline(ToolResult {
|
||||
call_id: call.id.clone(),
|
||||
content: snapshot.to_tool_result_content(),
|
||||
status: ToolResultStatus::Success,
|
||||
}),
|
||||
Err(error) => ToolCallDecision::Reject(ToolResult {
|
||||
call_id: call.id.clone(),
|
||||
content: format!("Error: {error}"),
|
||||
status: ToolResultStatus::Error,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
ToolCallDecision::Execute
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,62 @@ fn executes_only_tools_advertised_for_this_turn() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_updates_are_inline_only_when_advertised_and_keep_the_call_identity() {
|
||||
let call = ToolCall {
|
||||
id: "task-update-1".to_string(),
|
||||
name: UPDATE_TASK_LIST_NAME.to_string(),
|
||||
arguments: serde_json::json!({"tasks": [
|
||||
{"id": "fix", "title": "Fix the failure", "status": "in_progress"},
|
||||
{"id": "review", "title": "Review the fix", "status": "pending"},
|
||||
]}),
|
||||
};
|
||||
let unadvertised = ToolPolicy::new(&[definition("read_files")]);
|
||||
let ToolCallDecision::Reject(result) = unadvertised.decide(&call, &[], &[]) else {
|
||||
panic!("task updates cannot bypass advertised capabilities");
|
||||
};
|
||||
assert_eq!(result.call_id, call.id);
|
||||
assert_eq!(result.status, ToolResultStatus::Error);
|
||||
|
||||
let policy = ToolPolicy::new(&[definition(UPDATE_TASK_LIST_NAME)]);
|
||||
let ToolCallDecision::Inline(result) = policy.decide(&call, &[], &[]) else {
|
||||
panic!("valid task updates must not dispatch external actions");
|
||||
};
|
||||
assert_eq!(result.call_id, call.id);
|
||||
assert_eq!(result.status, ToolResultStatus::Success);
|
||||
let snapshot = crate::task_list_from_tool_result(&result.content).unwrap();
|
||||
assert_eq!(snapshot.tasks[0].id, "fix");
|
||||
assert_eq!(snapshot.tasks[1].id, "review");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_task_updates_return_one_correlated_error_instead_of_executing() {
|
||||
let policy = ToolPolicy::new(&[definition(UPDATE_TASK_LIST_NAME)]);
|
||||
for arguments in [
|
||||
serde_json::json!({"tasks": []}),
|
||||
serde_json::json!({"tasks": [
|
||||
{"id": "same", "title": "First", "status": "completed"},
|
||||
{"id": "same", "title": "Second", "status": "pending"},
|
||||
]}),
|
||||
serde_json::json!({"tasks": [
|
||||
{"id": "one", "title": "First", "status": "invented"},
|
||||
]}),
|
||||
] {
|
||||
let call = ToolCall {
|
||||
id: "invalid-task-update".to_string(),
|
||||
name: UPDATE_TASK_LIST_NAME.to_string(),
|
||||
arguments,
|
||||
};
|
||||
let ToolCallDecision::Reject(result) = policy.decide(&call, &[], &[]) else {
|
||||
panic!("invalid task updates must not execute or succeed");
|
||||
};
|
||||
assert_eq!(result.call_id, call.id);
|
||||
assert_eq!(result.status, ToolResultStatus::Error);
|
||||
assert!(result.content.starts_with("Error: "));
|
||||
assert_eq!(crate::task_list_from_tool_result(&result.content), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recall_searches_archived_and_live_results_with_live_results_most_recent() {
|
||||
let policy = ToolPolicy::new(&[definition(RECALL_TOOL_HISTORY_NAME)]);
|
||||
|
||||
Reference in New Issue
Block a user