Make direct-provider agent runs durable
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use galaxy_agent_core::{
|
||||
AgentEvent, RuntimeActivity, RuntimeActivityStatus, RuntimeCapabilities, StopReason, Usage,
|
||||
AgentEvent, ProviderRunOutcome, RuntimeActivity, RuntimeActivityStatus, RuntimeCapabilities,
|
||||
StopReason, Usage,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
use warp_multi_agent_api::response_event::stream_finished;
|
||||
use warp_multi_agent_api::{self as api, ClientAction, ResponseEvent};
|
||||
|
||||
use super::provider_run_coordinator::ProviderRunProjection;
|
||||
use crate::ai::agent::runtime_activity;
|
||||
use crate::ai::bedrock::response_translator::{
|
||||
build_add_agent_output_message, build_append_text, build_create_task, build_stream_init,
|
||||
@@ -14,6 +16,7 @@ use crate::ai::bedrock::response_translator::{
|
||||
};
|
||||
use crate::ai::openai::response_translator::{build_stream_finished, StreamUsage};
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub(crate) struct RuntimeResponseConfig {
|
||||
pub(crate) task_id: String,
|
||||
pub(crate) conversation_id: String,
|
||||
@@ -41,17 +44,104 @@ pub(crate) struct RuntimeResponseTranslator {
|
||||
context_usage: Option<(u64, u64)>,
|
||||
}
|
||||
|
||||
/// Projects a multi-turn provider run into one existing Galaxy response stream.
|
||||
/// Intermediate model stops remain coordinator-internal; only the run outcome
|
||||
/// emits the UI's terminal `Finished` event.
|
||||
pub(crate) struct ProviderRunResponseProjector {
|
||||
translator: RuntimeResponseTranslator,
|
||||
has_started_model_turn: bool,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl ProviderRunResponseProjector {
|
||||
pub(crate) fn new(config: RuntimeResponseConfig) -> Self {
|
||||
Self {
|
||||
translator: RuntimeResponseTranslator::new(config),
|
||||
has_started_model_turn: false,
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn restored(config: RuntimeResponseConfig) -> Self {
|
||||
Self {
|
||||
translator: RuntimeResponseTranslator::restored(config),
|
||||
has_started_model_turn: false,
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn project(
|
||||
&mut self,
|
||||
projection: ProviderRunProjection,
|
||||
) -> Result<Vec<ResponseEvent>, String> {
|
||||
if self.finished {
|
||||
return Err("provider run projection is already finished".to_string());
|
||||
}
|
||||
match projection {
|
||||
ProviderRunProjection::ModelTurnStarted { .. } => {
|
||||
if self.has_started_model_turn {
|
||||
self.translator.begin_followup_turn();
|
||||
}
|
||||
self.has_started_model_turn = true;
|
||||
self.translator.translate(AgentEvent::TurnStarted {
|
||||
runtime_request_id: String::new(),
|
||||
})
|
||||
}
|
||||
ProviderRunProjection::ModelEvent { event, .. } => self.translator.translate(event),
|
||||
ProviderRunProjection::ModelRetry { .. }
|
||||
| ProviderRunProjection::ToolBatchReady { .. } => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_task_id(&mut self, task_id: impl Into<String>) {
|
||||
self.translator.set_task_id(task_id);
|
||||
}
|
||||
|
||||
pub(crate) fn finish(
|
||||
&mut self,
|
||||
outcome: &ProviderRunOutcome,
|
||||
) -> Result<Vec<ResponseEvent>, String> {
|
||||
if self.finished {
|
||||
return Err("provider run projection is already finished".to_string());
|
||||
}
|
||||
self.finished = true;
|
||||
match outcome {
|
||||
ProviderRunOutcome::Completed(completion) => {
|
||||
self.translator.translate(AgentEvent::TurnStopped {
|
||||
reason: completion.stop_reason.clone(),
|
||||
})
|
||||
}
|
||||
ProviderRunOutcome::Failed(failure) => {
|
||||
Ok(self.translator.provider_failure(&failure.message))
|
||||
}
|
||||
ProviderRunOutcome::Cancelled { .. } => {
|
||||
self.translator.translate(AgentEvent::TurnStopped {
|
||||
reason: StopReason::Cancelled,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeResponseTranslator {
|
||||
pub(crate) fn new(config: RuntimeResponseConfig) -> Self {
|
||||
Self::with_initialization(config, false)
|
||||
}
|
||||
|
||||
pub(crate) fn restored(config: RuntimeResponseConfig) -> Self {
|
||||
Self::with_initialization(config, true)
|
||||
}
|
||||
|
||||
fn with_initialization(config: RuntimeResponseConfig, initialized: bool) -> Self {
|
||||
Self {
|
||||
config,
|
||||
request_id: Uuid::new_v4().to_string(),
|
||||
initialized: false,
|
||||
initialized,
|
||||
text_message_id: None,
|
||||
reasoning_message_id: None,
|
||||
activity_message_ids: HashMap::new(),
|
||||
activities: HashMap::new(),
|
||||
has_visible_output: false,
|
||||
has_visible_output: initialized,
|
||||
usage: Usage::default(),
|
||||
context_usage: None,
|
||||
}
|
||||
@@ -152,6 +242,18 @@ impl RuntimeResponseTranslator {
|
||||
self.reasoning_message_id = None;
|
||||
}
|
||||
|
||||
pub(crate) fn set_task_id(&mut self, task_id: impl Into<String>) {
|
||||
let task_id = task_id.into();
|
||||
if self.config.task_id == task_id {
|
||||
return;
|
||||
}
|
||||
self.config.task_id = task_id;
|
||||
self.text_message_id = None;
|
||||
self.reasoning_message_id = None;
|
||||
self.activity_message_ids.clear();
|
||||
self.activities.clear();
|
||||
}
|
||||
|
||||
fn initialize(&mut self, events: &mut Vec<ResponseEvent>) {
|
||||
if self.initialized {
|
||||
return;
|
||||
@@ -256,7 +358,23 @@ impl RuntimeResponseTranslator {
|
||||
}
|
||||
|
||||
fn finished(&self, reason: StopReason) -> ResponseEvent {
|
||||
let reason = map_stop_reason(reason);
|
||||
self.finished_with_reason(map_stop_reason(reason))
|
||||
}
|
||||
|
||||
fn provider_failure(&mut self, message: &str) -> Vec<ResponseEvent> {
|
||||
let mut events = Vec::new();
|
||||
self.initialize(&mut events);
|
||||
events.push(
|
||||
self.finished_with_reason(stream_finished::Reason::InternalError(
|
||||
stream_finished::InternalError {
|
||||
message: message.to_owned(),
|
||||
},
|
||||
)),
|
||||
);
|
||||
events
|
||||
}
|
||||
|
||||
fn finished_with_reason(&self, reason: stream_finished::Reason) -> ResponseEvent {
|
||||
if !self.config.capabilities.host_managed_history {
|
||||
let (used_tokens, context_size) = self.context_usage.unwrap_or_default();
|
||||
return build_context_finished(
|
||||
|
||||
@@ -3,8 +3,9 @@ use galaxy_agent_core::{
|
||||
};
|
||||
use warp_multi_agent_api::{client_action, message, response_event};
|
||||
|
||||
use super::{RuntimeResponseConfig, RuntimeResponseTranslator};
|
||||
use super::{ProviderRunResponseProjector, RuntimeResponseConfig, RuntimeResponseTranslator};
|
||||
use crate::ai::agent::runtime_activity;
|
||||
use crate::ai::runtime::provider_run_coordinator::ProviderRunProjection;
|
||||
|
||||
fn provider_translator() -> RuntimeResponseTranslator {
|
||||
RuntimeResponseTranslator::new(RuntimeResponseConfig {
|
||||
@@ -32,6 +33,51 @@ fn session_translator() -> RuntimeResponseTranslator {
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restored_provider_projection_skips_stream_initialization() {
|
||||
let config = RuntimeResponseConfig {
|
||||
task_id: "task".to_owned(),
|
||||
conversation_id: "conversation".to_owned(),
|
||||
needs_create_task: true,
|
||||
user_query: Some("do not duplicate".to_owned()),
|
||||
model_id: "model".to_owned(),
|
||||
max_context_tokens: Some(1_000),
|
||||
capabilities: RuntimeCapabilities::provider(),
|
||||
empty_output_message: None,
|
||||
};
|
||||
let mut projector = ProviderRunResponseProjector::restored(config);
|
||||
let work_id = galaxy_agent_core::ExternalWorkId {
|
||||
run_id: galaxy_agent_core::ProviderRunId::new("run"),
|
||||
epoch: galaxy_agent_core::RunEpoch::new(2),
|
||||
};
|
||||
|
||||
assert!(projector
|
||||
.project(ProviderRunProjection::ModelTurnStarted {
|
||||
work_id: work_id.clone(),
|
||||
runtime_request_id: "request".to_owned(),
|
||||
retry_attempt: 0,
|
||||
})
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
let events = projector
|
||||
.project(ProviderRunProjection::ModelEvent {
|
||||
work_id,
|
||||
event: AgentEvent::TextDelta {
|
||||
text: "continued".to_owned(),
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(events.len(), 1);
|
||||
let Some(response_event::Type::ClientActions(actions)) = &events[0].r#type else {
|
||||
panic!("restored output should append through a client action");
|
||||
};
|
||||
assert!(matches!(
|
||||
actions.actions[0].action,
|
||||
Some(client_action::Action::AddMessagesToTask(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_and_session_runtimes_share_text_translation() {
|
||||
for mut translator in [provider_translator(), session_translator()] {
|
||||
@@ -63,6 +109,41 @@ fn provider_and_session_runtimes_share_text_translation() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retargeting_starts_new_text_and_reasoning_messages_on_the_new_task() {
|
||||
let mut translator = provider_translator();
|
||||
translator
|
||||
.translate(AgentEvent::TextDelta {
|
||||
text: "root text".to_owned(),
|
||||
})
|
||||
.expect("root text");
|
||||
translator
|
||||
.translate(AgentEvent::ReasoningDelta {
|
||||
text: "root reasoning".to_owned(),
|
||||
})
|
||||
.expect("root reasoning");
|
||||
|
||||
translator.set_task_id("cli-task");
|
||||
for event in [
|
||||
AgentEvent::TextDelta {
|
||||
text: "cli text".to_owned(),
|
||||
},
|
||||
AgentEvent::ReasoningDelta {
|
||||
text: "cli reasoning".to_owned(),
|
||||
},
|
||||
] {
|
||||
let translated = translator.translate(event).expect("retargeted output");
|
||||
let Some(response_event::Type::ClientActions(actions)) = &translated[0].r#type else {
|
||||
panic!("expected retargeted client action");
|
||||
};
|
||||
let Some(client_action::Action::AddMessagesToTask(add)) = &actions.actions[0].action else {
|
||||
panic!("retargeted output must start a new message");
|
||||
};
|
||||
assert_eq!(add.task_id, "cli-task");
|
||||
assert_eq!(add.messages[0].task_id, "cli-task");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_uses_the_native_reasoning_message_contract() {
|
||||
let mut translator = provider_translator();
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
mod event_translator;
|
||||
mod provider;
|
||||
mod provider_run_coordinator;
|
||||
mod rig;
|
||||
mod rig_request;
|
||||
mod rig_tool;
|
||||
|
||||
pub(crate) use event_translator::{RuntimeResponseConfig, RuntimeResponseTranslator};
|
||||
pub(crate) use provider::ProviderRuntime;
|
||||
pub(crate) use rig::{rig_bedrock_response_stream, rig_openai_response_stream};
|
||||
pub(crate) use event_translator::{
|
||||
ProviderRunResponseProjector, RuntimeResponseConfig, RuntimeResponseTranslator,
|
||||
};
|
||||
pub(crate) use provider_run_coordinator::{
|
||||
ProviderRunBlock, ProviderRunCoordinator, ProviderRunProfile, ProviderToolExecutionRef,
|
||||
ProviderToolLifecycleOutcome, BASE_PROVIDER_PROFILE, CLI_MONITOR_PROVIDER_PROFILE,
|
||||
};
|
||||
pub(crate) use rig::{
|
||||
prepare_provider_run, provider_runtime_for_request, PreparedProviderRun, ProviderActionContext,
|
||||
};
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
use futures::channel::oneshot;
|
||||
|
||||
use crate::ai::agent::api::{self, ConvertToAPITypeError};
|
||||
use crate::ai::provider::ProviderConfig;
|
||||
|
||||
/// Application-facing provider runtime dispatcher.
|
||||
///
|
||||
/// OpenAI-compatible models can opt into the provider-neutral Rig runtime;
|
||||
/// other models continue through their current translators while migration is
|
||||
/// in progress. Both paths preserve the existing UI response stream contract.
|
||||
pub(crate) struct ProviderRuntime {
|
||||
provider_config: ProviderConfig,
|
||||
}
|
||||
|
||||
impl ProviderRuntime {
|
||||
pub(crate) fn new(provider_config: ProviderConfig) -> Self {
|
||||
Self { provider_config }
|
||||
}
|
||||
|
||||
pub(crate) async fn start_turn(
|
||||
self,
|
||||
params: api::RequestParams,
|
||||
cancellation_rx: oneshot::Receiver<()>,
|
||||
) -> Result<api::ResponseStream, ConvertToAPITypeError> {
|
||||
api::generate_multi_agent_output(self.provider_config, params, cancellation_rx).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,743 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::future::BoxFuture;
|
||||
use futures::StreamExt;
|
||||
use galaxy_agent_core::{
|
||||
AgentError, AgentErrorKind, AgentEvent, AgentRuntime, CompletedModelTurn, ContentPart,
|
||||
ExternalWorkId, ModelFailureDisposition, PendingToolBatch, ProviderModelCall,
|
||||
ProviderRequestProfile, ProviderRun, ProviderRunFailureKind, ProviderRunId, ProviderRunLimits,
|
||||
ProviderRunOutcome, ProviderRunPhase, ProviderRunProtocolError, ProviderRunState,
|
||||
ProviderRunStep, RunEpoch, RuntimeKind, StopReason, ToolEvent, TurnControl, TurnRequest, Usage,
|
||||
};
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
|
||||
pub(crate) const BASE_PROVIDER_PROFILE: &str = "base";
|
||||
pub(crate) const CLI_MONITOR_PROVIDER_PROFILE: &str = "cli-monitor";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum ProviderRunProjection {
|
||||
ModelTurnStarted {
|
||||
work_id: ExternalWorkId,
|
||||
runtime_request_id: String,
|
||||
retry_attempt: u32,
|
||||
},
|
||||
ModelEvent {
|
||||
work_id: ExternalWorkId,
|
||||
event: AgentEvent,
|
||||
},
|
||||
ModelRetry {
|
||||
work_id: ExternalWorkId,
|
||||
retry_attempt: u32,
|
||||
error: AgentError,
|
||||
},
|
||||
ToolBatchReady {
|
||||
batch: PendingToolBatch,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum ProviderRunBlock {
|
||||
Tools(PendingToolBatch),
|
||||
AwaitingDriver {
|
||||
work_id: ExternalWorkId,
|
||||
stop_reason: StopReason,
|
||||
},
|
||||
Done(ProviderRunOutcome),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ProviderToolExecutionRef {
|
||||
pub(crate) conversation_id: AIConversationId,
|
||||
pub(crate) run_id: ProviderRunId,
|
||||
pub(crate) epoch: RunEpoch,
|
||||
pub(crate) call_id: String,
|
||||
}
|
||||
|
||||
impl ProviderToolExecutionRef {
|
||||
pub(crate) fn new(
|
||||
conversation_id: AIConversationId,
|
||||
work_id: &ExternalWorkId,
|
||||
call_id: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
conversation_id,
|
||||
run_id: work_id.run_id.clone(),
|
||||
epoch: work_id.epoch,
|
||||
call_id: call_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn work_id(&self) -> ExternalWorkId {
|
||||
ExternalWorkId {
|
||||
run_id: self.run_id.clone(),
|
||||
epoch: self.epoch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ProviderToolLifecycleOutcome {
|
||||
Pending,
|
||||
BatchCommitted,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ProviderRunCoordinatorError {
|
||||
InvalidRuntime(String),
|
||||
InvalidToolLifecycle(String),
|
||||
Core(ProviderRunProtocolError),
|
||||
}
|
||||
|
||||
impl fmt::Display for ProviderRunCoordinatorError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidRuntime(message) | Self::InvalidToolLifecycle(message) => {
|
||||
f.write_str(message)
|
||||
}
|
||||
Self::Core(error) => error.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for ProviderRunCoordinatorError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
match self {
|
||||
Self::InvalidRuntime(_) | Self::InvalidToolLifecycle(_) => None,
|
||||
Self::Core(error) => Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProviderRunProtocolError> for ProviderRunCoordinatorError {
|
||||
fn from(value: ProviderRunProtocolError) -> Self {
|
||||
Self::Core(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ProviderRunProfile {
|
||||
pub(crate) runtime: Arc<dyn AgentRuntime>,
|
||||
pub(crate) request: TurnRequest,
|
||||
}
|
||||
|
||||
impl ProviderRunProfile {
|
||||
pub(crate) fn new(runtime: Arc<dyn AgentRuntime>, request: TurnRequest) -> Self {
|
||||
Self { runtime, request }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ProviderRunCoordinator {
|
||||
run: ProviderRun,
|
||||
profiles: BTreeMap<String, ProviderRunProfile>,
|
||||
}
|
||||
|
||||
impl ProviderRunCoordinator {
|
||||
pub(crate) fn from_request(
|
||||
run_id: impl Into<galaxy_agent_core::ProviderRunId>,
|
||||
runtime: Arc<dyn AgentRuntime>,
|
||||
request: TurnRequest,
|
||||
tool_result_archive: Vec<galaxy_agent_core::ConversationMessage>,
|
||||
limits: ProviderRunLimits,
|
||||
) -> Result<Self, ProviderRunCoordinatorError> {
|
||||
let mut run = ProviderRun::new(
|
||||
run_id,
|
||||
request.messages.clone(),
|
||||
BASE_PROVIDER_PROFILE,
|
||||
limits,
|
||||
);
|
||||
run.replace_tool_result_archive(tool_result_archive);
|
||||
let mut profiles = BTreeMap::new();
|
||||
profiles.insert(
|
||||
BASE_PROVIDER_PROFILE.to_string(),
|
||||
ProviderRunProfile::new(runtime, request),
|
||||
);
|
||||
Self::new(run, profiles)
|
||||
}
|
||||
|
||||
pub(crate) fn new(
|
||||
run: ProviderRun,
|
||||
profiles: BTreeMap<String, ProviderRunProfile>,
|
||||
) -> Result<Self, ProviderRunCoordinatorError> {
|
||||
if !profiles.contains_key(run.profile().as_str()) {
|
||||
return Err(ProviderRunCoordinatorError::InvalidRuntime(format!(
|
||||
"provider request profile '{}' is not configured",
|
||||
run.profile().as_str()
|
||||
)));
|
||||
}
|
||||
for (profile, config) in &profiles {
|
||||
validate_profile_runtime(profile, config.runtime.as_ref())?;
|
||||
}
|
||||
Ok(Self { run, profiles })
|
||||
}
|
||||
|
||||
pub(crate) fn run(&self) -> &ProviderRun {
|
||||
&self.run
|
||||
}
|
||||
|
||||
pub(crate) fn run_mut(&mut self) -> &mut ProviderRun {
|
||||
&mut self.run
|
||||
}
|
||||
|
||||
pub(crate) fn profile_request(&self, profile: &str) -> Option<&TurnRequest> {
|
||||
self.profiles.get(profile).map(|profile| &profile.request)
|
||||
}
|
||||
|
||||
pub(crate) fn insert_profile(
|
||||
&mut self,
|
||||
profile: impl Into<String>,
|
||||
runtime: Arc<dyn AgentRuntime>,
|
||||
request: TurnRequest,
|
||||
) -> Result<(), ProviderRunCoordinatorError> {
|
||||
let profile = profile.into();
|
||||
validate_profile_runtime(&profile, runtime.as_ref())?;
|
||||
self.profiles
|
||||
.insert(profile, ProviderRunProfile::new(runtime, request));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn apply_tool_lifecycle(
|
||||
&mut self,
|
||||
execution_ref: &ProviderToolExecutionRef,
|
||||
event: &ToolEvent,
|
||||
) -> Result<ProviderToolLifecycleOutcome, ProviderRunCoordinatorError> {
|
||||
let event_call_id = tool_event_call_id(event)?;
|
||||
if event_call_id != execution_ref.call_id {
|
||||
return Err(ProviderRunCoordinatorError::InvalidToolLifecycle(format!(
|
||||
"provider tool lifecycle call ID mismatch: expected '{}', received '{}'",
|
||||
execution_ref.call_id, event_call_id
|
||||
)));
|
||||
}
|
||||
|
||||
let work_id = execution_ref.work_id();
|
||||
match event {
|
||||
ToolEvent::Proposed { .. } => {
|
||||
return Err(ProviderRunCoordinatorError::InvalidToolLifecycle(
|
||||
"tool proposals must be committed by the model turn before action execution"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
ToolEvent::PermissionRequested { request } => {
|
||||
self.run
|
||||
.request_tool_permission(&work_id, request.clone())?;
|
||||
}
|
||||
ToolEvent::PermissionResolved {
|
||||
request_id,
|
||||
call_id,
|
||||
decision,
|
||||
} => {
|
||||
self.run.resolve_tool_permission(
|
||||
&work_id,
|
||||
call_id,
|
||||
request_id,
|
||||
decision.clone(),
|
||||
)?;
|
||||
}
|
||||
ToolEvent::Started { call_id } => {
|
||||
self.run.start_tool(&work_id, call_id)?;
|
||||
}
|
||||
ToolEvent::Completed { result } => {
|
||||
self.run.complete_tool(&work_id, result.clone())?;
|
||||
}
|
||||
}
|
||||
|
||||
let batch_is_complete = match self.run.state() {
|
||||
ProviderRunState::AwaitingTools { batch } => batch.is_complete(),
|
||||
ProviderRunState::ReadyToCallModel
|
||||
| ProviderRunState::AwaitingModel { .. }
|
||||
| ProviderRunState::ResolvingModel { .. }
|
||||
| ProviderRunState::AwaitingDriver { .. }
|
||||
| ProviderRunState::Done { .. }
|
||||
| ProviderRunState::Failed { .. }
|
||||
| ProviderRunState::Cancelled { .. } => false,
|
||||
};
|
||||
if batch_is_complete {
|
||||
self.run.commit_tool_batch(&work_id)?;
|
||||
Ok(ProviderToolLifecycleOutcome::BatchCommitted)
|
||||
} else {
|
||||
Ok(ProviderToolLifecycleOutcome::Pending)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn drive_until_blocked<F>(
|
||||
&mut self,
|
||||
control: TurnControl,
|
||||
project: F,
|
||||
) -> Result<ProviderRunBlock, ProviderRunCoordinatorError>
|
||||
where
|
||||
F: FnMut(ProviderRunProjection) -> Result<(), String>,
|
||||
{
|
||||
self.drive_until_blocked_with_checkpoint(control, project, |_| Box::pin(async { Ok(()) }))
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn drive_until_blocked_with_checkpoint<F, C>(
|
||||
&mut self,
|
||||
control: TurnControl,
|
||||
mut project: F,
|
||||
mut checkpoint: C,
|
||||
) -> Result<ProviderRunBlock, ProviderRunCoordinatorError>
|
||||
where
|
||||
F: FnMut(ProviderRunProjection) -> Result<(), String>,
|
||||
C: FnMut(ProviderRun) -> BoxFuture<'static, Result<(), String>>,
|
||||
{
|
||||
loop {
|
||||
match self.run.next_step()? {
|
||||
Some(ProviderRunStep::CallModel(call)) => {
|
||||
if !self.checkpoint_or_fail(&mut checkpoint).await? {
|
||||
continue;
|
||||
}
|
||||
self.drive_model_call(call, control.clone(), &mut project)
|
||||
.await?;
|
||||
}
|
||||
Some(ProviderRunStep::DispatchTools(batch)) => {
|
||||
if !self.checkpoint_or_fail(&mut checkpoint).await? {
|
||||
continue;
|
||||
}
|
||||
if !self.project_or_fail(
|
||||
ProviderRunProjection::ToolBatchReady {
|
||||
batch: batch.clone(),
|
||||
},
|
||||
&mut project,
|
||||
)? {
|
||||
continue;
|
||||
}
|
||||
if batch.is_complete() {
|
||||
self.run.commit_tool_batch(&batch.work_id)?;
|
||||
continue;
|
||||
}
|
||||
return Ok(ProviderRunBlock::Tools(batch));
|
||||
}
|
||||
Some(ProviderRunStep::Done(outcome)) => {
|
||||
if !self.checkpoint_or_fail(&mut checkpoint).await? {
|
||||
continue;
|
||||
}
|
||||
return Ok(ProviderRunBlock::Done(outcome));
|
||||
}
|
||||
None => match self.run.state() {
|
||||
ProviderRunState::AwaitingDriver {
|
||||
work_id,
|
||||
stop_reason,
|
||||
} => {
|
||||
let work_id = work_id.clone();
|
||||
let stop_reason = stop_reason.clone();
|
||||
if !self.checkpoint_or_fail(&mut checkpoint).await? {
|
||||
continue;
|
||||
}
|
||||
return Ok(ProviderRunBlock::AwaitingDriver {
|
||||
work_id,
|
||||
stop_reason,
|
||||
});
|
||||
}
|
||||
ProviderRunState::ReadyToCallModel
|
||||
| ProviderRunState::AwaitingModel { .. }
|
||||
| ProviderRunState::ResolvingModel { .. }
|
||||
| ProviderRunState::AwaitingTools { .. }
|
||||
| ProviderRunState::Done { .. }
|
||||
| ProviderRunState::Failed { .. }
|
||||
| ProviderRunState::Cancelled { .. } => {
|
||||
return Err(ProviderRunCoordinatorError::Core(
|
||||
ProviderRunProtocolError::UnexpectedState {
|
||||
expected: ProviderRunPhase::AwaitingDriver,
|
||||
actual: self.run.state().phase(),
|
||||
},
|
||||
));
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn checkpoint_or_fail<C>(
|
||||
&mut self,
|
||||
checkpoint: &mut C,
|
||||
) -> Result<bool, ProviderRunCoordinatorError>
|
||||
where
|
||||
C: FnMut(ProviderRun) -> BoxFuture<'static, Result<(), String>>,
|
||||
{
|
||||
match checkpoint(self.run.clone()).await {
|
||||
Ok(()) => Ok(true),
|
||||
Err(message) => {
|
||||
self.run.fail(
|
||||
ProviderRunFailureKind::ExternalWork,
|
||||
format!("provider run checkpoint failed: {message}"),
|
||||
)?;
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn drive_model_call<F>(
|
||||
&mut self,
|
||||
call: ProviderModelCall,
|
||||
control: TurnControl,
|
||||
project: &mut F,
|
||||
) -> Result<(), ProviderRunCoordinatorError>
|
||||
where
|
||||
F: FnMut(ProviderRunProjection) -> Result<(), String>,
|
||||
{
|
||||
let Some(profile) = self.profiles.get(call.profile.as_str()).cloned() else {
|
||||
self.run.fail(
|
||||
ProviderRunFailureKind::ExternalWork,
|
||||
format!(
|
||||
"provider request profile '{}' is not configured",
|
||||
call.profile.as_str()
|
||||
),
|
||||
)?;
|
||||
return Ok(());
|
||||
};
|
||||
let advertised_tools = profile
|
||||
.request
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| tool.name.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let request = request_for_model_call(profile.request, &call);
|
||||
let stream = match profile.runtime.start_turn(request, control).await {
|
||||
Ok(stream) => stream,
|
||||
Err(error) => {
|
||||
self.handle_model_failure(&call.work_id, error, project)?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
futures::pin_mut!(stream);
|
||||
let mut buffer = ModelTurnBuffer::default();
|
||||
|
||||
while let Some(event) = stream.next().await {
|
||||
let event = match event {
|
||||
Ok(event) => event,
|
||||
Err(error) => {
|
||||
self.handle_model_failure(&call.work_id, error, project)?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
match event {
|
||||
AgentEvent::TurnStarted { runtime_request_id } => {
|
||||
if buffer.started {
|
||||
self.handle_model_failure(
|
||||
&call.work_id,
|
||||
protocol_error("provider emitted more than one TurnStarted event"),
|
||||
project,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
if runtime_request_id.is_empty() {
|
||||
self.handle_model_failure(
|
||||
&call.work_id,
|
||||
protocol_error("provider emitted an empty runtime request ID"),
|
||||
project,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
buffer.started = true;
|
||||
if !self.project_or_fail(
|
||||
ProviderRunProjection::ModelTurnStarted {
|
||||
work_id: call.work_id.clone(),
|
||||
runtime_request_id,
|
||||
retry_attempt: call.retry_attempt,
|
||||
},
|
||||
project,
|
||||
)? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
AgentEvent::TextDelta { text } => {
|
||||
if !self.ensure_model_started(&call.work_id, &buffer, project)? {
|
||||
return Ok(());
|
||||
}
|
||||
buffer.text.push_str(&text);
|
||||
if !self.project_or_fail(
|
||||
ProviderRunProjection::ModelEvent {
|
||||
work_id: call.work_id.clone(),
|
||||
event: AgentEvent::TextDelta { text },
|
||||
},
|
||||
project,
|
||||
)? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
AgentEvent::ReasoningDelta { text } => {
|
||||
if !self.ensure_model_started(&call.work_id, &buffer, project)? {
|
||||
return Ok(());
|
||||
}
|
||||
buffer.reasoning.push_str(&text);
|
||||
if !self.project_or_fail(
|
||||
ProviderRunProjection::ModelEvent {
|
||||
work_id: call.work_id.clone(),
|
||||
event: AgentEvent::ReasoningDelta { text },
|
||||
},
|
||||
project,
|
||||
)? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
AgentEvent::ReasoningCompleted { text, signature } => {
|
||||
if !self.ensure_model_started(&call.work_id, &buffer, project)? {
|
||||
return Ok(());
|
||||
}
|
||||
if !text.is_empty() {
|
||||
buffer.reasoning.clone_from(&text);
|
||||
}
|
||||
buffer.reasoning_signature.clone_from(&signature);
|
||||
if !self.project_or_fail(
|
||||
ProviderRunProjection::ModelEvent {
|
||||
work_id: call.work_id.clone(),
|
||||
event: AgentEvent::ReasoningCompleted { text, signature },
|
||||
},
|
||||
project,
|
||||
)? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
AgentEvent::Tool {
|
||||
event: ToolEvent::Proposed { call: tool_call },
|
||||
} => {
|
||||
if !self.ensure_model_started(&call.work_id, &buffer, project)? {
|
||||
return Ok(());
|
||||
}
|
||||
buffer.tool_calls.push(tool_call);
|
||||
}
|
||||
AgentEvent::UsageUpdated { usage } => {
|
||||
if !self.ensure_model_started(&call.work_id, &buffer, project)? {
|
||||
return Ok(());
|
||||
}
|
||||
buffer.usage.clone_from(&usage);
|
||||
let cumulative_usage = combined_usage(self.run.usage(), &usage);
|
||||
if !self.project_or_fail(
|
||||
ProviderRunProjection::ModelEvent {
|
||||
work_id: call.work_id.clone(),
|
||||
event: AgentEvent::UsageUpdated {
|
||||
usage: cumulative_usage,
|
||||
},
|
||||
},
|
||||
project,
|
||||
)? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
AgentEvent::TurnStopped { reason } => {
|
||||
if !self.ensure_model_started(&call.work_id, &buffer, project)? {
|
||||
return Ok(());
|
||||
}
|
||||
if reason == StopReason::Cancelled {
|
||||
self.run.cancel("provider model call was cancelled")?;
|
||||
return Ok(());
|
||||
}
|
||||
let turn = buffer.complete(reason, advertised_tools);
|
||||
if let Err(error) = self.run.accept_model_turn(&call.work_id, turn) {
|
||||
self.run.fail(
|
||||
ProviderRunFailureKind::Protocol,
|
||||
format!("provider returned an invalid completed turn: {error}"),
|
||||
)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
AgentEvent::Tool {
|
||||
event:
|
||||
ToolEvent::PermissionRequested { .. }
|
||||
| ToolEvent::PermissionResolved { .. }
|
||||
| ToolEvent::Started { .. }
|
||||
| ToolEvent::Completed { .. },
|
||||
}
|
||||
| AgentEvent::RuntimeActivityUpdated { .. }
|
||||
| AgentEvent::ContextUsageUpdated { .. }
|
||||
| AgentEvent::UserInputAccepted { .. }
|
||||
| AgentEvent::RuntimeNotice { .. } => {
|
||||
self.handle_model_failure(
|
||||
&call.work_id,
|
||||
protocol_error(
|
||||
"direct-provider transport emitted a non-model lifecycle event",
|
||||
),
|
||||
project,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.handle_model_failure(
|
||||
&call.work_id,
|
||||
protocol_error("provider stream ended before TurnStopped"),
|
||||
project,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_model_started<F>(
|
||||
&mut self,
|
||||
work_id: &ExternalWorkId,
|
||||
buffer: &ModelTurnBuffer,
|
||||
project: &mut F,
|
||||
) -> Result<bool, ProviderRunCoordinatorError>
|
||||
where
|
||||
F: FnMut(ProviderRunProjection) -> Result<(), String>,
|
||||
{
|
||||
if buffer.started {
|
||||
return Ok(true);
|
||||
}
|
||||
self.handle_model_failure(
|
||||
work_id,
|
||||
protocol_error("provider emitted model output before TurnStarted"),
|
||||
project,
|
||||
)?;
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn handle_model_failure<F>(
|
||||
&mut self,
|
||||
work_id: &ExternalWorkId,
|
||||
error: AgentError,
|
||||
project: &mut F,
|
||||
) -> Result<(), ProviderRunCoordinatorError>
|
||||
where
|
||||
F: FnMut(ProviderRunProjection) -> Result<(), String>,
|
||||
{
|
||||
let disposition = self.run.register_model_failure(work_id, error.clone())?;
|
||||
if disposition == ModelFailureDisposition::RetryScheduled {
|
||||
let retry_attempt = match self.run.state() {
|
||||
ProviderRunState::AwaitingModel { call } => call.retry_attempt,
|
||||
ProviderRunState::ReadyToCallModel
|
||||
| ProviderRunState::ResolvingModel { .. }
|
||||
| ProviderRunState::AwaitingTools { .. }
|
||||
| ProviderRunState::AwaitingDriver { .. }
|
||||
| ProviderRunState::Done { .. }
|
||||
| ProviderRunState::Failed { .. }
|
||||
| ProviderRunState::Cancelled { .. } => {
|
||||
return Err(ProviderRunCoordinatorError::Core(
|
||||
ProviderRunProtocolError::UnexpectedState {
|
||||
expected: ProviderRunPhase::AwaitingModel,
|
||||
actual: self.run.state().phase(),
|
||||
},
|
||||
));
|
||||
}
|
||||
};
|
||||
self.project_or_fail(
|
||||
ProviderRunProjection::ModelRetry {
|
||||
work_id: work_id.clone(),
|
||||
retry_attempt,
|
||||
error,
|
||||
},
|
||||
project,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn project_or_fail<F>(
|
||||
&mut self,
|
||||
event: ProviderRunProjection,
|
||||
project: &mut F,
|
||||
) -> Result<bool, ProviderRunCoordinatorError>
|
||||
where
|
||||
F: FnMut(ProviderRunProjection) -> Result<(), String>,
|
||||
{
|
||||
match project(event) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(message) => {
|
||||
self.run.fail(
|
||||
ProviderRunFailureKind::Projection,
|
||||
format!("provider run projection failed: {message}"),
|
||||
)?;
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ModelTurnBuffer {
|
||||
started: bool,
|
||||
text: String,
|
||||
reasoning: String,
|
||||
reasoning_signature: Option<String>,
|
||||
tool_calls: Vec<galaxy_agent_core::ToolCall>,
|
||||
usage: Usage,
|
||||
}
|
||||
|
||||
impl ModelTurnBuffer {
|
||||
fn complete(
|
||||
self,
|
||||
stop_reason: StopReason,
|
||||
advertised_tools: BTreeSet<String>,
|
||||
) -> CompletedModelTurn {
|
||||
let mut assistant_content = Vec::new();
|
||||
if !self.reasoning.is_empty() || self.reasoning_signature.is_some() {
|
||||
assistant_content.push(ContentPart::Reasoning {
|
||||
text: self.reasoning,
|
||||
signature: self.reasoning_signature,
|
||||
});
|
||||
}
|
||||
if !self.text.is_empty() {
|
||||
assistant_content.push(ContentPart::Text(self.text));
|
||||
}
|
||||
CompletedModelTurn {
|
||||
assistant_content,
|
||||
tool_calls: self.tool_calls,
|
||||
usage: self.usage,
|
||||
stop_reason,
|
||||
advertised_tools,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_profile_runtime(
|
||||
profile: &str,
|
||||
runtime: &dyn AgentRuntime,
|
||||
) -> Result<(), ProviderRunCoordinatorError> {
|
||||
let descriptor = runtime.descriptor();
|
||||
if descriptor.kind != RuntimeKind::Provider {
|
||||
return Err(ProviderRunCoordinatorError::InvalidRuntime(format!(
|
||||
"runtime '{}' for provider request profile '{profile}' is not a direct-provider transport",
|
||||
descriptor.id
|
||||
)));
|
||||
}
|
||||
if !descriptor.capabilities.host_managed_history || !descriptor.capabilities.host_tool_execution
|
||||
{
|
||||
return Err(ProviderRunCoordinatorError::InvalidRuntime(format!(
|
||||
"runtime '{}' for provider request profile '{profile}' does not expose Galaxy-owned history and tools",
|
||||
descriptor.id
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn request_for_model_call(mut template: TurnRequest, call: &ProviderModelCall) -> TurnRequest {
|
||||
template.prompt = None;
|
||||
template.messages = call.messages.clone();
|
||||
template
|
||||
}
|
||||
|
||||
fn combined_usage(previous: &Usage, current: &Usage) -> Usage {
|
||||
Usage {
|
||||
input_tokens: previous.input_tokens.saturating_add(current.input_tokens),
|
||||
output_tokens: previous.output_tokens.saturating_add(current.output_tokens),
|
||||
cached_input_tokens: previous
|
||||
.cached_input_tokens
|
||||
.saturating_add(current.cached_input_tokens),
|
||||
cache_creation_input_tokens: previous
|
||||
.cache_creation_input_tokens
|
||||
.saturating_add(current.cache_creation_input_tokens),
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_event_call_id(event: &ToolEvent) -> Result<&str, ProviderRunCoordinatorError> {
|
||||
match event {
|
||||
ToolEvent::Proposed { call } => Ok(&call.id),
|
||||
ToolEvent::PermissionRequested { request } => Ok(&request.call_id),
|
||||
ToolEvent::PermissionResolved { call_id, .. } | ToolEvent::Started { call_id } => {
|
||||
Ok(call_id)
|
||||
}
|
||||
ToolEvent::Completed { result } => Ok(&result.call_id),
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_error(message: impl Into<String>) -> AgentError {
|
||||
AgentError::new(AgentErrorKind::Protocol, message)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "provider_run_coordinator_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,963 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use galaxy_agent_core::{
|
||||
turn_control, AgentEventStream, ConversationMessage, MessageContent, MessageRole,
|
||||
PermissionDecision, PermissionKind, PermissionRequest, ProviderRunFailure, RuntimeCapabilities,
|
||||
RuntimeDescriptor, ToolCall, ToolDefinition, ToolResult, ToolResultStatus,
|
||||
};
|
||||
use warp_multi_agent_api::response_event;
|
||||
|
||||
use super::*;
|
||||
use crate::ai::runtime::event_translator::{ProviderRunResponseProjector, RuntimeResponseConfig};
|
||||
|
||||
type ScriptedEvent = Result<AgentEvent, AgentError>;
|
||||
type ScriptedTurn = Result<Vec<ScriptedEvent>, AgentError>;
|
||||
|
||||
struct ScriptedRuntime {
|
||||
descriptor: RuntimeDescriptor,
|
||||
turns: Mutex<VecDeque<ScriptedTurn>>,
|
||||
requests: Mutex<Vec<TurnRequest>>,
|
||||
}
|
||||
|
||||
impl ScriptedRuntime {
|
||||
fn new(turns: Vec<ScriptedTurn>) -> Self {
|
||||
Self::with_id("scripted", turns)
|
||||
}
|
||||
|
||||
fn with_id(id: &str, turns: Vec<ScriptedTurn>) -> Self {
|
||||
Self {
|
||||
descriptor: RuntimeDescriptor {
|
||||
id: id.to_string(),
|
||||
display_name: "Scripted provider".to_string(),
|
||||
kind: RuntimeKind::Provider,
|
||||
capabilities: RuntimeCapabilities::provider(),
|
||||
},
|
||||
turns: Mutex::new(turns.into()),
|
||||
requests: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn requests(&self) -> Vec<TurnRequest> {
|
||||
self.requests.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentRuntime for ScriptedRuntime {
|
||||
fn descriptor(&self) -> &RuntimeDescriptor {
|
||||
&self.descriptor
|
||||
}
|
||||
|
||||
async fn start_turn(
|
||||
&self,
|
||||
request: TurnRequest,
|
||||
_control: TurnControl,
|
||||
) -> Result<AgentEventStream, AgentError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
let turn = self
|
||||
.turns
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.expect("scripted runtime ran out of turns")?;
|
||||
Ok(Box::pin(futures::stream::iter(turn)))
|
||||
}
|
||||
}
|
||||
|
||||
fn request() -> TurnRequest {
|
||||
let mut request = TurnRequest::new(
|
||||
"test-model",
|
||||
vec![ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text("Inspect and fix the issue".to_string()),
|
||||
}],
|
||||
);
|
||||
request.conversation_id = Some("conversation".to_string());
|
||||
request.system_prompt = Some("Use tools and finish the task.".to_string());
|
||||
request.tools = vec![
|
||||
ToolDefinition {
|
||||
name: "read_files".to_string(),
|
||||
description: "Read files".to_string(),
|
||||
input_schema: serde_json::json!({"type": "object"}),
|
||||
},
|
||||
ToolDefinition {
|
||||
name: galaxy_agent_core::RECALL_TOOL_HISTORY_NAME.to_string(),
|
||||
description: "Recall tool results".to_string(),
|
||||
input_schema: serde_json::json!({"type": "object"}),
|
||||
},
|
||||
];
|
||||
request
|
||||
}
|
||||
|
||||
fn started(id: &str) -> ScriptedEvent {
|
||||
Ok(AgentEvent::TurnStarted {
|
||||
runtime_request_id: id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn usage(input_tokens: u64, output_tokens: u64) -> ScriptedEvent {
|
||||
Ok(AgentEvent::UsageUpdated {
|
||||
usage: Usage {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
..Usage::default()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn stopped(reason: StopReason) -> ScriptedEvent {
|
||||
Ok(AgentEvent::TurnStopped { reason })
|
||||
}
|
||||
|
||||
fn tool_call(id: &str, name: &str) -> ToolCall {
|
||||
ToolCall {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
arguments: serde_json::json!({"path": "Cargo.toml"}),
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_turn() -> ScriptedTurn {
|
||||
Ok(vec![
|
||||
started("request-tools"),
|
||||
Ok(AgentEvent::TextDelta {
|
||||
text: "I will inspect it.".to_string(),
|
||||
}),
|
||||
Ok(AgentEvent::Tool {
|
||||
event: ToolEvent::Proposed {
|
||||
call: tool_call("read-1", "read_files"),
|
||||
},
|
||||
}),
|
||||
usage(10, 4),
|
||||
stopped(StopReason::Completed),
|
||||
])
|
||||
}
|
||||
|
||||
fn tool_turn_with_calls(calls: Vec<ToolCall>) -> ScriptedTurn {
|
||||
let mut events = vec![started("request-tools")];
|
||||
events.extend(calls.into_iter().map(|call| {
|
||||
Ok(AgentEvent::Tool {
|
||||
event: ToolEvent::Proposed { call },
|
||||
})
|
||||
}));
|
||||
events.push(usage(10, 4));
|
||||
events.push(stopped(StopReason::Completed));
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
fn answer_turn() -> ScriptedTurn {
|
||||
Ok(vec![
|
||||
started("request-answer"),
|
||||
Ok(AgentEvent::TextDelta {
|
||||
text: "The issue is fixed.".to_string(),
|
||||
}),
|
||||
usage(20, 5),
|
||||
stopped(StopReason::Completed),
|
||||
])
|
||||
}
|
||||
|
||||
fn coordinator(runtime: Arc<dyn AgentRuntime>) -> ProviderRunCoordinator {
|
||||
ProviderRunCoordinator::from_request(
|
||||
"run-1",
|
||||
runtime,
|
||||
request(),
|
||||
Vec::new(),
|
||||
ProviderRunLimits::default(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn coordinator_awaiting_tools(
|
||||
calls: Vec<ToolCall>,
|
||||
) -> (ProviderRunCoordinator, PendingToolBatch) {
|
||||
let runtime = Arc::new(ScriptedRuntime::new(vec![tool_turn_with_calls(calls)]));
|
||||
let mut coordinator = coordinator(runtime);
|
||||
let (_sender, control) = turn_control();
|
||||
let block = coordinator
|
||||
.drive_until_blocked(control, |_| Ok(()))
|
||||
.await
|
||||
.unwrap();
|
||||
let ProviderRunBlock::Tools(batch) = block else {
|
||||
panic!("expected external tool work");
|
||||
};
|
||||
(coordinator, batch)
|
||||
}
|
||||
|
||||
fn tool_result(call_id: &str, content: &str, status: ToolResultStatus) -> ToolEvent {
|
||||
ToolEvent::Completed {
|
||||
result: ToolResult {
|
||||
call_id: call_id.to_string(),
|
||||
content: content.to_string(),
|
||||
status,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_projection(
|
||||
events: &mut Vec<ProviderRunProjection>,
|
||||
) -> impl FnMut(ProviderRunProjection) -> Result<(), String> + '_ {
|
||||
|event| {
|
||||
events.push(event);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_run_drives_model_tool_and_followup_turns_with_atomic_history() {
|
||||
let runtime = Arc::new(ScriptedRuntime::new(vec![tool_turn(), answer_turn()]));
|
||||
let mut coordinator = coordinator(runtime.clone());
|
||||
let mut projections = Vec::new();
|
||||
let (_sender, control) = turn_control();
|
||||
|
||||
let first_block = coordinator
|
||||
.drive_until_blocked(control, collect_projection(&mut projections))
|
||||
.await
|
||||
.unwrap();
|
||||
let ProviderRunBlock::Tools(batch) = first_block else {
|
||||
panic!("expected external tool work");
|
||||
};
|
||||
assert_eq!(batch.work_id.epoch.get(), 1);
|
||||
assert_eq!(batch.calls.len(), 1);
|
||||
assert!(projections.iter().all(|projection| !matches!(
|
||||
projection,
|
||||
ProviderRunProjection::ModelEvent {
|
||||
event: AgentEvent::Tool { .. },
|
||||
..
|
||||
}
|
||||
)));
|
||||
assert!(matches!(
|
||||
projections.last(),
|
||||
Some(ProviderRunProjection::ToolBatchReady { .. })
|
||||
));
|
||||
|
||||
coordinator
|
||||
.run_mut()
|
||||
.start_tool(&batch.work_id, "read-1")
|
||||
.unwrap();
|
||||
coordinator
|
||||
.run_mut()
|
||||
.complete_tool(
|
||||
&batch.work_id,
|
||||
ToolResult {
|
||||
call_id: "read-1".to_string(),
|
||||
content: "manifest contents".to_string(),
|
||||
status: ToolResultStatus::Success,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
coordinator
|
||||
.run_mut()
|
||||
.commit_tool_batch(&batch.work_id)
|
||||
.unwrap();
|
||||
|
||||
projections.clear();
|
||||
let (_sender, control) = turn_control();
|
||||
let second_block = coordinator
|
||||
.drive_until_blocked(control, collect_projection(&mut projections))
|
||||
.await
|
||||
.unwrap();
|
||||
let ProviderRunBlock::AwaitingDriver {
|
||||
work_id,
|
||||
stop_reason,
|
||||
} = second_block
|
||||
else {
|
||||
panic!("expected driver decision");
|
||||
};
|
||||
assert_eq!(stop_reason, StopReason::Completed);
|
||||
assert_eq!(work_id.epoch.get(), 3);
|
||||
assert_eq!(coordinator.run().usage().input_tokens, 30);
|
||||
assert_eq!(coordinator.run().usage().output_tokens, 9);
|
||||
|
||||
let requests = runtime.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests[0].messages.len(), 1);
|
||||
assert_eq!(requests[1].messages.len(), 3);
|
||||
let MessageContent::MultiPart(results) = &requests[1].messages[2].content else {
|
||||
panic!("expected atomic tool result message");
|
||||
};
|
||||
assert!(matches!(
|
||||
results.as_slice(),
|
||||
[ContentPart::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error: false,
|
||||
}] if tool_use_id == "read-1" && content == "manifest contents"
|
||||
));
|
||||
assert!(projections.iter().any(|projection| matches!(
|
||||
projection,
|
||||
ProviderRunProjection::ModelEvent {
|
||||
event: AgentEvent::UsageUpdated { usage },
|
||||
..
|
||||
} if usage.input_tokens == 30 && usage.output_tokens == 9
|
||||
)));
|
||||
|
||||
coordinator.run_mut().complete(&work_id).unwrap();
|
||||
let (_sender, control) = turn_control();
|
||||
assert_eq!(
|
||||
coordinator
|
||||
.drive_until_blocked(control, |_| Ok(()))
|
||||
.await
|
||||
.unwrap(),
|
||||
ProviderRunBlock::Done(ProviderRunOutcome::Completed(
|
||||
galaxy_agent_core::ProviderRunCompletion {
|
||||
stop_reason: StopReason::Completed,
|
||||
}
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_profiles_route_through_their_own_runtime_and_template() {
|
||||
let base_runtime = Arc::new(ScriptedRuntime::with_id(
|
||||
"base-runtime",
|
||||
vec![answer_turn()],
|
||||
));
|
||||
let cli_runtime = Arc::new(ScriptedRuntime::with_id("cli-runtime", vec![answer_turn()]));
|
||||
let mut base_request = request();
|
||||
base_request.model = "base-model".into();
|
||||
base_request.system_prompt = Some("base prompt".to_string());
|
||||
let mut cli_request = request();
|
||||
cli_request.model = "cli-model".into();
|
||||
cli_request.system_prompt = Some("cli prompt".to_string());
|
||||
let run = ProviderRun::new(
|
||||
"run-1",
|
||||
base_request.messages.clone(),
|
||||
BASE_PROVIDER_PROFILE,
|
||||
ProviderRunLimits::default(),
|
||||
);
|
||||
let mut profiles = BTreeMap::new();
|
||||
profiles.insert(
|
||||
BASE_PROVIDER_PROFILE.to_string(),
|
||||
ProviderRunProfile::new(base_runtime.clone(), base_request),
|
||||
);
|
||||
profiles.insert(
|
||||
CLI_MONITOR_PROVIDER_PROFILE.to_string(),
|
||||
ProviderRunProfile::new(cli_runtime.clone(), cli_request),
|
||||
);
|
||||
let mut coordinator = ProviderRunCoordinator::new(run, profiles).unwrap();
|
||||
|
||||
let (_sender, control) = turn_control();
|
||||
let ProviderRunBlock::AwaitingDriver { work_id, .. } = coordinator
|
||||
.drive_until_blocked(control, |_| Ok(()))
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("expected base driver decision");
|
||||
};
|
||||
coordinator
|
||||
.run_mut()
|
||||
.continue_with_observation(
|
||||
&work_id,
|
||||
MessageContent::Text("command is still running".to_string()),
|
||||
CLI_MONITOR_PROVIDER_PROFILE,
|
||||
)
|
||||
.unwrap();
|
||||
let (_sender, control) = turn_control();
|
||||
assert!(matches!(
|
||||
coordinator
|
||||
.drive_until_blocked(control, |_| Ok(()))
|
||||
.await
|
||||
.unwrap(),
|
||||
ProviderRunBlock::AwaitingDriver { .. }
|
||||
));
|
||||
|
||||
let base_requests = base_runtime.requests();
|
||||
let cli_requests = cli_runtime.requests();
|
||||
assert_eq!(base_requests.len(), 1);
|
||||
assert_eq!(base_requests[0].model.as_str(), "base-model");
|
||||
assert_eq!(
|
||||
base_requests[0].system_prompt.as_deref(),
|
||||
Some("base prompt")
|
||||
);
|
||||
assert_eq!(cli_requests.len(), 1);
|
||||
assert_eq!(cli_requests[0].model.as_str(), "cli-model");
|
||||
assert_eq!(cli_requests[0].system_prompt.as_deref(), Some("cli prompt"));
|
||||
assert!(matches!(
|
||||
cli_requests[0].messages.last(),
|
||||
Some(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text(text),
|
||||
}) if text == "command is still running"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn correlated_parallel_lifecycle_commits_results_in_original_call_order() {
|
||||
let (mut coordinator, batch) = coordinator_awaiting_tools(vec![
|
||||
tool_call("first", "read_files"),
|
||||
tool_call("second", "read_files"),
|
||||
])
|
||||
.await;
|
||||
let conversation_id = AIConversationId::new();
|
||||
let first = ProviderToolExecutionRef::new(conversation_id, &batch.work_id, "first");
|
||||
let second = ProviderToolExecutionRef::new(conversation_id, &batch.work_id, "second");
|
||||
|
||||
assert_eq!(
|
||||
coordinator
|
||||
.apply_tool_lifecycle(
|
||||
&first,
|
||||
&ToolEvent::Started {
|
||||
call_id: "first".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
ProviderToolLifecycleOutcome::Pending
|
||||
);
|
||||
coordinator
|
||||
.apply_tool_lifecycle(
|
||||
&second,
|
||||
&ToolEvent::Started {
|
||||
call_id: "second".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
coordinator
|
||||
.apply_tool_lifecycle(
|
||||
&second,
|
||||
&tool_result("second", "second result", ToolResultStatus::Success),
|
||||
)
|
||||
.unwrap(),
|
||||
ProviderToolLifecycleOutcome::Pending
|
||||
);
|
||||
assert_eq!(coordinator.run().transcript().len(), 2);
|
||||
assert_eq!(
|
||||
coordinator
|
||||
.apply_tool_lifecycle(
|
||||
&first,
|
||||
&tool_result("first", "first result", ToolResultStatus::Success),
|
||||
)
|
||||
.unwrap(),
|
||||
ProviderToolLifecycleOutcome::BatchCommitted
|
||||
);
|
||||
|
||||
let MessageContent::MultiPart(parts) = &coordinator.run().transcript().last().unwrap().content
|
||||
else {
|
||||
panic!("expected atomic tool result message");
|
||||
};
|
||||
let ids = parts
|
||||
.iter()
|
||||
.map(|part| match part {
|
||||
ContentPart::ToolResult { tool_use_id, .. } => tool_use_id.as_str(),
|
||||
ContentPart::Text(_)
|
||||
| ContentPart::Reasoning { .. }
|
||||
| ContentPart::Image { .. }
|
||||
| ContentPart::ToolUse { .. } => panic!("expected only tool results"),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(ids, vec!["first", "second"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn correlated_lifecycle_rejects_wrong_run_epoch_call_and_duplicate_without_mutation() {
|
||||
let (mut coordinator, batch) = coordinator_awaiting_tools(vec![
|
||||
tool_call("first", "read_files"),
|
||||
tool_call("second", "read_files"),
|
||||
])
|
||||
.await;
|
||||
let conversation_id = AIConversationId::new();
|
||||
let first = ProviderToolExecutionRef::new(conversation_id, &batch.work_id, "first");
|
||||
let second = ProviderToolExecutionRef::new(conversation_id, &batch.work_id, "second");
|
||||
let initial = coordinator.run().clone();
|
||||
|
||||
let mut wrong_run = first.clone();
|
||||
wrong_run.run_id = ProviderRunId::new("wrong-run");
|
||||
assert!(matches!(
|
||||
coordinator
|
||||
.apply_tool_lifecycle(
|
||||
&wrong_run,
|
||||
&ToolEvent::Started {
|
||||
call_id: "first".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap_err(),
|
||||
ProviderRunCoordinatorError::Core(ProviderRunProtocolError::WorkMismatch { .. })
|
||||
));
|
||||
assert_eq!(coordinator.run(), &initial);
|
||||
|
||||
let mut stale = first.clone();
|
||||
stale.epoch = RunEpoch::new(stale.epoch.get() + 1);
|
||||
assert!(matches!(
|
||||
coordinator
|
||||
.apply_tool_lifecycle(
|
||||
&stale,
|
||||
&ToolEvent::Started {
|
||||
call_id: "first".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap_err(),
|
||||
ProviderRunCoordinatorError::Core(ProviderRunProtocolError::WorkMismatch { .. })
|
||||
));
|
||||
assert_eq!(coordinator.run(), &initial);
|
||||
|
||||
assert!(matches!(
|
||||
coordinator
|
||||
.apply_tool_lifecycle(
|
||||
&first,
|
||||
&ToolEvent::Started {
|
||||
call_id: "other".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap_err(),
|
||||
ProviderRunCoordinatorError::InvalidToolLifecycle(_)
|
||||
));
|
||||
assert_eq!(coordinator.run(), &initial);
|
||||
|
||||
coordinator
|
||||
.apply_tool_lifecycle(
|
||||
&second,
|
||||
&tool_result("second", "done", ToolResultStatus::Success),
|
||||
)
|
||||
.unwrap();
|
||||
let after_completion = coordinator.run().clone();
|
||||
assert!(matches!(
|
||||
coordinator
|
||||
.apply_tool_lifecycle(
|
||||
&second,
|
||||
&tool_result("second", "duplicate", ToolResultStatus::Success),
|
||||
)
|
||||
.unwrap_err(),
|
||||
ProviderRunCoordinatorError::Core(ProviderRunProtocolError::DuplicateToolUpdate { .. })
|
||||
));
|
||||
assert_eq!(coordinator.run(), &after_completion);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn permission_denial_is_the_only_correlated_terminal_result() {
|
||||
let (mut coordinator, batch) =
|
||||
coordinator_awaiting_tools(vec![tool_call("shell", "read_files")]).await;
|
||||
let execution_ref =
|
||||
ProviderToolExecutionRef::new(AIConversationId::new(), &batch.work_id, "shell");
|
||||
let request = PermissionRequest {
|
||||
id: "permission-shell".to_string(),
|
||||
call_id: "shell".to_string(),
|
||||
kind: PermissionKind::Execute,
|
||||
reason: Some("run a command".to_string()),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
coordinator
|
||||
.apply_tool_lifecycle(
|
||||
&execution_ref,
|
||||
&ToolEvent::PermissionRequested {
|
||||
request: request.clone(),
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
ProviderToolLifecycleOutcome::Pending
|
||||
);
|
||||
assert_eq!(
|
||||
coordinator
|
||||
.apply_tool_lifecycle(
|
||||
&execution_ref,
|
||||
&ToolEvent::PermissionResolved {
|
||||
request_id: request.id,
|
||||
call_id: request.call_id,
|
||||
decision: PermissionDecision::Denied {
|
||||
reason: Some("not allowed".to_string()),
|
||||
},
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
ProviderToolLifecycleOutcome::BatchCommitted
|
||||
);
|
||||
let committed = coordinator.run().clone();
|
||||
assert!(matches!(
|
||||
&committed.transcript().last().unwrap().content,
|
||||
MessageContent::MultiPart(parts)
|
||||
if matches!(parts.as_slice(), [ContentPart::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error: true,
|
||||
}] if tool_use_id == "shell" && content == "not allowed")
|
||||
));
|
||||
|
||||
assert!(coordinator
|
||||
.apply_tool_lifecycle(
|
||||
&execution_ref,
|
||||
&tool_result("shell", "duplicate denial", ToolResultStatus::Denied),
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(coordinator.run(), &committed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execution_failure_commits_one_correlated_error_result() {
|
||||
let (mut coordinator, batch) =
|
||||
coordinator_awaiting_tools(vec![tool_call("read", "read_files")]).await;
|
||||
let execution_ref =
|
||||
ProviderToolExecutionRef::new(AIConversationId::new(), &batch.work_id, "read");
|
||||
|
||||
coordinator
|
||||
.apply_tool_lifecycle(
|
||||
&execution_ref,
|
||||
&ToolEvent::Started {
|
||||
call_id: "read".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
coordinator
|
||||
.apply_tool_lifecycle(
|
||||
&execution_ref,
|
||||
&tool_result("read", "file missing", ToolResultStatus::Error),
|
||||
)
|
||||
.unwrap(),
|
||||
ProviderToolLifecycleOutcome::BatchCommitted
|
||||
);
|
||||
assert!(matches!(
|
||||
&coordinator.run().transcript().last().unwrap().content,
|
||||
MessageContent::MultiPart(parts)
|
||||
if matches!(parts.as_slice(), [ContentPart::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error: true,
|
||||
}] if tool_use_id == "read" && content == "file missing")
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inline_tool_batches_continue_without_leaving_the_coordinator() {
|
||||
let recall_turn = Ok(vec![
|
||||
started("request-recall"),
|
||||
Ok(AgentEvent::Tool {
|
||||
event: ToolEvent::Proposed {
|
||||
call: ToolCall {
|
||||
id: "recall-1".to_string(),
|
||||
name: galaxy_agent_core::RECALL_TOOL_HISTORY_NAME.to_string(),
|
||||
arguments: serde_json::json!({"search_query": "missing"}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
usage(10, 1),
|
||||
stopped(StopReason::Completed),
|
||||
]);
|
||||
let runtime = Arc::new(ScriptedRuntime::new(vec![recall_turn, answer_turn()]));
|
||||
let mut coordinator = coordinator(runtime.clone());
|
||||
let mut projections = Vec::new();
|
||||
let (_sender, control) = turn_control();
|
||||
|
||||
let block = coordinator
|
||||
.drive_until_blocked(control, collect_projection(&mut projections))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(block, ProviderRunBlock::AwaitingDriver { .. }));
|
||||
assert_eq!(runtime.requests().len(), 2);
|
||||
assert!(projections.iter().any(|projection| matches!(
|
||||
projection,
|
||||
ProviderRunProjection::ToolBatchReady { batch } if batch.is_complete()
|
||||
)));
|
||||
let requests = runtime.requests();
|
||||
assert_eq!(requests[1].messages.len(), 3);
|
||||
let MessageContent::MultiPart(results) = &requests[1].messages[2].content else {
|
||||
panic!("expected inline result batch");
|
||||
};
|
||||
assert!(matches!(
|
||||
results.as_slice(),
|
||||
[ContentPart::ToolResult {
|
||||
tool_use_id,
|
||||
is_error: false,
|
||||
..
|
||||
}] if tool_use_id == "recall-1"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recoverable_start_failure_retries_the_same_work_identity() {
|
||||
let mut recoverable = AgentError::new(AgentErrorKind::Transport, "temporary network error");
|
||||
recoverable.recoverable = true;
|
||||
let runtime = Arc::new(ScriptedRuntime::new(vec![Err(recoverable), answer_turn()]));
|
||||
let mut coordinator = coordinator(runtime.clone());
|
||||
let mut projections = Vec::new();
|
||||
let (_sender, control) = turn_control();
|
||||
|
||||
let block = coordinator
|
||||
.drive_until_blocked(control, collect_projection(&mut projections))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(block, ProviderRunBlock::AwaitingDriver { .. }));
|
||||
assert_eq!(runtime.requests().len(), 2);
|
||||
assert_eq!(coordinator.run().model_retries(), 1);
|
||||
let retry = projections
|
||||
.iter()
|
||||
.find_map(|projection| match projection {
|
||||
ProviderRunProjection::ModelRetry {
|
||||
work_id,
|
||||
retry_attempt,
|
||||
..
|
||||
} => Some((work_id.clone(), *retry_attempt)),
|
||||
ProviderRunProjection::ModelTurnStarted { .. }
|
||||
| ProviderRunProjection::ModelEvent { .. }
|
||||
| ProviderRunProjection::ToolBatchReady { .. } => None,
|
||||
})
|
||||
.expect("retry projection");
|
||||
let started = projections
|
||||
.iter()
|
||||
.find_map(|projection| match projection {
|
||||
ProviderRunProjection::ModelTurnStarted {
|
||||
work_id,
|
||||
retry_attempt: 1,
|
||||
..
|
||||
} => Some(work_id.clone()),
|
||||
ProviderRunProjection::ModelTurnStarted { .. }
|
||||
| ProviderRunProjection::ModelEvent { .. }
|
||||
| ProviderRunProjection::ModelRetry { .. }
|
||||
| ProviderRunProjection::ToolBatchReady { .. } => None,
|
||||
})
|
||||
.expect("retried model start");
|
||||
assert_eq!(retry.0, started);
|
||||
assert_eq!(retry.1, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_projection_failure_synthesizes_a_result_and_fails_the_run() {
|
||||
let runtime = Arc::new(ScriptedRuntime::new(vec![tool_turn()]));
|
||||
let mut coordinator = coordinator(runtime);
|
||||
let (_sender, control) = turn_control();
|
||||
|
||||
let block = coordinator
|
||||
.drive_until_blocked(control, |event| match event {
|
||||
ProviderRunProjection::ToolBatchReady { .. } => {
|
||||
Err("task projection disappeared".to_string())
|
||||
}
|
||||
ProviderRunProjection::ModelTurnStarted { .. }
|
||||
| ProviderRunProjection::ModelEvent { .. }
|
||||
| ProviderRunProjection::ModelRetry { .. } => Ok(()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let ProviderRunBlock::Done(ProviderRunOutcome::Failed(failure)) = block else {
|
||||
panic!("projection failure must terminate the run visibly");
|
||||
};
|
||||
assert_eq!(failure.kind, ProviderRunFailureKind::Projection);
|
||||
let MessageContent::MultiPart(results) =
|
||||
&coordinator.run().transcript().last().unwrap().content
|
||||
else {
|
||||
panic!("pending tool must receive a synthetic error result");
|
||||
};
|
||||
assert!(matches!(
|
||||
results.as_slice(),
|
||||
[ContentPart::ToolResult {
|
||||
tool_use_id,
|
||||
is_error: true,
|
||||
..
|
||||
}] if tool_use_id == "read-1"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_without_terminal_event_fails_instead_of_committing_partial_output() {
|
||||
let runtime = Arc::new(ScriptedRuntime::new(vec![Ok(vec![
|
||||
started("request-eof"),
|
||||
Ok(AgentEvent::TextDelta {
|
||||
text: "partial".to_string(),
|
||||
}),
|
||||
])]));
|
||||
let mut coordinator = coordinator(runtime);
|
||||
let (_sender, control) = turn_control();
|
||||
|
||||
let block = coordinator
|
||||
.drive_until_blocked(control, |_| Ok(()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let ProviderRunBlock::Done(ProviderRunOutcome::Failed(failure)) = block else {
|
||||
panic!("unexpected EOF must fail the run");
|
||||
};
|
||||
assert_eq!(failure.kind, ProviderRunFailureKind::ModelCall);
|
||||
assert_eq!(coordinator.run().transcript().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_cancellation_does_not_commit_partial_assistant_content() {
|
||||
let expected_transcript = request().messages;
|
||||
let runtime = Arc::new(ScriptedRuntime::new(vec![Ok(vec![
|
||||
started("request-cancelled"),
|
||||
Ok(AgentEvent::TextDelta {
|
||||
text: "partial".to_string(),
|
||||
}),
|
||||
stopped(StopReason::Cancelled),
|
||||
])]));
|
||||
let mut coordinator = coordinator(runtime);
|
||||
let (_sender, control) = turn_control();
|
||||
|
||||
let block = coordinator
|
||||
.drive_until_blocked(control, |_| Ok(()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
block,
|
||||
ProviderRunBlock::Done(ProviderRunOutcome::Cancelled {
|
||||
reason: "provider model call was cancelled".to_string(),
|
||||
})
|
||||
);
|
||||
assert_eq!(coordinator.run().transcript(), expected_transcript);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transcript_projector_emits_one_ui_stream_for_the_whole_run() {
|
||||
let runtime = Arc::new(ScriptedRuntime::new(vec![answer_turn()]));
|
||||
let mut coordinator = coordinator(runtime);
|
||||
let mut projector = ProviderRunResponseProjector::new(RuntimeResponseConfig {
|
||||
task_id: "task".to_string(),
|
||||
conversation_id: "conversation".to_string(),
|
||||
needs_create_task: true,
|
||||
user_query: Some("Inspect and fix the issue".to_string()),
|
||||
model_id: "test-model".to_string(),
|
||||
max_context_tokens: Some(100_000),
|
||||
capabilities: RuntimeCapabilities::provider(),
|
||||
empty_output_message: None,
|
||||
});
|
||||
let mut ui_events = Vec::new();
|
||||
let (_sender, control) = turn_control();
|
||||
|
||||
let block = coordinator
|
||||
.drive_until_blocked(control, |projection| {
|
||||
ui_events.extend(projector.project(projection)?);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let ProviderRunBlock::AwaitingDriver { work_id, .. } = block else {
|
||||
panic!("expected driver decision");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
count_response_events(&ui_events, ResponseEventKind::Init),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
count_response_events(&ui_events, ResponseEventKind::Finished),
|
||||
0
|
||||
);
|
||||
assert!(ui_events
|
||||
.iter()
|
||||
.any(|event| matches!(event.r#type, Some(response_event::Type::ClientActions(_)))));
|
||||
|
||||
coordinator.run_mut().complete(&work_id).unwrap();
|
||||
let (_sender, control) = turn_control();
|
||||
let ProviderRunBlock::Done(outcome) = coordinator
|
||||
.drive_until_blocked(control, |_| Ok(()))
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("expected terminal run");
|
||||
};
|
||||
ui_events.extend(projector.finish(&outcome).unwrap());
|
||||
assert_eq!(
|
||||
count_response_events(&ui_events, ResponseEventKind::Finished),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_projector_preserves_provider_failure_message() {
|
||||
let mut projector = ProviderRunResponseProjector::new(RuntimeResponseConfig {
|
||||
task_id: "task".to_string(),
|
||||
conversation_id: "conversation".to_string(),
|
||||
needs_create_task: false,
|
||||
user_query: None,
|
||||
model_id: "test-model".to_string(),
|
||||
max_context_tokens: Some(100_000),
|
||||
capabilities: RuntimeCapabilities::provider(),
|
||||
empty_output_message: None,
|
||||
});
|
||||
let events = projector
|
||||
.finish(&ProviderRunOutcome::Failed(ProviderRunFailure {
|
||||
kind: ProviderRunFailureKind::ModelCall,
|
||||
message: "upstream provider rejected the request".to_string(),
|
||||
source: None,
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let finished = events
|
||||
.iter()
|
||||
.find_map(|event| match &event.r#type {
|
||||
Some(response_event::Type::Finished(finished)) => Some(finished),
|
||||
Some(response_event::Type::Init(_))
|
||||
| Some(response_event::Type::ClientActions(_))
|
||||
| None => None,
|
||||
})
|
||||
.expect("terminal provider failure");
|
||||
assert!(matches!(
|
||||
&finished.reason,
|
||||
Some(response_event::stream_finished::Reason::InternalError(error))
|
||||
if error.message == "upstream provider rejected the request"
|
||||
));
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ResponseEventKind {
|
||||
Init,
|
||||
Finished,
|
||||
}
|
||||
|
||||
fn count_response_events(
|
||||
events: &[warp_multi_agent_api::ResponseEvent],
|
||||
kind: ResponseEventKind,
|
||||
) -> usize {
|
||||
events
|
||||
.iter()
|
||||
.filter(|event| match (&event.r#type, kind) {
|
||||
(Some(response_event::Type::Init(_)), ResponseEventKind::Init)
|
||||
| (Some(response_event::Type::Finished(_)), ResponseEventKind::Finished) => true,
|
||||
(Some(response_event::Type::ClientActions(_)), ResponseEventKind::Init)
|
||||
| (Some(response_event::Type::ClientActions(_)), ResponseEventKind::Finished)
|
||||
| (Some(response_event::Type::Init(_)), ResponseEventKind::Finished)
|
||||
| (Some(response_event::Type::Finished(_)), ResponseEventKind::Init)
|
||||
| (None, ResponseEventKind::Init)
|
||||
| (None, ResponseEventKind::Finished) => false,
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_runtime_is_rejected_before_any_turn_can_start() {
|
||||
struct SessionRuntime {
|
||||
descriptor: RuntimeDescriptor,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentRuntime for SessionRuntime {
|
||||
fn descriptor(&self) -> &RuntimeDescriptor {
|
||||
&self.descriptor
|
||||
}
|
||||
|
||||
async fn start_turn(
|
||||
&self,
|
||||
_request: TurnRequest,
|
||||
_control: TurnControl,
|
||||
) -> Result<AgentEventStream, AgentError> {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
let runtime = Arc::new(SessionRuntime {
|
||||
descriptor: RuntimeDescriptor {
|
||||
id: "session".to_string(),
|
||||
display_name: "Session runtime".to_string(),
|
||||
kind: RuntimeKind::Acp,
|
||||
capabilities: RuntimeCapabilities::session_runtime(),
|
||||
},
|
||||
});
|
||||
let error = ProviderRunCoordinator::from_request(
|
||||
"run-1",
|
||||
runtime,
|
||||
request(),
|
||||
Vec::new(),
|
||||
ProviderRunLimits::default(),
|
||||
)
|
||||
.err()
|
||||
.expect("session runtime must be rejected");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
ProviderRunCoordinatorError::InvalidRuntime(_)
|
||||
));
|
||||
}
|
||||
+206
-566
@@ -1,12 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::channel::oneshot;
|
||||
use futures::{FutureExt, StreamExt};
|
||||
use galaxy_agent_core::{
|
||||
turn_control, AgentError, AgentEvent, AgentRuntime, MessageContent, MessageRole, StopReason,
|
||||
ToolCall, ToolCallDecision, ToolEvent, ToolPolicy, ToolResult, TurnCommand, Usage,
|
||||
};
|
||||
use galaxy_agent_core::{AgentRuntime, ToolCall, TurnRequest};
|
||||
use galaxy_agent_rig::{
|
||||
AnthropicRuntime, AnthropicRuntimeConfig, ChatGPTSubscriptionRuntime,
|
||||
ChatGPTSubscriptionRuntimeConfig, GeminiRuntime, GeminiRuntimeConfig, OpenAICompatibleRuntime,
|
||||
@@ -16,597 +11,242 @@ use uuid::Uuid;
|
||||
use warp_multi_agent_api::ToolType;
|
||||
|
||||
use super::rig_request::{
|
||||
prepare_bedrock_rig_turn, prepare_rig_turn, MCPToolTarget, PreparedRigTurn,
|
||||
prepare_bedrock_rig_turn_for_mode, prepare_rig_turn, prepare_rig_turn_for_mode, MCPToolTarget,
|
||||
PreparedRigTurn, RigRequestMode,
|
||||
};
|
||||
use super::rig_tool::action_from_tool_call;
|
||||
use crate::ai::agent::api::{Event, RequestParams, ResponseStream, StreamEvent};
|
||||
use super::ProviderRunProfile;
|
||||
use crate::ai::agent::api::RequestParams;
|
||||
use crate::ai::agent::AIAgentAction;
|
||||
use crate::ai::bedrock::client::{BedrockClient, BedrockClientConfig};
|
||||
use crate::ai::bedrock::client::BedrockClient;
|
||||
use crate::ai::bedrock::convert::CachingConfig;
|
||||
use crate::ai::bedrock::external_config::ExternalBedrockConfig;
|
||||
use crate::ai::bedrock::response_translator::build_add_agent_output_message;
|
||||
use crate::ai::openai::client::OpenAIClientConfig;
|
||||
use crate::ai::provider::types::{ContentPart, ConversationMessage};
|
||||
use crate::ai::runtime::{RuntimeResponseConfig, RuntimeResponseTranslator};
|
||||
use crate::server::server_api::AIApiError;
|
||||
use crate::ai::provider::types::ConversationMessage;
|
||||
use crate::ai::runtime::RuntimeResponseConfig;
|
||||
use crate::settings::OpenAIProviderKind;
|
||||
|
||||
const MAX_INLINE_TOOL_CONTINUATIONS: usize = 3;
|
||||
const INLINE_TOOL_LOOP_MESSAGE: &str =
|
||||
"I couldn't continue because the model repeatedly searched prior tool history without making progress. Please retry with a more specific instruction.";
|
||||
pub(crate) struct PreparedProviderRun {
|
||||
pub(crate) base_profile: ProviderRunProfile,
|
||||
pub(crate) cli_monitor_profile: Option<ProviderRunProfile>,
|
||||
pub(crate) tool_result_archive: Vec<ConversationMessage>,
|
||||
pub(crate) messages_sent: Arc<std::sync::Mutex<Vec<ConversationMessage>>>,
|
||||
pub(crate) persistence_offset: usize,
|
||||
pub(crate) response_config: RuntimeResponseConfig,
|
||||
pub(crate) action_context: ProviderActionContext,
|
||||
}
|
||||
|
||||
pub(crate) fn rig_openai_response_stream(
|
||||
config: OpenAIClientConfig,
|
||||
params: RequestParams,
|
||||
supported_tools: Vec<ToolType>,
|
||||
supported_cli_agent_tools: Vec<ToolType>,
|
||||
cancellation_rx: oneshot::Receiver<()>,
|
||||
) -> ResponseStream {
|
||||
let skill_path_origin = params.session_context.skill_path_origin();
|
||||
let prepared = prepare_rig_turn(&config, params, supported_tools, supported_cli_agent_tools);
|
||||
let model_id = prepared.request.model.as_str().to_string();
|
||||
match config.kind {
|
||||
OpenAIProviderKind::OpenAI | OpenAIProviderKind::LiteLLM => {
|
||||
let runtime = OpenAICompatibleRuntime::new(OpenAICompatibleRuntimeConfig {
|
||||
base_url: config.base_url,
|
||||
api_key: config.api_key,
|
||||
model: model_id.clone(),
|
||||
max_output_tokens: config.max_output_tokens.map(u64::from),
|
||||
supports_system_messages: config.supports_system_messages,
|
||||
});
|
||||
rig_response_stream(
|
||||
runtime,
|
||||
prepared,
|
||||
skill_path_origin,
|
||||
config.max_input_tokens,
|
||||
"rig_openai_compatible",
|
||||
cancellation_rx,
|
||||
)
|
||||
}
|
||||
OpenAIProviderKind::ChatGPTSubscription => {
|
||||
let runtime = ChatGPTSubscriptionRuntime::new(ChatGPTSubscriptionRuntimeConfig {
|
||||
model: model_id,
|
||||
reasoning_effort: config.reasoning_effort,
|
||||
max_output_tokens: config.max_output_tokens.map(u64::from),
|
||||
auth_file: None,
|
||||
});
|
||||
rig_response_stream(
|
||||
runtime,
|
||||
prepared,
|
||||
skill_path_origin,
|
||||
config.max_input_tokens,
|
||||
"rig_chatgpt_subscription",
|
||||
cancellation_rx,
|
||||
)
|
||||
}
|
||||
OpenAIProviderKind::Anthropic => {
|
||||
let runtime = AnthropicRuntime::new(AnthropicRuntimeConfig {
|
||||
api_key: config.api_key.unwrap_or_default(),
|
||||
model: model_id,
|
||||
max_output_tokens: config.max_output_tokens.map(u64::from),
|
||||
});
|
||||
rig_response_stream(
|
||||
runtime,
|
||||
prepared,
|
||||
skill_path_origin,
|
||||
config.max_input_tokens,
|
||||
"rig_anthropic",
|
||||
cancellation_rx,
|
||||
)
|
||||
}
|
||||
OpenAIProviderKind::Gemini => {
|
||||
let runtime = GeminiRuntime::new(GeminiRuntimeConfig {
|
||||
api_key: config.api_key.unwrap_or_default(),
|
||||
model: model_id,
|
||||
max_output_tokens: config.max_output_tokens.map(u64::from),
|
||||
});
|
||||
rig_response_stream(
|
||||
runtime,
|
||||
prepared,
|
||||
skill_path_origin,
|
||||
config.max_input_tokens,
|
||||
"rig_gemini",
|
||||
cancellation_rx,
|
||||
)
|
||||
}
|
||||
OpenAIProviderKind::VertexAI => {
|
||||
let runtime = VertexAiRuntime::new(VertexAiRuntimeConfig {
|
||||
project_id: config.project_id.unwrap_or_default(),
|
||||
location: config.location.unwrap_or_else(|| "global".to_string()),
|
||||
model: model_id,
|
||||
max_output_tokens: config.max_output_tokens.map(u64::from),
|
||||
});
|
||||
rig_response_stream(
|
||||
runtime,
|
||||
prepared,
|
||||
skill_path_origin,
|
||||
config.max_input_tokens,
|
||||
"rig_vertex_ai",
|
||||
cancellation_rx,
|
||||
)
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub(crate) struct ProviderActionContext {
|
||||
task_id: String,
|
||||
skill_path_origin: ai::skills::SkillPathOrigin,
|
||||
mcp_tool_aliases: HashMap<String, MCPToolTarget>,
|
||||
}
|
||||
|
||||
impl ProviderActionContext {
|
||||
pub(crate) fn task_id(&self) -> &str {
|
||||
&self.task_id
|
||||
}
|
||||
|
||||
pub(crate) fn set_task_id(&mut self, task_id: impl Into<String>) {
|
||||
self.task_id = task_id.into();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn new_for_test(task_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
task_id: task_id.into(),
|
||||
skill_path_origin: ai::skills::SkillPathOrigin::Local,
|
||||
mcp_tool_aliases: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn action_from_tool_call(&self, call: &ToolCall) -> Result<AIAgentAction, String> {
|
||||
action_from_tool_call(
|
||||
&self.task_id,
|
||||
call,
|
||||
&self.skill_path_origin,
|
||||
&self.mcp_tool_aliases,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn rig_bedrock_response_stream(
|
||||
config: BedrockClientConfig,
|
||||
params: RequestParams,
|
||||
supported_tools: Vec<ToolType>,
|
||||
supported_cli_agent_tools: Vec<ToolType>,
|
||||
cancellation_rx: oneshot::Receiver<()>,
|
||||
) -> anyhow::Result<ResponseStream> {
|
||||
pub(crate) async fn prepare_provider_run(
|
||||
base_provider_config: crate::ai::provider::ProviderConfig,
|
||||
cli_provider_config: crate::ai::provider::ProviderConfig,
|
||||
mut params: RequestParams,
|
||||
) -> anyhow::Result<PreparedProviderRun> {
|
||||
let (supported_tools, supported_cli_agent_tools) =
|
||||
crate::ai::agent::api::prepare_direct_provider_params(&mut params);
|
||||
let skill_path_origin = params.session_context.skill_path_origin();
|
||||
let max_context_tokens = params.context_window_limit;
|
||||
let model = params.model.as_str().to_string();
|
||||
let max_output_tokens = Some(64_000);
|
||||
let cross_region_inference = config.cross_region_inference;
|
||||
let external_config = ExternalBedrockConfig::load();
|
||||
let prompt_caching = !external_config.disable_prompt_caching;
|
||||
let client = BedrockClient::from_config(config).await?;
|
||||
let runtime = client.rig_runtime(
|
||||
model.clone(),
|
||||
cross_region_inference,
|
||||
prompt_caching,
|
||||
max_output_tokens,
|
||||
)?;
|
||||
let prepared = prepare_bedrock_rig_turn(
|
||||
model,
|
||||
max_output_tokens,
|
||||
let mut cli_params = params.clone();
|
||||
cli_params.model = params.cli_agent_model.clone();
|
||||
|
||||
let (base_runtime, prepared) = prepare_provider_profile(
|
||||
base_provider_config,
|
||||
params,
|
||||
supported_tools,
|
||||
supported_cli_agent_tools,
|
||||
);
|
||||
supported_tools.clone(),
|
||||
supported_cli_agent_tools.clone(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let cli_monitor_profile = match cli_provider_config {
|
||||
crate::ai::provider::ProviderConfig::None => None,
|
||||
provider_config => {
|
||||
let (runtime, prepared) = prepare_provider_profile(
|
||||
provider_config,
|
||||
cli_params,
|
||||
supported_tools,
|
||||
supported_cli_agent_tools,
|
||||
Some(RigRequestMode::Cli),
|
||||
)
|
||||
.await?;
|
||||
Some(ProviderRunProfile::new(runtime, prepared.request))
|
||||
}
|
||||
};
|
||||
|
||||
Ok(rig_response_stream(
|
||||
runtime,
|
||||
prepared,
|
||||
skill_path_origin,
|
||||
max_context_tokens,
|
||||
"rig_bedrock",
|
||||
cancellation_rx,
|
||||
))
|
||||
}
|
||||
|
||||
fn rig_response_stream<R>(
|
||||
runtime: R,
|
||||
prepared: PreparedRigTurn,
|
||||
skill_path_origin: ai::skills::SkillPathOrigin,
|
||||
max_context_tokens: Option<u32>,
|
||||
stream_type: &'static str,
|
||||
cancellation_rx: oneshot::Receiver<()>,
|
||||
) -> ResponseStream
|
||||
where
|
||||
R: AgentRuntime + Send + Sync + 'static,
|
||||
{
|
||||
let runtime_capabilities = runtime.descriptor().capabilities.clone();
|
||||
let PreparedRigTurn {
|
||||
task_id,
|
||||
needs_create_task,
|
||||
user_query,
|
||||
request: turn_request,
|
||||
request,
|
||||
persistent_messages,
|
||||
tool_result_archive,
|
||||
messages_sent,
|
||||
mcp_tool_aliases,
|
||||
} = prepared;
|
||||
store_messages_sent(&messages_sent, &persistent_messages);
|
||||
|
||||
let conversation_id = turn_request.conversation_id.clone();
|
||||
let model_id = turn_request.model.as_str().to_string();
|
||||
let tool_policy = ToolPolicy::new(&turn_request.tools);
|
||||
let stream = async_stream::stream! {
|
||||
let cancel_future = cancellation_rx.fuse();
|
||||
futures::pin_mut!(cancel_future);
|
||||
|
||||
let conversation_id = conversation_id.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
let mut translator = RuntimeResponseTranslator::new(RuntimeResponseConfig {
|
||||
task_id: task_id.clone(),
|
||||
conversation_id,
|
||||
needs_create_task,
|
||||
user_query,
|
||||
model_id,
|
||||
max_context_tokens,
|
||||
capabilities: runtime_capabilities,
|
||||
empty_output_message: None,
|
||||
});
|
||||
let mut turn_request = turn_request;
|
||||
let mut cumulative_usage = Usage::default();
|
||||
let mut inline_continuation_count = 0;
|
||||
|
||||
'provider_turns: loop {
|
||||
let (control_sender, control) = turn_control();
|
||||
let start_future = runtime.start_turn(turn_request.clone(), control).fuse();
|
||||
futures::pin_mut!(start_future);
|
||||
|
||||
let mut agent_events = futures::select_biased! {
|
||||
_ = cancel_future => {
|
||||
let _ = control_sender.try_send(TurnCommand::Cancel);
|
||||
match start_future.await {
|
||||
Ok(stream) => stream,
|
||||
Err(error) => {
|
||||
yield Err(agent_error(error, stream_type));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
result = start_future => match result {
|
||||
Ok(stream) => stream,
|
||||
Err(error) => {
|
||||
yield Err(agent_error(error, stream_type));
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let mut full_text = String::new();
|
||||
let mut full_reasoning = String::new();
|
||||
let mut reasoning_signature = None;
|
||||
let mut proposed_tools = Vec::new();
|
||||
let mut assistant_history_index = None;
|
||||
let mut handled_inline_tool = false;
|
||||
let mut proposed_client_tool = false;
|
||||
|
||||
loop {
|
||||
let next_event = agent_events.next().fuse();
|
||||
futures::pin_mut!(next_event);
|
||||
futures::select_biased! {
|
||||
_ = cancel_future => {
|
||||
let _ = control_sender.try_send(TurnCommand::Cancel);
|
||||
}
|
||||
event = next_event => {
|
||||
let Some(event) = event else {
|
||||
yield Err(Arc::new(AIApiError::UnexpectedEof));
|
||||
return;
|
||||
};
|
||||
let event = match event {
|
||||
Ok(event) => event,
|
||||
Err(error) => {
|
||||
yield Err(agent_error(error, stream_type));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match event {
|
||||
AgentEvent::Tool {
|
||||
event: ToolEvent::Proposed { call },
|
||||
} => {
|
||||
proposed_tools.push(call.clone());
|
||||
sync_assistant_turn(
|
||||
&messages_sent,
|
||||
&full_reasoning,
|
||||
reasoning_signature.as_deref(),
|
||||
&full_text,
|
||||
&proposed_tools,
|
||||
&mut assistant_history_index,
|
||||
);
|
||||
let history = messages_sent
|
||||
.lock()
|
||||
.map(|sent| sent.clone())
|
||||
.unwrap_or_default();
|
||||
match tool_policy.decide(&call, &history, &tool_result_archive) {
|
||||
ToolCallDecision::Execute => {
|
||||
proposed_client_tool = true;
|
||||
match build_tool_proposed(
|
||||
&task_id,
|
||||
&call,
|
||||
&skill_path_origin,
|
||||
&mcp_tool_aliases,
|
||||
) {
|
||||
Ok(action) => yield Ok(StreamEvent::ToolProposed(action)),
|
||||
Err(message) => {
|
||||
yield Err(agent_error(AgentError::new(
|
||||
galaxy_agent_core::AgentErrorKind::Protocol,
|
||||
message,
|
||||
), stream_type));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolCallDecision::Inline(result) => {
|
||||
handled_inline_tool = true;
|
||||
append_tool_result(&messages_sent, result);
|
||||
}
|
||||
ToolCallDecision::Reject(result) => {
|
||||
log::warn!(
|
||||
"Rig model called unavailable tool '{}' (id={})",
|
||||
call.name,
|
||||
call.id
|
||||
);
|
||||
let error_display = format!(
|
||||
"Failed tool call: `{}`\n\n{}",
|
||||
call.name, result.content
|
||||
);
|
||||
append_tool_result(&messages_sent, result);
|
||||
let message_id = Uuid::new_v4().to_string();
|
||||
yield Ok(StreamEvent::Response(build_add_agent_output_message(
|
||||
&task_id,
|
||||
&message_id,
|
||||
&error_display,
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
AgentEvent::UsageUpdated { usage } => {
|
||||
accumulate_usage(&mut cumulative_usage, &usage);
|
||||
let response_events = match translator.translate(
|
||||
AgentEvent::UsageUpdated {
|
||||
usage: cumulative_usage.clone(),
|
||||
},
|
||||
) {
|
||||
Ok(response_events) => response_events,
|
||||
Err(message) => {
|
||||
yield Err(agent_error(AgentError::new(
|
||||
galaxy_agent_core::AgentErrorKind::Protocol,
|
||||
message,
|
||||
), stream_type));
|
||||
return;
|
||||
}
|
||||
};
|
||||
for response_event in response_events {
|
||||
yield Ok(StreamEvent::Response(response_event));
|
||||
}
|
||||
}
|
||||
AgentEvent::TurnStopped { mut reason } => {
|
||||
sync_assistant_turn(
|
||||
&messages_sent,
|
||||
&full_reasoning,
|
||||
reasoning_signature.as_deref(),
|
||||
&full_text,
|
||||
&proposed_tools,
|
||||
&mut assistant_history_index,
|
||||
);
|
||||
if reason == StopReason::Completed
|
||||
&& handled_inline_tool
|
||||
&& !proposed_client_tool
|
||||
{
|
||||
if inline_continuation_count < MAX_INLINE_TOOL_CONTINUATIONS {
|
||||
inline_continuation_count += 1;
|
||||
turn_request.messages = match copy_messages(&messages_sent) {
|
||||
Ok(messages) => messages,
|
||||
Err(()) => {
|
||||
yield Err(agent_error(AgentError::new(
|
||||
galaxy_agent_core::AgentErrorKind::Protocol,
|
||||
"could not access Rig conversation history for inline tool continuation",
|
||||
), stream_type));
|
||||
return;
|
||||
}
|
||||
};
|
||||
translator.begin_followup_turn();
|
||||
log::info!(
|
||||
"Continuing Rig provider turn after inline tool result ({inline_continuation_count}/{MAX_INLINE_TOOL_CONTINUATIONS})"
|
||||
);
|
||||
continue 'provider_turns;
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"Rig provider exceeded {MAX_INLINE_TOOL_CONTINUATIONS} inline tool continuations"
|
||||
);
|
||||
append_assistant_text(&messages_sent, INLINE_TOOL_LOOP_MESSAGE);
|
||||
let response_events = match translator.translate(
|
||||
AgentEvent::RuntimeNotice {
|
||||
message: INLINE_TOOL_LOOP_MESSAGE.to_string(),
|
||||
},
|
||||
) {
|
||||
Ok(response_events) => response_events,
|
||||
Err(message) => {
|
||||
yield Err(agent_error(AgentError::new(
|
||||
galaxy_agent_core::AgentErrorKind::Protocol,
|
||||
message,
|
||||
), stream_type));
|
||||
return;
|
||||
}
|
||||
};
|
||||
for response_event in response_events {
|
||||
yield Ok(StreamEvent::Response(response_event));
|
||||
}
|
||||
reason = StopReason::ToolLoopLimit;
|
||||
}
|
||||
let response_events = match translator
|
||||
.translate(AgentEvent::TurnStopped { reason })
|
||||
{
|
||||
Ok(response_events) => response_events,
|
||||
Err(message) => {
|
||||
yield Err(agent_error(AgentError::new(
|
||||
galaxy_agent_core::AgentErrorKind::Protocol,
|
||||
message,
|
||||
), stream_type));
|
||||
return;
|
||||
}
|
||||
};
|
||||
for response_event in response_events {
|
||||
yield Ok(StreamEvent::Response(response_event));
|
||||
}
|
||||
return;
|
||||
}
|
||||
event => {
|
||||
match &event {
|
||||
AgentEvent::TextDelta { text } => full_text.push_str(text),
|
||||
AgentEvent::ReasoningDelta { text } => {
|
||||
full_reasoning.push_str(text);
|
||||
}
|
||||
AgentEvent::ReasoningCompleted { text, signature } => {
|
||||
if !text.is_empty() {
|
||||
full_reasoning.clone_from(text);
|
||||
}
|
||||
reasoning_signature.clone_from(signature);
|
||||
}
|
||||
AgentEvent::TurnStarted { .. }
|
||||
| AgentEvent::Tool { .. }
|
||||
| AgentEvent::UsageUpdated { .. }
|
||||
| AgentEvent::RuntimeActivityUpdated { .. }
|
||||
| AgentEvent::ContextUsageUpdated { .. }
|
||||
| AgentEvent::UserInputAccepted { .. }
|
||||
| AgentEvent::RuntimeNotice { .. }
|
||||
| AgentEvent::TurnStopped { .. } => {}
|
||||
}
|
||||
let response_events = match translator.translate(event) {
|
||||
Ok(response_events) => response_events,
|
||||
Err(message) => {
|
||||
yield Err(agent_error(AgentError::new(
|
||||
galaxy_agent_core::AgentErrorKind::Protocol,
|
||||
message,
|
||||
), stream_type));
|
||||
return;
|
||||
}
|
||||
};
|
||||
for response_event in response_events {
|
||||
yield Ok(StreamEvent::Response(response_event));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let persistence_offset = request
|
||||
.messages
|
||||
.len()
|
||||
.saturating_sub(persistent_messages.len());
|
||||
let response_config = RuntimeResponseConfig {
|
||||
task_id: task_id.clone(),
|
||||
conversation_id: request
|
||||
.conversation_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| Uuid::new_v4().to_string()),
|
||||
needs_create_task,
|
||||
user_query,
|
||||
model_id: request.model.as_str().to_string(),
|
||||
max_context_tokens,
|
||||
capabilities: base_runtime.descriptor().capabilities.clone(),
|
||||
empty_output_message: None,
|
||||
};
|
||||
|
||||
Box::pin(stream)
|
||||
}
|
||||
|
||||
fn store_messages_sent(
|
||||
messages_sent: &std::sync::Arc<std::sync::Mutex<Vec<ConversationMessage>>>,
|
||||
messages: &[ConversationMessage],
|
||||
) {
|
||||
let Ok(mut sent) = messages_sent.lock() else {
|
||||
return;
|
||||
};
|
||||
*sent = messages.to_vec();
|
||||
}
|
||||
|
||||
fn copy_messages(
|
||||
messages_sent: &std::sync::Arc<std::sync::Mutex<Vec<ConversationMessage>>>,
|
||||
) -> Result<Vec<ConversationMessage>, ()> {
|
||||
messages_sent
|
||||
.lock()
|
||||
.map(|sent| sent.clone())
|
||||
.map_err(|_| ())
|
||||
}
|
||||
|
||||
fn append_tool_result(
|
||||
messages_sent: &std::sync::Arc<std::sync::Mutex<Vec<ConversationMessage>>>,
|
||||
result: ToolResult,
|
||||
) {
|
||||
let is_error = result.is_error();
|
||||
let message = ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::ToolResult {
|
||||
tool_use_id: result.call_id,
|
||||
content: result.content,
|
||||
is_error,
|
||||
Ok(PreparedProviderRun {
|
||||
base_profile: ProviderRunProfile::new(base_runtime, request),
|
||||
cli_monitor_profile,
|
||||
tool_result_archive,
|
||||
messages_sent,
|
||||
persistence_offset,
|
||||
response_config,
|
||||
action_context: ProviderActionContext {
|
||||
task_id,
|
||||
skill_path_origin,
|
||||
mcp_tool_aliases,
|
||||
},
|
||||
};
|
||||
if let Ok(mut sent) = messages_sent.lock() {
|
||||
sent.push(message);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn append_assistant_text(
|
||||
messages_sent: &std::sync::Arc<std::sync::Mutex<Vec<ConversationMessage>>>,
|
||||
text: &str,
|
||||
) {
|
||||
if let Ok(mut sent) = messages_sent.lock() {
|
||||
sent.push(ConversationMessage {
|
||||
role: MessageRole::Assistant,
|
||||
content: MessageContent::Text(text.to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn accumulate_usage(total: &mut Usage, usage: &Usage) {
|
||||
total.input_tokens = total.input_tokens.saturating_add(usage.input_tokens);
|
||||
total.output_tokens = total.output_tokens.saturating_add(usage.output_tokens);
|
||||
total.cached_input_tokens = total
|
||||
.cached_input_tokens
|
||||
.saturating_add(usage.cached_input_tokens);
|
||||
total.cache_creation_input_tokens = total
|
||||
.cache_creation_input_tokens
|
||||
.saturating_add(usage.cache_creation_input_tokens);
|
||||
}
|
||||
|
||||
fn sync_assistant_turn(
|
||||
messages_sent: &std::sync::Arc<std::sync::Mutex<Vec<ConversationMessage>>>,
|
||||
reasoning_text: &str,
|
||||
reasoning_signature: Option<&str>,
|
||||
text: &str,
|
||||
tool_calls: &[ToolCall],
|
||||
history_index: &mut Option<usize>,
|
||||
) {
|
||||
let has_reasoning = !reasoning_text.is_empty() || reasoning_signature.is_some();
|
||||
let mut parts = Vec::with_capacity(
|
||||
usize::from(has_reasoning) + usize::from(!text.is_empty()) + tool_calls.len(),
|
||||
);
|
||||
if has_reasoning {
|
||||
parts.push(ContentPart::Reasoning {
|
||||
text: reasoning_text.to_string(),
|
||||
signature: reasoning_signature.map(str::to_string),
|
||||
});
|
||||
}
|
||||
if !text.is_empty() {
|
||||
parts.push(ContentPart::Text(text.to_string()));
|
||||
}
|
||||
parts.extend(tool_calls.iter().map(|call| ContentPart::ToolUse {
|
||||
tool_use_id: call.id.clone(),
|
||||
name: call.name.clone(),
|
||||
input: call.arguments.clone(),
|
||||
}));
|
||||
if parts.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let content = if parts.len() == 1 {
|
||||
match parts.pop().unwrap() {
|
||||
ContentPart::Text(text) => MessageContent::Text(text),
|
||||
ContentPart::ToolUse {
|
||||
tool_use_id,
|
||||
name,
|
||||
input,
|
||||
} => MessageContent::ToolUse {
|
||||
tool_use_id,
|
||||
name,
|
||||
input,
|
||||
},
|
||||
reasoning @ ContentPart::Reasoning { .. } => MessageContent::MultiPart(vec![reasoning]),
|
||||
ContentPart::Image { .. } | ContentPart::ToolResult { .. } => unreachable!(),
|
||||
async fn prepare_provider_profile(
|
||||
provider_config: crate::ai::provider::ProviderConfig,
|
||||
params: RequestParams,
|
||||
supported_tools: Vec<ToolType>,
|
||||
supported_cli_agent_tools: Vec<ToolType>,
|
||||
mode: Option<RigRequestMode>,
|
||||
) -> anyhow::Result<(Arc<dyn AgentRuntime>, PreparedRigTurn)> {
|
||||
let model = params.model.as_str().to_string();
|
||||
let prepared = match &provider_config {
|
||||
crate::ai::provider::ProviderConfig::OpenAI(config) => match mode {
|
||||
Some(mode) => prepare_rig_turn_for_mode(
|
||||
config,
|
||||
params,
|
||||
supported_tools,
|
||||
supported_cli_agent_tools,
|
||||
mode,
|
||||
),
|
||||
None => prepare_rig_turn(config, params, supported_tools, supported_cli_agent_tools),
|
||||
},
|
||||
crate::ai::provider::ProviderConfig::Bedrock(_) => prepare_bedrock_rig_turn_for_mode(
|
||||
model,
|
||||
Some(64_000),
|
||||
params,
|
||||
supported_tools,
|
||||
supported_cli_agent_tools,
|
||||
mode,
|
||||
),
|
||||
crate::ai::provider::ProviderConfig::None => {
|
||||
anyhow::bail!(
|
||||
"No AI runtime configured. Enable an agent runtime or model provider in settings."
|
||||
);
|
||||
}
|
||||
} else {
|
||||
MessageContent::MultiPart(parts)
|
||||
};
|
||||
let message = ConversationMessage {
|
||||
role: MessageRole::Assistant,
|
||||
content,
|
||||
};
|
||||
let runtime = provider_runtime_for_request(provider_config, &prepared.request).await?;
|
||||
Ok((runtime, prepared))
|
||||
}
|
||||
|
||||
let Ok(mut sent) = messages_sent.lock() else {
|
||||
return;
|
||||
};
|
||||
if let Some(index) = *history_index {
|
||||
if index < sent.len() {
|
||||
sent[index] = message;
|
||||
return;
|
||||
/// Rebuilds a one-turn provider transport from current settings and a persisted request.
|
||||
/// Credentials remain in the live provider config and never enter the run snapshot.
|
||||
pub(crate) async fn provider_runtime_for_request(
|
||||
provider_config: crate::ai::provider::ProviderConfig,
|
||||
request: &TurnRequest,
|
||||
) -> anyhow::Result<Arc<dyn AgentRuntime>> {
|
||||
let model = request.model.as_str().to_string();
|
||||
let runtime: Arc<dyn AgentRuntime> = match provider_config {
|
||||
crate::ai::provider::ProviderConfig::OpenAI(config) => match config.kind {
|
||||
OpenAIProviderKind::OpenAI | OpenAIProviderKind::LiteLLM => Arc::new(
|
||||
OpenAICompatibleRuntime::new(OpenAICompatibleRuntimeConfig {
|
||||
base_url: config.base_url,
|
||||
api_key: config.api_key,
|
||||
model,
|
||||
max_output_tokens: config.max_output_tokens.map(u64::from),
|
||||
supports_system_messages: config.supports_system_messages,
|
||||
}),
|
||||
),
|
||||
OpenAIProviderKind::ChatGPTSubscription => Arc::new(ChatGPTSubscriptionRuntime::new(
|
||||
ChatGPTSubscriptionRuntimeConfig {
|
||||
model,
|
||||
reasoning_effort: config.reasoning_effort,
|
||||
max_output_tokens: config.max_output_tokens.map(u64::from),
|
||||
auth_file: None,
|
||||
},
|
||||
)),
|
||||
OpenAIProviderKind::Anthropic => {
|
||||
Arc::new(AnthropicRuntime::new(AnthropicRuntimeConfig {
|
||||
api_key: config.api_key.unwrap_or_default(),
|
||||
model,
|
||||
max_output_tokens: config.max_output_tokens.map(u64::from),
|
||||
}))
|
||||
}
|
||||
OpenAIProviderKind::Gemini => Arc::new(GeminiRuntime::new(GeminiRuntimeConfig {
|
||||
api_key: config.api_key.unwrap_or_default(),
|
||||
model,
|
||||
max_output_tokens: config.max_output_tokens.map(u64::from),
|
||||
})),
|
||||
OpenAIProviderKind::VertexAI => Arc::new(VertexAiRuntime::new(VertexAiRuntimeConfig {
|
||||
project_id: config.project_id.unwrap_or_default(),
|
||||
location: config.location.unwrap_or_else(|| "global".to_string()),
|
||||
model,
|
||||
max_output_tokens: config.max_output_tokens.map(u64::from),
|
||||
})),
|
||||
},
|
||||
crate::ai::provider::ProviderConfig::Bedrock(config) => {
|
||||
let max_output_tokens = Some(64_000);
|
||||
let cross_region_inference = config.cross_region_inference;
|
||||
let caching_config =
|
||||
CachingConfig::from_external_config(&ExternalBedrockConfig::load());
|
||||
let client = BedrockClient::from_config(config).await?;
|
||||
Arc::new(client.agent_runtime(
|
||||
model,
|
||||
cross_region_inference,
|
||||
max_output_tokens,
|
||||
caching_config,
|
||||
)?)
|
||||
}
|
||||
}
|
||||
*history_index = Some(sent.len());
|
||||
sent.push(message);
|
||||
}
|
||||
|
||||
fn build_tool_proposed(
|
||||
task_id: &str,
|
||||
call: &ToolCall,
|
||||
skill_path_origin: &ai::skills::SkillPathOrigin,
|
||||
mcp_tool_aliases: &HashMap<String, MCPToolTarget>,
|
||||
) -> Result<AIAgentAction, String> {
|
||||
action_from_tool_call(task_id, call, skill_path_origin, mcp_tool_aliases)
|
||||
}
|
||||
|
||||
fn agent_error(error: AgentError, stream_type: &'static str) -> Arc<AIApiError> {
|
||||
Arc::new(
|
||||
AIApiError::Stream {
|
||||
stream_type,
|
||||
source: anyhow::anyhow!(error),
|
||||
crate::ai::provider::ProviderConfig::None => {
|
||||
anyhow::bail!(
|
||||
"No AI runtime configured. Enable an agent runtime or model provider in settings."
|
||||
);
|
||||
}
|
||||
.into_quota_limit_if_provider_budget_exhausted(),
|
||||
)
|
||||
};
|
||||
Ok(runtime)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "rig_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -33,8 +33,8 @@ pub(crate) struct PreparedRigTurn {
|
||||
pub mcp_tool_aliases: HashMap<String, MCPToolTarget>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(super) struct MCPToolTarget {
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub(crate) struct MCPToolTarget {
|
||||
pub server_id: Option<Uuid>,
|
||||
pub name: String,
|
||||
}
|
||||
@@ -52,6 +52,25 @@ pub(crate) fn prepare_rig_turn(
|
||||
params,
|
||||
supported_tools,
|
||||
supported_cli_agent_tools,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_rig_turn_for_mode(
|
||||
config: &OpenAIClientConfig,
|
||||
params: RequestParams,
|
||||
supported_tools: Vec<ToolType>,
|
||||
supported_cli_agent_tools: Vec<ToolType>,
|
||||
mode: RigRequestMode,
|
||||
) -> PreparedRigTurn {
|
||||
prepare_rig_turn_for_provider(
|
||||
config.model.clone(),
|
||||
config.max_output_tokens.map(u64::from),
|
||||
RigRequestSanitizer::OpenAICompatible,
|
||||
params,
|
||||
supported_tools,
|
||||
supported_cli_agent_tools,
|
||||
Some(mode),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -61,6 +80,24 @@ pub(crate) fn prepare_bedrock_rig_turn(
|
||||
params: RequestParams,
|
||||
supported_tools: Vec<ToolType>,
|
||||
supported_cli_agent_tools: Vec<ToolType>,
|
||||
) -> PreparedRigTurn {
|
||||
prepare_bedrock_rig_turn_for_mode(
|
||||
model,
|
||||
max_output_tokens,
|
||||
params,
|
||||
supported_tools,
|
||||
supported_cli_agent_tools,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_bedrock_rig_turn_for_mode(
|
||||
model: String,
|
||||
max_output_tokens: Option<u64>,
|
||||
params: RequestParams,
|
||||
supported_tools: Vec<ToolType>,
|
||||
supported_cli_agent_tools: Vec<ToolType>,
|
||||
mode: Option<RigRequestMode>,
|
||||
) -> PreparedRigTurn {
|
||||
prepare_rig_turn_for_provider(
|
||||
Some(model),
|
||||
@@ -69,6 +106,7 @@ pub(crate) fn prepare_bedrock_rig_turn(
|
||||
params,
|
||||
supported_tools,
|
||||
supported_cli_agent_tools,
|
||||
mode,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -85,6 +123,7 @@ fn prepare_rig_turn_for_provider(
|
||||
params: RequestParams,
|
||||
supported_tools: Vec<ToolType>,
|
||||
supported_cli_agent_tools: Vec<ToolType>,
|
||||
mode_override: Option<RigRequestMode>,
|
||||
) -> PreparedRigTurn {
|
||||
let RequestParams {
|
||||
input,
|
||||
@@ -107,7 +146,7 @@ fn prepare_rig_turn_for_provider(
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
let needs_create_task = tasks.is_empty();
|
||||
let user_query = input.iter().find_map(input_user_query);
|
||||
let mode = request_mode(&input);
|
||||
let mode = mode_override.unwrap_or_else(|| request_mode(&input));
|
||||
let available_tools = match mode {
|
||||
RigRequestMode::Cli => supported_cli_agent_tools,
|
||||
RigRequestMode::CompletedCommandAssessment => Vec::new(),
|
||||
@@ -119,11 +158,9 @@ fn prepare_rig_turn_for_provider(
|
||||
tool_definitions(&available_tools, mcp_context.as_ref());
|
||||
match mode {
|
||||
RigRequestMode::Cli => {
|
||||
// History recall cannot advance a running command and is handled inline by the Rig
|
||||
// adapter (without producing a client action that can trigger another turn). Keeping it
|
||||
// in the CLI tool list lets the model spend its entire monitor turn recalling the prior
|
||||
// snapshot instead of scheduling `read_shell_command_output`, so make polling the only
|
||||
// way to inspect the active command here.
|
||||
// History recall cannot advance a running command. Keeping it in the CLI tool list lets
|
||||
// the model spend its monitor turn recalling a prior snapshot instead of scheduling
|
||||
// `read_shell_command_output`, so make polling the only inspection path here.
|
||||
tools.retain(|tool| tool.name != "recall_tool_history");
|
||||
}
|
||||
RigRequestMode::CompletedCommandAssessment => {
|
||||
@@ -435,7 +472,7 @@ fn input_user_query(input: &AIAgentInput) -> Option<String> {
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum RigRequestMode {
|
||||
pub(crate) enum RigRequestMode {
|
||||
Normal,
|
||||
Plan,
|
||||
Orchestrate,
|
||||
|
||||
@@ -7,7 +7,10 @@ use galaxy_agent_core::{ContentPart, MessageContent, MessageRole, ToolResult, To
|
||||
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
|
||||
use warp_multi_agent_api::ToolType;
|
||||
|
||||
use super::{input_messages, prepare_bedrock_rig_turn, prepare_rig_turn, tool_definitions};
|
||||
use super::{
|
||||
input_messages, prepare_bedrock_rig_turn, prepare_rig_turn, prepare_rig_turn_for_mode,
|
||||
tool_definitions, RigRequestMode,
|
||||
};
|
||||
use crate::ai::agent::api::RequestParams;
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::{
|
||||
@@ -344,6 +347,38 @@ fn completed_command_assessment_uses_root_history_without_tools_or_monitor_instr
|
||||
assert_eq!(prepared.request.messages, prepared.persistent_messages);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_cli_profile_uses_monitor_prompt_and_tools_for_an_initial_query() {
|
||||
let mut params = RequestParams::new_for_test();
|
||||
params.input = vec![user_query("Run the checks")];
|
||||
|
||||
let prepared = prepare_rig_turn_for_mode(
|
||||
&config(),
|
||||
params,
|
||||
vec![ToolType::RunShellCommand],
|
||||
vec![ToolType::ReadShellCommandOutput],
|
||||
RigRequestMode::Cli,
|
||||
);
|
||||
let prompt = prepared.request.system_prompt.expect("system prompt");
|
||||
|
||||
assert!(prompt.contains("## Running Command Monitor"));
|
||||
assert!(prepared
|
||||
.request
|
||||
.tools
|
||||
.iter()
|
||||
.any(|tool| tool.name == "read_shell_command_output"));
|
||||
assert!(!prepared
|
||||
.request
|
||||
.tools
|
||||
.iter()
|
||||
.any(|tool| tool.name == "run_shell_command"));
|
||||
assert!(!prepared
|
||||
.request
|
||||
.tools
|
||||
.iter()
|
||||
.any(|tool| tool.name == "recall_tool_history"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lrc_snapshot_follow_up_uses_the_cli_monitor_prompt_and_tools() {
|
||||
let block_id: galaxy_terminal::model::BlockId = "precmd-lrc-test".to_string().into();
|
||||
|
||||
@@ -1,468 +0,0 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use ai::skills::SkillPathOrigin;
|
||||
use async_trait::async_trait;
|
||||
use futures::channel::oneshot;
|
||||
use futures::StreamExt;
|
||||
use galaxy_agent_core::{
|
||||
AgentError, AgentErrorKind, AgentEvent, AgentEventStream, AgentRuntime, ContentPart,
|
||||
ConversationMessage, MessageContent, MessageRole, RuntimeCapabilities, RuntimeDescriptor,
|
||||
RuntimeKind, StopReason, ToolCall, ToolDefinition, ToolEvent, ToolResult, ToolResultStatus,
|
||||
TurnControl, TurnRequest, Usage, RECALL_TOOL_HISTORY_NAME,
|
||||
};
|
||||
use warp_multi_agent_api::{client_action, message, response_event};
|
||||
|
||||
use super::{
|
||||
append_tool_result, build_tool_proposed, rig_response_stream, sync_assistant_turn,
|
||||
PreparedRigTurn, INLINE_TOOL_LOOP_MESSAGE, MAX_INLINE_TOOL_CONTINUATIONS,
|
||||
};
|
||||
use crate::ai::agent::api::StreamEvent;
|
||||
|
||||
#[test]
|
||||
fn tool_proposal_matches_the_domain_permission_contract() {
|
||||
let action = build_tool_proposed(
|
||||
"task",
|
||||
&ToolCall {
|
||||
id: "call-1".to_string(),
|
||||
name: "run_shell_command".to_string(),
|
||||
arguments: serde_json::json!({
|
||||
"command": "cargo test",
|
||||
"is_read_only": true
|
||||
}),
|
||||
},
|
||||
&SkillPathOrigin::Local,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(action.id.to_string(), "call-1");
|
||||
assert!(matches!(
|
||||
action.action,
|
||||
crate::ai::agent::AIAgentActionType::RequestCommandOutput {
|
||||
command,
|
||||
is_read_only: Some(true),
|
||||
..
|
||||
} if command == "cargo test"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_tool_proposal_routes_directly_to_the_mcp_executor_contract() {
|
||||
let action = build_tool_proposed(
|
||||
"task",
|
||||
&ToolCall {
|
||||
id: "call-mcp".to_string(),
|
||||
name: "mcp__11111111-1111-4111-8111-111111111111__read_file".to_string(),
|
||||
arguments: serde_json::json!({"path": "Cargo.toml"}),
|
||||
},
|
||||
&SkillPathOrigin::Local,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
action.action,
|
||||
crate::ai::agent::AIAgentActionType::CallMCPTool {
|
||||
server_id: Some(server_id),
|
||||
name,
|
||||
..
|
||||
} if server_id.to_string() == "11111111-1111-4111-8111-111111111111"
|
||||
&& name == "read_file"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_history_is_updated_before_fast_tool_execution_can_continue() {
|
||||
let messages = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut history_index = None;
|
||||
let first_call = ToolCall {
|
||||
id: "call-1".to_string(),
|
||||
name: "read_files".to_string(),
|
||||
arguments: serde_json::json!({"files": ["Cargo.toml"]}),
|
||||
};
|
||||
let second_call = ToolCall {
|
||||
id: "call-2".to_string(),
|
||||
name: "grep".to_string(),
|
||||
arguments: serde_json::json!({"queries": ["rig"]}),
|
||||
};
|
||||
|
||||
sync_assistant_turn(
|
||||
&messages,
|
||||
"",
|
||||
None,
|
||||
"I'll inspect both.",
|
||||
std::slice::from_ref(&first_call),
|
||||
&mut history_index,
|
||||
);
|
||||
sync_assistant_turn(
|
||||
&messages,
|
||||
"",
|
||||
None,
|
||||
"I'll inspect both.",
|
||||
&[first_call, second_call],
|
||||
&mut history_index,
|
||||
);
|
||||
|
||||
let messages = messages.lock().unwrap();
|
||||
assert_eq!(messages.len(), 1);
|
||||
let MessageContent::MultiPart(parts) = &messages[0].content else {
|
||||
panic!("expected combined assistant content");
|
||||
};
|
||||
assert_eq!(parts.len(), 3);
|
||||
assert!(
|
||||
matches!(&parts[0], galaxy_agent_core::ContentPart::Text(text) if text == "I'll inspect both.")
|
||||
);
|
||||
assert!(
|
||||
matches!(&parts[1], galaxy_agent_core::ContentPart::ToolUse { tool_use_id, .. } if tool_use_id == "call-1")
|
||||
);
|
||||
assert!(
|
||||
matches!(&parts[2], galaxy_agent_core::ContentPart::ToolUse { tool_use_id, .. } if tool_use_id == "call-2")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_reasoning_is_persisted_before_the_tool_call() {
|
||||
let messages = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut history_index = None;
|
||||
let call = ToolCall {
|
||||
id: "call-1".to_string(),
|
||||
name: "read_files".to_string(),
|
||||
arguments: serde_json::json!({"files": ["Cargo.toml"]}),
|
||||
};
|
||||
|
||||
sync_assistant_turn(
|
||||
&messages,
|
||||
"I should inspect the manifest.",
|
||||
Some("signed-reasoning"),
|
||||
"",
|
||||
std::slice::from_ref(&call),
|
||||
&mut history_index,
|
||||
);
|
||||
|
||||
let messages = messages.lock().unwrap();
|
||||
let MessageContent::MultiPart(parts) = &messages[0].content else {
|
||||
panic!("expected reasoning and tool call parts");
|
||||
};
|
||||
assert!(matches!(
|
||||
parts.as_slice(),
|
||||
[
|
||||
ContentPart::Reasoning {
|
||||
text,
|
||||
signature: Some(signature),
|
||||
},
|
||||
ContentPart::ToolUse { tool_use_id, .. },
|
||||
] if text == "I should inspect the manifest."
|
||||
&& signature == "signed-reasoning"
|
||||
&& tool_use_id == "call-1"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_tool_result_is_paired_with_the_assistant_call_in_history() {
|
||||
let messages = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut history_index = None;
|
||||
let call = ToolCall {
|
||||
id: "call-unknown".to_string(),
|
||||
name: "invented_tool".to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
};
|
||||
sync_assistant_turn(
|
||||
&messages,
|
||||
"",
|
||||
None,
|
||||
"",
|
||||
std::slice::from_ref(&call),
|
||||
&mut history_index,
|
||||
);
|
||||
append_tool_result(
|
||||
&messages,
|
||||
ToolResult {
|
||||
call_id: call.id.clone(),
|
||||
content: "tool is unavailable".to_string(),
|
||||
status: ToolResultStatus::Error,
|
||||
},
|
||||
);
|
||||
|
||||
let messages = messages.lock().unwrap();
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert_eq!(messages[0].role, MessageRole::Assistant);
|
||||
assert!(matches!(
|
||||
&messages[0].content,
|
||||
MessageContent::ToolUse {
|
||||
tool_use_id,
|
||||
name,
|
||||
..
|
||||
} if tool_use_id == "call-unknown" && name == "invented_tool"
|
||||
));
|
||||
assert_eq!(messages[1].role, MessageRole::User);
|
||||
assert!(matches!(
|
||||
&messages[1].content,
|
||||
MessageContent::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error: true,
|
||||
} if tool_use_id == "call-unknown" && content == "tool is unavailable"
|
||||
));
|
||||
}
|
||||
|
||||
struct ScriptedRuntime {
|
||||
descriptor: RuntimeDescriptor,
|
||||
turns: Mutex<VecDeque<Vec<AgentEvent>>>,
|
||||
requests: Arc<Mutex<Vec<TurnRequest>>>,
|
||||
}
|
||||
|
||||
impl ScriptedRuntime {
|
||||
fn new(turns: Vec<Vec<AgentEvent>>, requests: Arc<Mutex<Vec<TurnRequest>>>) -> Self {
|
||||
Self {
|
||||
descriptor: RuntimeDescriptor {
|
||||
id: "scripted-provider".to_string(),
|
||||
display_name: "Scripted provider".to_string(),
|
||||
kind: RuntimeKind::Provider,
|
||||
capabilities: RuntimeCapabilities::provider(),
|
||||
},
|
||||
turns: Mutex::new(turns.into()),
|
||||
requests,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentRuntime for ScriptedRuntime {
|
||||
fn descriptor(&self) -> &RuntimeDescriptor {
|
||||
&self.descriptor
|
||||
}
|
||||
|
||||
async fn start_turn(
|
||||
&self,
|
||||
request: TurnRequest,
|
||||
_control: TurnControl,
|
||||
) -> Result<AgentEventStream, AgentError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
let events = self.turns.lock().unwrap().pop_front().ok_or_else(|| {
|
||||
AgentError::new(
|
||||
AgentErrorKind::Protocol,
|
||||
"scripted provider ran out of turns",
|
||||
)
|
||||
})?;
|
||||
Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok))))
|
||||
}
|
||||
}
|
||||
|
||||
fn recall_turn(index: usize) -> Vec<AgentEvent> {
|
||||
vec![
|
||||
AgentEvent::TurnStarted {
|
||||
runtime_request_id: format!("request-{index}"),
|
||||
},
|
||||
AgentEvent::Tool {
|
||||
event: ToolEvent::Proposed {
|
||||
call: ToolCall {
|
||||
id: format!("recall-{index}"),
|
||||
name: RECALL_TOOL_HISTORY_NAME.to_string(),
|
||||
arguments: serde_json::json!({"search_query": "missing"}),
|
||||
},
|
||||
},
|
||||
},
|
||||
AgentEvent::UsageUpdated {
|
||||
usage: Usage {
|
||||
input_tokens: 10,
|
||||
output_tokens: 1,
|
||||
..Usage::default()
|
||||
},
|
||||
},
|
||||
AgentEvent::TurnStopped {
|
||||
reason: StopReason::Completed,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn answer_turn() -> Vec<AgentEvent> {
|
||||
vec![
|
||||
AgentEvent::TurnStarted {
|
||||
runtime_request_id: "request-answer".to_string(),
|
||||
},
|
||||
AgentEvent::TextDelta {
|
||||
text: "Continuing with the answer.".to_string(),
|
||||
},
|
||||
AgentEvent::UsageUpdated {
|
||||
usage: Usage {
|
||||
input_tokens: 20,
|
||||
output_tokens: 3,
|
||||
..Usage::default()
|
||||
},
|
||||
},
|
||||
AgentEvent::TurnStopped {
|
||||
reason: StopReason::Completed,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn prepared_turn(messages_sent: Arc<Mutex<Vec<ConversationMessage>>>) -> PreparedRigTurn {
|
||||
let initial_messages = vec![ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text("Inspect the issue.".to_string()),
|
||||
}];
|
||||
let mut request = TurnRequest::new("test-model", initial_messages.clone());
|
||||
request.conversation_id = Some("conversation".to_string());
|
||||
request.tools = vec![ToolDefinition {
|
||||
name: RECALL_TOOL_HISTORY_NAME.to_string(),
|
||||
description: "Recall prior tool output".to_string(),
|
||||
input_schema: serde_json::json!({"type": "object"}),
|
||||
}];
|
||||
PreparedRigTurn {
|
||||
task_id: "task".to_string(),
|
||||
needs_create_task: false,
|
||||
user_query: None,
|
||||
request,
|
||||
persistent_messages: initial_messages,
|
||||
tool_result_archive: Vec::new(),
|
||||
messages_sent,
|
||||
mcp_tool_aliases: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_scripted_turn(
|
||||
turns: Vec<Vec<AgentEvent>>,
|
||||
) -> (Vec<StreamEvent>, Vec<TurnRequest>, Vec<ConversationMessage>) {
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let messages_sent = Arc::new(Mutex::new(Vec::new()));
|
||||
let runtime = ScriptedRuntime::new(turns, requests.clone());
|
||||
let (cancel_tx, cancellation_rx) = oneshot::channel();
|
||||
let events = rig_response_stream(
|
||||
runtime,
|
||||
prepared_turn(messages_sent.clone()),
|
||||
SkillPathOrigin::Local,
|
||||
Some(100_000),
|
||||
"scripted",
|
||||
cancellation_rx,
|
||||
)
|
||||
.collect::<Vec<_>>()
|
||||
.await
|
||||
.into_iter()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.expect("scripted response should succeed");
|
||||
drop(cancel_tx);
|
||||
let requests = requests.lock().unwrap().clone();
|
||||
let messages_sent = messages_sent.lock().unwrap().clone();
|
||||
(events, requests, messages_sent)
|
||||
}
|
||||
|
||||
fn agent_output_texts(events: &[StreamEvent]) -> Vec<&str> {
|
||||
let mut texts = Vec::new();
|
||||
for event in events {
|
||||
let StreamEvent::Response(response) = event else {
|
||||
continue;
|
||||
};
|
||||
let Some(response_event::Type::ClientActions(actions)) = &response.r#type else {
|
||||
continue;
|
||||
};
|
||||
for action in &actions.actions {
|
||||
let Some(client_action::Action::AddMessagesToTask(add)) = &action.action else {
|
||||
continue;
|
||||
};
|
||||
for message in &add.messages {
|
||||
if let Some(message::Message::AgentOutput(output)) = &message.message {
|
||||
texts.push(output.text.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
texts
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inline_recall_starts_a_followup_provider_turn_with_the_paired_result() {
|
||||
let (events, requests, messages_sent) =
|
||||
run_scripted_turn(vec![recall_turn(1), answer_turn()]).await;
|
||||
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests[1].messages.len(), 3);
|
||||
assert!(matches!(
|
||||
&requests[1].messages[1].content,
|
||||
MessageContent::ToolUse {
|
||||
tool_use_id,
|
||||
name,
|
||||
..
|
||||
} if tool_use_id == "recall-1" && name == RECALL_TOOL_HISTORY_NAME
|
||||
));
|
||||
assert!(matches!(
|
||||
&requests[1].messages[2].content,
|
||||
MessageContent::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error: false,
|
||||
} if tool_use_id == "recall-1"
|
||||
&& content == "No matching tool calls found in conversation history."
|
||||
));
|
||||
assert_eq!(
|
||||
events
|
||||
.iter()
|
||||
.filter(|event| matches!(
|
||||
event,
|
||||
StreamEvent::Response(response)
|
||||
if matches!(response.r#type, Some(response_event::Type::Init(_)))
|
||||
))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
events
|
||||
.iter()
|
||||
.filter(|event| matches!(
|
||||
event,
|
||||
StreamEvent::Response(response)
|
||||
if matches!(response.r#type, Some(response_event::Type::Finished(_)))
|
||||
))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(events
|
||||
.iter()
|
||||
.all(|event| !matches!(event, StreamEvent::ToolProposed(_))));
|
||||
assert_eq!(agent_output_texts(&events), ["Continuing with the answer."]);
|
||||
|
||||
let finished = events.iter().find_map(|event| {
|
||||
let StreamEvent::Response(response) = event else {
|
||||
return None;
|
||||
};
|
||||
let Some(response_event::Type::Finished(finished)) = &response.r#type else {
|
||||
return None;
|
||||
};
|
||||
Some(finished)
|
||||
});
|
||||
let finished = finished.expect("stream should finish");
|
||||
assert_eq!(finished.token_usage[0].total_input, 30);
|
||||
assert_eq!(finished.token_usage[0].output, 4);
|
||||
assert!(matches!(
|
||||
messages_sent.last().map(|message| &message.content),
|
||||
Some(MessageContent::Text(text)) if text == "Continuing with the answer."
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repeated_inline_recall_stops_with_a_visible_loop_limit_message() {
|
||||
let turns = (0..=MAX_INLINE_TOOL_CONTINUATIONS)
|
||||
.map(recall_turn)
|
||||
.collect();
|
||||
let (events, requests, messages_sent) = run_scripted_turn(turns).await;
|
||||
|
||||
assert_eq!(requests.len(), MAX_INLINE_TOOL_CONTINUATIONS + 1);
|
||||
assert!(agent_output_texts(&events).contains(&INLINE_TOOL_LOOP_MESSAGE));
|
||||
assert!(matches!(
|
||||
messages_sent.last().map(|message| &message.content),
|
||||
Some(MessageContent::Text(text)) if text == INLINE_TOOL_LOOP_MESSAGE
|
||||
));
|
||||
let finished = events.iter().find_map(|event| {
|
||||
let StreamEvent::Response(response) = event else {
|
||||
return None;
|
||||
};
|
||||
let Some(response_event::Type::Finished(finished)) = &response.r#type else {
|
||||
return None;
|
||||
};
|
||||
Some(finished)
|
||||
});
|
||||
assert!(matches!(
|
||||
finished.and_then(|finished| finished.reason.as_ref()),
|
||||
Some(response_event::stream_finished::Reason::Other(_))
|
||||
));
|
||||
}
|
||||
@@ -18,7 +18,7 @@ use crate::ai::agent::{
|
||||
};
|
||||
use crate::ai::document::ai_document_model::AIDocumentId;
|
||||
|
||||
pub(super) fn action_from_tool_call(
|
||||
pub(crate) fn action_from_tool_call(
|
||||
task_id: &str,
|
||||
call: &ToolCall,
|
||||
skill_path_origin: &SkillPathOrigin,
|
||||
|
||||
Reference in New Issue
Block a user