Make direct-provider agent runs durable

This commit is contained in:
2026-08-14 22:02:15 -05:00
parent f4a04d0240
commit b079f036fa
50 changed files with 9473 additions and 3189 deletions
+122 -4
View File
@@ -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(
+82 -1
View File
@@ -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();
+11 -4
View File
@@ -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,
};
-27
View File
@@ -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
View File
@@ -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;
+46 -9
View File
@@ -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,
+36 -1
View File
@@ -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();
-468
View File
@@ -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(_))
));
}
+1 -1
View File
@@ -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,