Merge remote-tracking branch 'ryserve/master'

# Conflicts:
#	app/src/ai/llms.rs
This commit is contained in:
2026-10-25 01:32:18 -05:00
126 changed files with 7937 additions and 1147 deletions
@@ -661,10 +661,14 @@ impl AIExecutionProfile {
PermissionMode::GalaxyDecides => RunAgentsPermission::GalaxyDecides,
PermissionMode::AllowAll => RunAgentsPermission::AlwaysAllow,
};
self.ask_user_question = if self.permission_mode == PermissionMode::AskFirst {
AskUserQuestionPermission::AlwaysAsk
} else {
AskUserQuestionPermission::Never
// Allow All bypasses tool approval, while clarification can still need user input.
self.ask_user_question = match self.permission_mode {
PermissionMode::AskFirst | PermissionMode::AllowAll => {
AskUserQuestionPermission::AlwaysAsk
}
PermissionMode::ReadOnly | PermissionMode::GalaxyDecides => {
AskUserQuestionPermission::Never
}
};
self.directory_allowlist.clear();
self.mcp_allowlist.clear();
@@ -61,6 +61,7 @@ fn serialized_profiles_only_store_one_permission_mode_and_bounded_approvals() {
"write_to_pty",
"run_agents",
"computer_use",
"ask_user_question",
"directory_allowlist",
"mcp_allowlist",
] {
@@ -127,3 +128,25 @@ fn allow_all_projects_every_tool_to_always_allow() {
assert_eq!(profile.computer_use, ComputerUsePermission::AlwaysAllow);
assert_eq!(profile.run_agents, RunAgentsPermission::AlwaysAllow);
}
#[test]
fn clarification_availability_follows_permission_mode_after_projection_and_restore() {
for (mode, expected) in [
(PermissionMode::AskFirst, AskUserQuestionPermission::AlwaysAsk),
(PermissionMode::AllowAll, AskUserQuestionPermission::AlwaysAsk),
(PermissionMode::GalaxyDecides, AskUserQuestionPermission::Never),
(PermissionMode::ReadOnly, AskUserQuestionPermission::Never),
] {
let mut profile = AIExecutionProfile {
permission_mode: mode,
..Default::default()
};
profile.project_permission_mode();
assert_eq!(profile.ask_user_question, expected, "{mode:?}");
let saved = serde_json::to_value(&profile).unwrap();
assert!(saved.get("ask_user_question").is_none());
let restored: AIExecutionProfile = serde_json::from_value(saved).unwrap();
assert_eq!(restored.ask_user_question, expected, "{mode:?}");
}
}
+2
View File
@@ -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::*;
+101 -22
View File
@@ -4,10 +4,11 @@ use std::fmt;
use serde::{Deserialize, Serialize};
use crate::types::tool_image_group_call_id;
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 +358,9 @@ pub enum ProviderRunProtocolError {
InvalidTranscriptCompaction {
message: String,
},
InvalidTaskList {
message: String,
},
InvalidRestoredState {
message: String,
},
@@ -422,6 +426,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 +444,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 +467,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 +497,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 +589,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()));
}
@@ -831,6 +861,7 @@ impl ProviderRun {
.push(pending.call.id.clone());
pending.state = PendingToolCallState::Resolved {
result: ToolResult {
images: Vec::new(),
call_id: pending.call.id.clone(),
content: "Tool execution was interrupted by application restart and was not replayed."
.to_string(),
@@ -1132,6 +1163,7 @@ impl ProviderRun {
let content = reason.unwrap_or_else(|| "Tool permission was denied.".to_string());
call.state = PendingToolCallState::Resolved {
result: ToolResult {
images: Vec::new(),
call_id: call_id.to_string(),
content,
status: ToolResultStatus::Denied,
@@ -1215,6 +1247,7 @@ impl ProviderRun {
| PendingToolCallState::RecoveryPending => {
call.state = PendingToolCallState::Resolved {
result: ToolResult {
images: Vec::new(),
call_id: call_id.to_string(),
content: reason.into(),
status: ToolResultStatus::Cancelled,
@@ -1273,6 +1306,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 +1328,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.
@@ -1424,6 +1472,7 @@ impl ProviderRun {
let state = if has_launch && !is_local_agent_launch(&call) {
PendingToolCallState::Resolved {
result: ToolResult {
images: Vec::new(),
call_id: call.id.clone(),
content: "Not executed: launching a child group yields this turn. Wait for the group's completion before scheduling dependent work.".to_string(),
status: ToolResultStatus::Error,
@@ -1432,6 +1481,7 @@ impl ProviderRun {
} else if !call.arguments.is_object() {
PendingToolCallState::Resolved {
result: ToolResult {
images: Vec::new(),
call_id: call.id.clone(),
content: format!(
"Error: '{}' received malformed arguments; expected a JSON object.",
@@ -1543,6 +1593,7 @@ impl ProviderRun {
if call.state.result().is_none() {
call.state = PendingToolCallState::Resolved {
result: ToolResult {
images: Vec::new(),
call_id: call.call.id.clone(),
content: result_content.clone(),
status,
@@ -1634,6 +1685,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(())
}
@@ -1726,16 +1789,7 @@ fn assistant_message(turn: &CompletedModelTurn) -> ConversationMessage {
fn tool_result_message(results: Vec<ToolResult>) -> ConversationMessage {
ConversationMessage {
role: MessageRole::User,
content: MessageContent::MultiPart(
results
.into_iter()
.map(|result| ContentPart::ToolResult {
tool_use_id: result.call_id,
content: result.content,
is_error: !matches!(result.status, ToolResultStatus::Success),
})
.collect(),
),
content: MessageContent::MultiPart(ToolResult::into_batch_content_parts(results)),
}
}
@@ -1806,17 +1860,42 @@ pub fn archive_tool_results(
seen_results.insert(tool_use_id.clone())
}
MessageContent::MultiPart(parts) => {
parts.retain(|part| match part {
ContentPart::ToolUse { tool_use_id, .. } => {
seen_uses.insert(tool_use_id.clone())
let mut retained_results = HashSet::new();
let mut keep_parts: Vec<_> = parts
.iter()
.map(|part| match part {
ContentPart::ToolUse { tool_use_id, .. } => {
seen_uses.insert(tool_use_id.clone())
}
ContentPart::ToolResult { tool_use_id, .. } => {
let keep = seen_results.insert(tool_use_id.clone());
if keep {
retained_results.insert(tool_use_id.clone());
}
keep
}
ContentPart::Text(_)
| ContentPart::Reasoning { .. }
| ContentPart::Image { .. } => false,
})
.collect();
let mut keep_image_group = false;
for (index, part) in parts.iter().enumerate() {
match part {
ContentPart::Text(_) => {
keep_image_group = tool_image_group_call_id(part)
.is_some_and(|call_id| retained_results.contains(call_id))
&& matches!(parts.get(index + 1), Some(ContentPart::Image { .. }));
keep_parts[index] = keep_image_group;
}
ContentPart::Image { .. } => keep_parts[index] = keep_image_group,
ContentPart::Reasoning { .. }
| ContentPart::ToolUse { .. }
| ContentPart::ToolResult { .. } => keep_image_group = false,
}
ContentPart::ToolResult { tool_use_id, .. } => {
seen_results.insert(tool_use_id.clone())
}
ContentPart::Text(_)
| ContentPart::Reasoning { .. }
| ContentPart::Image { .. } => false,
});
}
let mut retained = keep_parts.into_iter();
parts.retain(|_| retained.next().unwrap_or(false));
!parts.is_empty()
}
MessageContent::Text(_) => false,
@@ -1,7 +1,7 @@
use serde_json::json;
use super::*;
use crate::{AgentErrorKind, PermissionKind};
use crate::{AgentErrorKind, PermissionKind, TaskListItem, TaskListStatus, ToolResultImage};
fn initial_messages() -> Vec<ConversationMessage> {
vec![ConversationMessage {
@@ -88,6 +88,7 @@ fn accept_tool_turn(run: &mut ProviderRun, turn: CompletedModelTurn) -> PendingT
fn successful_result(call_id: &str, content: &str) -> ToolResult {
ToolResult {
images: Vec::new(),
call_id: call_id.to_string(),
content: content.to_string(),
status: ToolResultStatus::Success,
@@ -354,6 +355,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();
@@ -1478,6 +1674,127 @@ fn multipart_archive_is_not_multiplied_and_legacy_duplicates_are_repaired() {
assert_eq!(archive, expected);
}
#[test]
fn screenshot_results_survive_checkpoint_compaction_and_archive_deduplication() {
let mut run = ProviderRun::new(
"screenshots",
vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::MultiPart(vec![ContentPart::Image {
data: vec![99],
mime_type: "image/png".to_string(),
}]),
}],
"base",
ProviderRunLimits::default(),
);
let batch = accept_tool_turn(
&mut run,
tool_turn(
vec![
tool_call("capture", "use_computer"),
tool_call("capture-more", "use_computer"),
],
&["use_computer"],
),
);
for (call_id, bytes) in [("capture", vec![1, 2]), ("capture-more", vec![3, 4])] {
let mut result = successful_result(call_id, "screenshot captured");
result.images.push(ToolResultImage {
data: bytes,
mime_type: "image/png".to_string(),
});
run.complete_tool(&batch.work_id, result).unwrap();
}
// Pending typed results and their image payloads must survive a checkpoint.
assert_serialization_round_trip(&run);
run = serde_json::from_str(&serde_json::to_string(&run).unwrap()).unwrap();
run.commit_tool_batch(&batch.work_id).unwrap();
let transcript = run.transcript().to_vec();
let summary = vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("Screenshots inspected.".to_string()),
}];
run.compact_transcript_at_model_boundary(transcript.len(), summary.clone())
.unwrap();
assert_eq!(run.transcript(), summary);
assert_eq!(run.tool_result_archive().len(), 2);
let MessageContent::MultiPart(parts) = &run.tool_result_archive()[1].content else {
panic!("expected archived screenshot result batch");
};
assert_eq!(parts.len(), 6);
assert!(matches!(
&parts[2], ContentPart::Text(text) if text == "Images returned by tool call capture:"
));
assert!(matches!(
&parts[3], ContentPart::Image { data, .. } if data == &[1, 2]
));
assert!(matches!(
&parts[4], ContentPart::Text(text) if text == "Images returned by tool call capture-more:"
));
assert!(matches!(
&parts[5], ContentPart::Image { data, .. } if data == &[3, 4]
));
let expected = run.tool_result_archive().to_vec();
let duplicated = expected.iter().cloned().chain(transcript).collect();
run.replace_tool_result_archive(duplicated);
assert_eq!(run.tool_result_archive(), expected);
assert_serialization_round_trip(&run);
}
#[test]
fn archives_only_images_labelled_for_new_results_in_the_same_message() {
let result = successful_result("capture", "captured");
let mut parts = ToolResult::into_batch_content_parts(vec![result]);
parts.extend([
ContentPart::Image {
data: vec![99],
mime_type: "image/png".to_string(),
},
ContentPart::Text("Images returned by tool call missing:".to_string()),
ContentPart::Image {
data: vec![88],
mime_type: "image/png".to_string(),
},
ContentPart::Text("Images returned by tool call capture:".to_string()),
ContentPart::Image {
data: vec![1],
mime_type: "image/png".to_string(),
},
ContentPart::Text("Unrelated user upload".to_string()),
ContentPart::Image {
data: vec![77],
mime_type: "image/png".to_string(),
},
]);
let mut archive = Vec::new();
let message = ConversationMessage {
role: MessageRole::User,
content: MessageContent::MultiPart(parts),
};
archive_tool_results(&mut archive, vec![message.clone()]);
let MessageContent::MultiPart(parts) = &archive[0].content else {
panic!("expected correlated tool result with one image");
};
assert_eq!(parts.len(), 3);
assert!(matches!(&parts[2], ContentPart::Image { data, .. } if data == &[1]));
let expected = archive.clone();
archive_tool_results(&mut archive, vec![message]);
assert_eq!(archive, expected);
}
#[test]
fn legacy_tool_results_without_images_still_restore() {
let result: ToolResult = serde_json::from_value(json!({
"call_id": "legacy",
"content": "old text result",
"status": "Success"
}))
.unwrap();
assert!(result.images.is_empty());
assert_eq!(result.content, "old text result");
}
#[test]
fn local_child_launch_yields_and_does_not_execute_batched_follow_up_tools() {
let mut run = run();
@@ -1523,6 +1840,7 @@ fn failed_child_launch_allows_a_corrective_model_turn() {
run.complete_tool(
&batch.work_id,
ToolResult {
images: Vec::new(),
call_id: "children".to_string(),
content: "Launch failed".to_string(),
status: ToolResultStatus::Error,
+129
View File
@@ -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);
}
}
+98 -15
View File
@@ -2,9 +2,10 @@ use std::collections::{BTreeSet, HashMap, VecDeque};
use serde_json::Value as JsonValue;
use crate::types::tool_image_group_call_id;
use crate::{
ContentPart, ConversationMessage, MessageContent, ToolCall, ToolDefinition, ToolResult,
ToolResultStatus,
ContentPart, ConversationMessage, MessageContent, TaskListSnapshot, ToolCall, ToolDefinition,
ToolResult, ToolResultImage, ToolResultStatus, UPDATE_TASK_LIST_NAME,
};
pub const RECALL_TOOL_HISTORY_NAME: &str = "recall_tool_history";
@@ -124,6 +125,7 @@ impl ToolPolicy {
)
};
return ToolCallDecision::Reject(ToolResult {
images: Vec::new(),
call_id: call.id.clone(),
content: format!(
"Error: '{}' is not a valid tool for this request; {available}. Do not invent tool names.",
@@ -134,17 +136,36 @@ impl ToolPolicy {
}
if call.name == RECALL_TOOL_HISTORY_NAME {
let (content, images) = recall_tool_history_with_images(
messages,
archive,
ToolHistoryQuery::from_arguments(&call.arguments),
);
return ToolCallDecision::Inline(ToolResult {
images,
call_id: call.id.clone(),
content: recall_tool_history(
messages,
archive,
ToolHistoryQuery::from_arguments(&call.arguments),
),
content,
status: ToolResultStatus::Success,
});
}
if call.name == UPDATE_TASK_LIST_NAME {
return match TaskListSnapshot::from_arguments(&call.arguments) {
Ok(snapshot) => ToolCallDecision::Inline(ToolResult {
images: Vec::new(),
call_id: call.id.clone(),
content: snapshot.to_tool_result_content(),
status: ToolResultStatus::Success,
}),
Err(error) => ToolCallDecision::Reject(ToolResult {
images: Vec::new(),
call_id: call.id.clone(),
content: format!("Error: {error}"),
status: ToolResultStatus::Error,
}),
};
}
ToolCallDecision::Execute
}
}
@@ -186,6 +207,14 @@ pub fn recall_tool_history(
archive: &[ConversationMessage],
query: ToolHistoryQuery<'_>,
) -> String {
recall_tool_history_with_images(messages, archive, query).0
}
fn recall_tool_history_with_images(
messages: &[ConversationMessage],
archive: &[ConversationMessage],
query: ToolHistoryQuery<'_>,
) -> (String, Vec<ToolResultImage>) {
let mut entries = Vec::new();
collect_tool_entries(archive, &mut entries);
collect_tool_entries(messages, &mut entries);
@@ -207,26 +236,43 @@ pub fn recall_tool_history(
.len()
.checked_sub(1usize.saturating_add(query.offset_from_end))
else {
return "No matching tool calls found in conversation history.".to_string();
return (
"No matching tool calls found in conversation history.".to_string(),
Vec::new(),
);
};
let entry = filtered[index];
let result = truncate_recalled_result(&entry.result);
format!(
"Tool: {}\nTool Use ID: {}\nInput: {}\nResult:\n{result}",
entry.name, entry.tool_use_id, entry.input
(
format!(
"Tool: {}\nTool Use ID: {}\nInput: {}\nResult:\n{result}",
entry.name, entry.tool_use_id, entry.input
),
entry
.images
.iter()
.map(|(data, mime_type)| ToolResultImage {
data: data.to_vec(),
mime_type: (*mime_type).to_string(),
})
.collect(),
)
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct ToolHistoryEntry {
#[derive(Clone, Debug, PartialEq)]
struct ToolHistoryEntry<'a> {
tool_use_id: String,
name: String,
input: String,
result: String,
images: Vec<(&'a [u8], &'a str)>,
}
fn collect_tool_entries(messages: &[ConversationMessage], entries: &mut Vec<ToolHistoryEntry>) {
fn collect_tool_entries<'a>(
messages: &'a [ConversationMessage],
entries: &mut Vec<ToolHistoryEntry<'a>>,
) {
let mut pending = Vec::new();
for message in messages {
match &message.content {
@@ -241,6 +287,7 @@ fn collect_tool_entries(messages: &[ConversationMessage], entries: &mut Vec<Tool
..
} => pair_result(tool_use_id, content, &mut pending, entries),
MessageContent::MultiPart(parts) => {
let entries_start = entries.len();
for part in parts {
match part {
ContentPart::ToolUse {
@@ -260,6 +307,41 @@ fn collect_tool_entries(messages: &[ConversationMessage], entries: &mut Vec<Tool
| ContentPart::Image { .. } => {}
}
}
// ToolResult::into_batch_content_parts places labelled image groups
// after every correlated result. Only attach images to results from
// this same message, never a nearby user upload or another call.
let result_ids: BTreeSet<_> = parts
.iter()
.filter_map(|part| match part {
ContentPart::ToolResult { tool_use_id, .. } => Some(tool_use_id.as_str()),
ContentPart::Text(_)
| ContentPart::Reasoning { .. }
| ContentPart::Image { .. }
| ContentPart::ToolUse { .. } => None,
})
.collect();
let mut image_call_id = None;
for part in parts {
match part {
ContentPart::Text(_) => {
image_call_id = tool_image_group_call_id(part)
.filter(|call_id| result_ids.contains(call_id));
}
ContentPart::Image { data, mime_type } => {
if let Some(entry) = image_call_id.and_then(|call_id| {
entries[entries_start..]
.iter_mut()
.rev()
.find(|entry| entry.tool_use_id == call_id)
}) {
entry.images.push((data, mime_type));
}
}
ContentPart::Reasoning { .. }
| ContentPart::ToolUse { .. }
| ContentPart::ToolResult { .. } => image_call_id = None,
}
}
}
MessageContent::Text(_) => {}
}
@@ -270,7 +352,7 @@ fn pair_result(
tool_use_id: &str,
content: &str,
pending: &mut Vec<(String, String, String)>,
entries: &mut Vec<ToolHistoryEntry>,
entries: &mut Vec<ToolHistoryEntry<'_>>,
) {
let Some(index) = pending.iter().position(|(id, _, _)| id == tool_use_id) else {
return;
@@ -281,6 +363,7 @@ fn pair_result(
name,
input,
result: content.to_string(),
images: Vec::new(),
});
}
@@ -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)]);
@@ -156,6 +212,120 @@ fn recall_never_returns_a_prior_recall_call() {
);
}
fn screenshot_exchange(id: &str, bytes: Vec<u8>) -> Vec<ConversationMessage> {
let mut messages = tool_exchange(id, "use_computer", serde_json::json!({}), "captured");
messages[1].content = MessageContent::MultiPart(ToolResult::into_batch_content_parts(vec![
ToolResult {
call_id: id.to_string(),
content: "captured".to_string(),
status: ToolResultStatus::Success,
images: vec![ToolResultImage {
data: bytes,
mime_type: "image/png".to_string(),
}],
},
]));
messages
}
#[test]
fn recall_attaches_only_the_exact_selected_screenshots_from_archived_or_live_results() {
let archive = screenshot_exchange("capture", vec![1, 2]);
let mut messages = screenshot_exchange("capture-more", vec![3, 4]);
messages.push(ConversationMessage {
role: MessageRole::User,
content: MessageContent::MultiPart(vec![
ContentPart::Text("Images returned by tool call capture:".to_string()),
ContentPart::Image {
data: vec![99],
mime_type: "image/png".to_string(),
},
]),
});
let policy = ToolPolicy::new(&[definition(RECALL_TOOL_HISTORY_NAME)]);
for (arguments, original_id, bytes) in [
(
serde_json::json!({"tool_use_id": "capture"}),
"capture",
vec![1, 2],
),
(
serde_json::json!({"tool_name": "use_computer"}),
"capture-more",
vec![3, 4],
),
(
serde_json::json!({"tool_name": "use_computer", "offset_from_end": 1}),
"capture",
vec![1, 2],
),
] {
let call = ToolCall {
id: "recall-image".to_string(),
name: RECALL_TOOL_HISTORY_NAME.to_string(),
arguments,
};
let ToolCallDecision::Inline(result) = policy.decide(&call, &messages, &archive) else {
panic!("recall should execute inline");
};
assert_eq!(result.call_id, call.id);
assert!(result
.content
.contains(&format!("Tool Use ID: {original_id}\n")));
assert_eq!(
result.images,
vec![ToolResultImage {
data: bytes,
mime_type: "image/png".to_string(),
}]
);
let parts = ToolResult::into_batch_content_parts(vec![result]);
assert!(matches!(
&parts[0], ContentPart::ToolResult { tool_use_id, .. } if tool_use_id == "recall-image"
));
assert!(matches!(
&parts[1], ContentPart::Text(text) if text == "Images returned by tool call recall-image:"
));
assert!(matches!(&parts[2], ContentPart::Image { .. }));
}
let call = ToolCall {
id: "missing".to_string(),
name: RECALL_TOOL_HISTORY_NAME.to_string(),
arguments: serde_json::json!({"tool_use_id": "capture-missing"}),
};
let ToolCallDecision::Inline(result) = policy.decide(&call, &messages, &archive) else {
panic!("missing recall should execute inline");
};
assert!(result.images.is_empty());
assert_eq!(
result.content,
"No matching tool calls found in conversation history."
);
}
#[test]
fn recall_does_not_reattach_images_from_an_earlier_recall_result() {
let mut messages = screenshot_exchange("recall-previous", vec![99]);
let MessageContent::ToolUse { name, .. } = &mut messages[0].content else {
panic!("expected tool call");
};
*name = RECALL_TOOL_HISTORY_NAME.to_string();
let policy = ToolPolicy::new(&[definition(RECALL_TOOL_HISTORY_NAME)]);
let call = ToolCall {
id: "recall-now".to_string(),
name: RECALL_TOOL_HISTORY_NAME.to_string(),
arguments: serde_json::json!({}),
};
let ToolCallDecision::Inline(result) = policy.decide(&call, &messages, &[]) else {
panic!("recall should execute inline");
};
assert!(result.images.is_empty());
assert_eq!(
result.content,
"No matching tool calls found in conversation history."
);
}
#[test]
fn loop_guard_detects_repeated_failures_and_resets_after_detection() {
let mut guard = ToolLoopGuard::new(5, 3);
+44
View File
@@ -190,9 +190,53 @@ pub struct ToolResult {
pub call_id: String,
pub content: String,
pub status: ToolResultStatus,
/// Images returned by a tool, kept outside the text so provider adapters receive real images.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub images: Vec<ToolResultImage>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ToolResultImage {
pub data: Vec<u8>,
pub mime_type: String,
}
pub(crate) fn tool_image_group_call_id(part: &ContentPart) -> Option<&str> {
let ContentPart::Text(text) = part else {
return None;
};
text.strip_prefix("Images returned by tool call ")?
.strip_suffix(':')
.filter(|call_id| !call_id.is_empty())
}
impl ToolResult {
pub fn into_batch_content_parts(results: Vec<Self>) -> Vec<ContentPart> {
let mut parts = Vec::new();
let mut image_parts = Vec::new();
for result in results {
let is_error = !matches!(result.status, ToolResultStatus::Success);
if !result.images.is_empty() {
image_parts.push(ContentPart::Text(format!(
"Images returned by tool call {}:", result.call_id
)));
image_parts.extend(result.images.into_iter().map(|image| ContentPart::Image {
data: image.data,
mime_type: image.mime_type,
}));
}
parts.push(ContentPart::ToolResult {
tool_use_id: result.call_id,
content: result.content,
is_error,
});
}
// OpenAI requires every tool result before the next user message. Its adapter splits
// adjacent images into a user message, so never interleave them with a result batch.
parts.extend(image_parts);
parts
}
pub fn is_error(&self) -> bool {
matches!(
self.status,
@@ -169,6 +169,7 @@ fn tool_events_keep_one_call_id_across_permission_and_execution() {
},
ToolEvent::Completed {
result: ToolResult {
images: Vec::new(),
call_id: "call-1".to_string(),
content: "ok".to_string(),
status: ToolResultStatus::Success,
@@ -182,11 +183,13 @@ fn tool_events_keep_one_call_id_across_permission_and_execution() {
#[test]
fn denied_results_are_errors_but_cancelled_results_are_distinct() {
let denied = ToolResult {
images: Vec::new(),
call_id: "denied".to_string(),
content: "permission denied".to_string(),
status: ToolResultStatus::Denied,
};
let cancelled = ToolResult {
images: Vec::new(),
call_id: "cancelled".to_string(),
content: "cancelled".to_string(),
status: ToolResultStatus::Cancelled,
+52 -22
View File
@@ -1,6 +1,7 @@
use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use futures::future::BoxFuture;
use futures::StreamExt;
use galaxy_agent_core::{
AgentError, AgentErrorKind, AgentEvent, AgentEventStream, AgentRuntime, ConversationMessage,
@@ -8,35 +9,50 @@ use galaxy_agent_core::{
TurnRequest, turn_control,
};
use rig_core::client::CompletionClient;
use rig_core::providers::chatgpt;
use rig_core::providers::chatgpt::{self, ChatGPTAuth};
use crate::request::build_completion_request;
use crate::stream::start_model_turn;
/// Refreshes Galaxy's cached ChatGPT subscription credentials without allowing
/// Rig to fall back to its interactive device-code flow.
pub async fn refresh_chatgpt_subscription_credentials() -> Result<(), String> {
let client = chatgpt::Client::builder()
.oauth()
.allow_device_flow(false)
.originator("galaxy")
.build()
.map_err(|error| error.to_string())?;
client.authorize().await.map_err(|error| error.to_string())
#[derive(Clone, PartialEq, Eq)]
pub struct ChatGPTSubscriptionCredentials {
pub access_token: String,
pub account_id: Option<String>,
}
impl std::fmt::Debug for ChatGPTSubscriptionCredentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ChatGPTSubscriptionCredentials")
.field("access_token", &"<redacted>")
.field("account_id", &self.account_id.as_ref().map(|_| "<redacted>"))
.finish()
}
}
pub type ChatGPTCredentialResolver = Arc<
dyn Fn() -> BoxFuture<'static, Result<ChatGPTSubscriptionCredentials, String>> + Send + Sync,
>;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChatGPTSubscriptionRuntimeConfig {
pub model: String,
pub reasoning_effort: Option<String>,
pub max_output_tokens: Option<u64>,
pub auth_file: Option<PathBuf>,
}
#[derive(Clone, Debug)]
#[derive(Clone)]
pub struct ChatGPTSubscriptionRuntime {
config: ChatGPTSubscriptionRuntimeConfig,
descriptor: RuntimeDescriptor,
credential_resolver: Option<ChatGPTCredentialResolver>,
}
impl std::fmt::Debug for ChatGPTSubscriptionRuntime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ChatGPTSubscriptionRuntime")
.field("config", &self.config)
.field("has_credential_resolver", &self.credential_resolver.is_some())
.finish()
}
}
fn reasoning_additional_params(reasoning_effort: Option<&str>) -> Option<serde_json::Value> {
@@ -56,7 +72,24 @@ impl ChatGPTSubscriptionRuntime {
kind: RuntimeKind::Provider,
capabilities: RuntimeCapabilities::provider(),
};
Self { config, descriptor }
Self { config, descriptor, credential_resolver: None }
}
pub fn with_credential_resolver(mut self, resolver: ChatGPTCredentialResolver) -> Self {
self.credential_resolver = Some(resolver);
self
}
async fn resolve_auth(&self) -> Result<ChatGPTAuth, AgentError> {
let resolver = self.credential_resolver.as_ref().ok_or_else(|| {
AgentError::new(AgentErrorKind::Authentication, "ChatGPT sign-in required. Reconnect ChatGPT in Providers.")
})?;
let credentials = resolver().await
.map_err(|message| AgentError::new(AgentErrorKind::Authentication, message))?;
Ok(ChatGPTAuth::AccessToken {
access_token: credentials.access_token,
account_id: credentials.account_id,
})
}
/// Completes a text-only request for callers such as crosscheck reviewers.
@@ -117,14 +150,11 @@ impl AgentRuntime for ChatGPTSubscriptionRuntime {
request: TurnRequest,
control: TurnControl,
) -> Result<AgentEventStream, AgentError> {
let mut builder = chatgpt::Client::builder()
.oauth()
let auth = self.resolve_auth().await?;
let client = chatgpt::Client::builder()
.api_key(auth)
.allow_device_flow(false)
.originator("galaxy");
if let Some(auth_file) = &self.config.auth_file {
builder = builder.auth_file(auth_file);
}
let client = builder
.originator("galaxy")
.build()
.map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?;
let model = client.completion_model(&self.config.model);
+108 -3
View File
@@ -1,13 +1,14 @@
use galaxy_agent_core::StopReason;
use rig_core::client::CompletionClient;
use rig_core::completion::{AssistantContent, CompletionModel, Message};
use rig_core::message::{ToolResultContent, UserContent};
use rig_core::providers::chatgpt::ChatGPTAuth;
use rig_core::test_utils::RecordingHttpClient;
use rig_core::test_utils::{RecordingHttpClient, SequencedStreamingHttpClient};
use super::*;
const COMPLETED_RESPONSE: &str = r#"data: {"type":"response.output_text.delta","delta":"ok"}
data: {"type":"response.completed","response":{"id":"resp_test","object":"response","created_at":1,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-5.3-codex","usage":{"input_tokens":1,"input_tokens_details":{"cached_tokens":0},"output_tokens":1,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":2},"output":[{"type":"message","id":"msg_test","status":"completed","role":"assistant","content":[{"type":"output_text","annotations":[],"text":"ok"}]}],"tools":[]}}
data: {"type":"response.completed","sequence_number":54,"response":{"id":"resp_test","object":"response","created_at":1,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-5.3-codex","usage":{"input_tokens":1,"input_tokens_details":{"cached_tokens":0},"output_tokens":1,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":2},"output":[{"type":"message","id":"msg_test","status":"completed","role":"assistant","content":[{"type":"output_text","annotations":[],"text":"ok"}]}],"tools":[]}}
data: [DONE]"#;
#[test]
@@ -16,7 +17,6 @@ fn runtime_descriptor_identifies_chatgpt_subscription() {
model: "gpt-5.3-codex".to_string(),
reasoning_effort: Some("high".to_string()),
max_output_tokens: None,
auth_file: None,
});
assert_eq!(
@@ -25,6 +25,43 @@ fn runtime_descriptor_identifies_chatgpt_subscription() {
);
}
#[tokio::test]
async fn runtime_resolves_current_credentials_without_rig_oauth() {
let runtime = ChatGPTSubscriptionRuntime::new(ChatGPTSubscriptionRuntimeConfig {
model: "test-model".to_owned(),
reasoning_effort: None,
max_output_tokens: None,
}).with_credential_resolver(std::sync::Arc::new(|| {
Box::pin(async {
Ok(ChatGPTSubscriptionCredentials {
access_token: "resolved-token".to_owned(),
account_id: Some("resolved-account".to_owned()),
})
})
}));
match runtime.resolve_auth().await.unwrap() {
ChatGPTAuth::AccessToken { access_token, account_id } => {
assert_eq!(access_token, "resolved-token");
assert_eq!(account_id.as_deref(), Some("resolved-account"));
}
ChatGPTAuth::OAuth => panic!("The runtime must not own OAuth refresh"),
}
}
#[tokio::test]
async fn missing_or_failed_resolver_requires_sign_in_without_device_flow() {
let runtime = ChatGPTSubscriptionRuntime::new(ChatGPTSubscriptionRuntimeConfig {
model: "test-model".to_owned(),
reasoning_effort: None,
max_output_tokens: None,
});
assert_eq!(runtime.resolve_auth().await.unwrap_err().kind, AgentErrorKind::Authentication);
let runtime = runtime.with_credential_resolver(std::sync::Arc::new(|| {
Box::pin(async { Err("Sign in again".to_owned()) })
}));
assert_eq!(runtime.resolve_auth().await.unwrap_err().kind, AgentErrorKind::Authentication);
}
#[test]
fn reasoning_effort_is_encoded_for_the_responses_request() {
let request = TurnRequest::new(
@@ -115,3 +152,71 @@ async fn chatgpt_follow_up_request_preserves_responses_call_ids() {
assert_eq!(input[0]["call_id"], "call_native_1");
assert_eq!(input[1]["call_id"], "call_native_1");
}
#[tokio::test]
async fn chatgpt_metadata_does_not_interrupt_text_or_completion() {
let metadata = r#"data: {"type":"response.metadata","response_id":"resp_test","sequence_number":51,"metadata":{"openai_chatgpt_moderation_metadata":{"prompt":{},"generation":{},"tool_call":{},"tool_response":{}}}}"#;
let delta = serde_json::json!({
"type": "response.output_text.delta",
"item_id": "msg_test",
"output_index": 0,
"content_index": 0,
"sequence_number": 52,
"delta": "ok",
});
let completed = COMPLETED_RESPONSE
.lines()
.find(|line| line.starts_with("data: {\"type\":\"response.completed\""))
.expect("fixture should contain a completed response");
let response =
format!("{metadata}\n\ndata: {delta}\n\n{metadata}\n\n{completed}\n\ndata: [DONE]\n\n");
let client = chatgpt::Client::builder()
.api_key(ChatGPTAuth::AccessToken {
access_token: "test-token".to_string(),
account_id: None,
})
.http_client(SequencedStreamingHttpClient::new(vec![Ok(response.into())]))
.build()
.expect("client should build");
let request = build_completion_request(
TurnRequest::new(
"gpt-5.3-codex".to_string(),
vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("hello".to_string()),
}],
),
None,
true,
true,
None,
)
.expect("request should convert");
let (_sender, control) = turn_control();
let mut stream = start_model_turn(
client.completion_model("gpt-5.3-codex"),
request,
control,
None,
)
.await
.expect("request should reach the mocked provider");
let mut text = String::new();
let mut usage = None;
let mut stop_reason = None;
while let Some(event) = stream.next().await {
match event.expect("metadata should not fail the turn") {
AgentEvent::TextDelta { text: delta } => text.push_str(&delta),
AgentEvent::UsageUpdated { usage: updated } => usage = Some(updated),
AgentEvent::TurnStopped { reason } => stop_reason = Some(reason),
_ => {}
}
}
assert_eq!(text, "ok");
assert_eq!(
usage.expect("completion should report usage").output_tokens,
1
);
assert_eq!(stop_reason, Some(StopReason::Completed));
}
+12
View File
@@ -160,6 +160,13 @@ where
});
}
Ok(StreamedAssistantContent::Unknown(value)) => {
// ChatGPT subscription streams include informational moderation
// metadata that Rig does not map to assistant content.
if value.get("type").and_then(serde_json::Value::as_str)
== Some("response.metadata")
{
continue;
}
if is_keepalive_event(&value) {
yield Ok(AgentEvent::KeepAlive);
} else {
@@ -512,6 +519,11 @@ fn text_indicates_authentication_failure(text: &str) -> bool {
"unrecognized client",
"security token included in the request is invalid",
"unable to locate credentials",
"unauthorizedexception",
"session token not found or invalid",
"sso session has expired",
"failed to load aws credentials",
"bedrock credentials not configured",
"credential should be scoped to a valid region",
]
.iter()
@@ -105,6 +105,10 @@ fn flattened_bedrock_credential_errors_are_authentication_failures() {
r#"{"__type":"ExpiredTokenException","message":"The security token included in the request is expired"}"#,
"UnrecognizedClientException: The security token included in the request is invalid",
"AccessDeniedException: not authorized to perform bedrock:ConverseStream",
"UnauthorizedException: Session token not found or invalid",
"SSO session has expired or is otherwise invalid",
"Failed to load AWS credentials: credentials were not loaded",
"Bedrock credentials not configured",
] {
let mapped = map_completion_error(CompletionError::ProviderError(message.to_string()));
assert_eq!(mapped.kind, AgentErrorKind::Authentication);
@@ -112,3 +116,15 @@ fn flattened_bedrock_credential_errors_are_authentication_failures() {
assert!(mapped.user_message.is_some());
}
}
#[test]
fn unrelated_provider_failures_do_not_become_authentication_failures() {
for message in [
"HTTP client error: connection reset",
"ProviderError: temporarily unavailable",
"ValidationException: invalid model",
] {
let mapped = map_completion_error(CompletionError::ProviderError(message.to_owned()));
assert_ne!(mapped.kind, AgentErrorKind::Authentication);
}
}
+1 -1
View File
@@ -169,7 +169,7 @@ impl Harness {
pub fn display_name(self) -> &'static str {
match self {
Self::Oz => "Oz",
Self::Oz => "Galaxy",
Self::Claude => "Claude Code",
Self::OpenCode => "OpenCode",
Self::Gemini => "Gemini CLI",