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
+49 -32
View File
@@ -43,36 +43,50 @@ Environment variables:
### AI Provider Architecture ### AI Provider Architecture
Galaxy supports multiple AI backends via a **provider dispatch pattern**. Provider selection Galaxy supports session-owned ACP backends and direct model providers. Direct-provider selection is
is controlled by settings (`ai.openai.enabled` takes priority over `ai.bedrock.enabled`). controlled by settings (`ai.openai.enabled` takes priority over `ai.bedrock.enabled`), but provider
configuration never selects lifecycle ownership.
``` ```
Provider dispatch: response_stream.rs → resolve_provider_config() → ProviderConfig enum Direct provider: controller.rs → prepare_provider_run() → ProviderRunCoordinator
↓ BedrockOpenAI one model call per turn
bedrock/translator.rs openai/translator.rs AgentRuntime implementation
↓ tool batch
correlated action execution
↓ committed results
next ProviderRun turn
ACP: controller.rs → ResponseStream → acp_output_stream (session-owned lifecycle)
``` ```
**Shared types** in `app/src/ai/provider/`: **Durable direct-provider run**:
- `crates/galaxy_agent_core/src/provider_run.rs` — Serializable `ProviderRun` state machine, run/epoch identity, bounded model retries, ordered tool batches, cancellation, and terminal outcomes
- `app/src/ai/runtime/provider_run_coordinator.rs` — Drives one-turn `AgentRuntime` calls, validates model events, projects output, and commits exact tool lifecycle events
- `app/src/ai/runtime/rig.rs` — Builds base/CLI request profiles and resolves the configured one-turn runtime; it does not own follow-through
- `app/src/ai/runtime/rig_request.rs` — Converts controller request state into provider-neutral `TurnRequest` history, tools, prompts, and MCP aliases
- `app/src/ai/runtime/event_translator.rs` — Projects provider-neutral runtime events into Warp response events for UI/history compatibility
- `app/src/ai/blocklist/controller.rs` — Retains active runs, correlates actions by `(conversation_id, run_id, epoch, call_id)`, monitors commands, persists checkpoints, and restores interrupted runs
- `app/src/ai/blocklist/controller/response_stream.rs` — Owns ACP transport and shared UI/history projection only; it must not drive direct-provider retries or follow-up turns
**Shared provider types** in `app/src/ai/provider/`:
- `types.rs``ConversationMessage`, `MessageRole`, `MessageContent`, `ContentPart`, `ToolDefinition` - `types.rs``ConversationMessage`, `MessageRole`, `MessageContent`, `ContentPart`, `ToolDefinition`
- `mod.rs``ProviderConfig` enum (Bedrock | OpenAI | None) - `mod.rs``ProviderConfig` enum (Bedrock | OpenAI | None)
**Bedrock provider** in `app/src/ai/bedrock/`: **Bedrock provider** in `app/src/ai/bedrock/`:
- `translator.rs` — Orchestrator: takes `api::Request` + config, returns `ResponseStream` - `runtime.rs` — Native one-call `AgentRuntime` over `ConverseStream`, including cancellation, reasoning signatures, token usage, and stop/error classification
- `request_translator.rs`Converts Warp proto → Bedrock SDK types (messages, system prompt, tools, sanitization) - `request_translator.rs`Shared Bedrock message sanitization and tool definitions
- `response_translator.rs` — Converts Bedrock stream events → Warp proto `ResponseEvent`s - `response_translator.rs` — Compatibility conversion helpers used by tests and background flows
- `convert.rs`Re-exports shared types + Bedrock SDK type builders - `convert.rs`Bedrock request construction and prompt-caching behavior
- `client.rs` — AWS SDK client construction and `converse_stream` call - `client.rs` — AWS SDK client construction, runtime creation, and independent background streaming calls
- `models.rs` — Model registry and cross-region inference prefix logic - `models.rs` — Model registry and cross-region inference prefix logic
- `discovery.rs` — AWS profile listing and model discovery (STS identity check + ListFoundationModels) - `discovery.rs` — AWS profile listing and model discovery (STS identity check + ListFoundationModels)
- `diagnostic.rs` — Debug logging (enabled via `GALAXY_BEDROCK_DIAGNOSTICS=1`) - `diagnostic.rs` — Debug logging (enabled via `GALAXY_BEDROCK_DIAGNOSTICS=1`)
- `external_config.rs` — Fallback config from Claude Code/OpenCode settings - `external_config.rs` — Fallback config from Claude Code/OpenCode settings
**OpenAI/LiteLLM provider** in `app/src/ai/openai/`: **OpenAI-compatible providers**:
- `translator.rs` — Orchestrator: same pattern as Bedrock, targets OpenAI chat completions API - Direct turns use one-call runtimes from `galaxy_agent_rig` selected in `app/src/ai/runtime/rig.rs` for OpenAI/LiteLLM, ChatGPT subscription, Anthropic, Gemini, and Vertex AI
- `client.rs``reqwest`-based HTTP client for `POST /v1/chat/completions` with streaming - `app/src/ai/openai/request_translator.rs` sanitizes provider-neutral history for OpenAI-compatible APIs
- `convert.rs``ConversationMessage` → OpenAI JSON format (system/user/assistant/tool roles, function calling) - `app/src/ai/openai/client.rs`, `convert.rs`, and `response_translator.rs` remain compatibility/background transport helpers, not lifecycle owners
- `request_translator.rs` — OpenAI-specific message sanitization (lighter than Bedrock's strict alternation rules)
- `response_translator.rs` — SSE stream parser → Warp proto `ResponseEvent`s
**Provider settings** (in settings TOML): **Provider settings** (in settings TOML):
- `ai.bedrock.enabled` — Use AWS Bedrock directly (default: true) - `ai.bedrock.enabled` — Use AWS Bedrock directly (default: true)
@@ -117,22 +131,25 @@ context_size = 128000
- Provider name shown as the description label in the model picker; icon shows OpenAI logo for all OpenAI-compatible providers - Provider name shown as the description label in the model picker; icon shows OpenAI logo for all OpenAI-compatible providers
Key invariants: Key invariants:
- Known tools are in `KNOWN_TOOLS` constant in `response_translator.rs` - Every direct-provider `AgentRuntime::start_turn` performs exactly one model call; only `ProviderRun` may schedule another turn or retry
- Tool definitions are built via `tool_definition_for_name()` in `convert_request.rs`; includes `recall_tool_history` for retrieving past tool results - `use_rig` and provider selection may choose request/transport details but must never choose lifecycle ownership
- Direct-provider normal and plan turns must advertise `read_plan`, `create_plan`, and `edit_plan` when the matching document capabilities are enabled; plan-creation requests should call `create_plan` after research rather than only returning prose or claiming the tool is unavailable - Direct-provider output may be projected through `ResponseStream`, but provider progress must not depend on response-stream result draining or `AfterStreamFinished`
- Unknown/hallucinated tool calls are caught in the stream, paired with synthetic error results, and now emit a visible `AgentOutput` text message to the UI - Provider actions and results must correlate by `(conversation_id, run_id, epoch, call_id)`; stale or duplicate callbacks must not advance a run
- `recall_tool_history` is handled inline by direct-provider adapters using a synthetic result from `messages_sent`; the Rig adapter must automatically start a bounded follow-up provider turn after pairing that result, without continuing turns that proposed client-executed tools - Active provider runs must checkpoint before external work, persist without credentials, normalize unsafe restored states, and reconcile command state before continuing
- `recall_tool_history` must exclude prior calls to itself from candidates so inline continuation cannot recursively recall synthetic recall results - Known tools are in `KNOWN_TOOLS` in `response_translator.rs`; definitions are built by `tool_definition_for_name()` in `convert_request.rs`
- Tool result archive: before progressive summarization drains messages, `ConversationMessage::archive_tool_results()` extracts all tool_use/tool_result pairs into a separate `tool_result_archive` vec. `recall_tool_history` searches both live history + archived results, and supports a `tool_use_id` parameter for exact ID lookup - Direct-provider normal and plan turns must advertise `read_plan`, `create_plan`, and `edit_plan` when the matching document capabilities are enabled; plan-creation requests should call `create_plan` after research rather than only returning prose
- Prompt caching uses three cache points: system prompt, conversation history (second-to-last message), tool config - Unknown or invalid tool calls receive one correlated synthetic error result and a visible `AgentOutput` message; the durable run owns any continuation
- `recall_tool_history` is an inline completed tool batch. `ProviderRun` commits its synthetic result and starts a bounded next turn without routing it through client action execution
- `recall_tool_history` excludes earlier calls to itself; archived tool results remain searchable by query or exact `tool_use_id`
- Before progressive summarization drains messages, `ConversationMessage::archive_tool_results()` moves tool-use/result pairs into `tool_result_archive`
- Bedrock prompt caching uses three cache points: system prompt, second-to-last history message, and tool configuration
- `ensure_tool_results_paired()` enforces Bedrock's invariant that every `tool_use` has a matching `tool_result` - `ensure_tool_results_paired()` enforces Bedrock's invariant that every `tool_use` has a matching `tool_result`
- `inject_input_messages_into_task()` and `extract_user_query_text()` ensure user queries persist for session restore - Progressive summaries are prepended to provider requests as a user/assistant pair; background summarization remains independent of the active provider run
- The stream emits a `UserQuery` proto message at the start of each response for conversation title - Loop prevention in `controller.rs` detects repeated tool failures (3+ identical) and injects a corrective instruction
- Progressive summary (if present) is prepended to the messages array as a user/assistant pair in `translator.rs` - Direct-provider long-running shell follow-ups retain CLI tasks as UI projections while command monitoring and completion stay owned by the same provider run
- Loop prevention guardrail in `controller.rs` detects repeated tool failures (3+ identical) and injects corrective instructions - Direct-provider completed-command assessments are hidden, tool-free root-task turns whose output and hidden input survive CLI-task deactivation and restoration
- Direct-provider long-running shell follow-ups create unlinked CLI tasks under the root task with an empty subagent tool-call ID; `TaskStore` linearization must include their exchanges chronologically even though no parent `Subagent` output references them - ACP remains a separate session-owned runtime; direct-provider cleanup must not move ACP lifecycle into `ProviderRun`
- Direct-provider completed-command assessments are hidden, tool-free root-task turns; CLI monitor exchanges remain on the retained CLI task, while the root assessment output must survive CLI-task deactivation and restoration and its hidden input must remain available to future provider context - Orchestrated child conversations are leaf workers: nested `RunAgents` and legacy `StartAgent` calls are rejected before autonomy or permission bypasses, and child requests do not advertise delegation tools
- Orchestrated child conversations are leaf workers by default: nested `RunAgents` and legacy `StartAgent` calls must be rejected before autonomous or permission bypasses, and child requests must not advertise delegation tools
### Platform Setup ### Platform Setup
- `./script/bootstrap` - Platform-specific setup plus common agent skill installation from `skills-lock.json`; prompts for project/global when an install or update is needed unless a target flag or environment override is provided. - `./script/bootstrap` - Platform-specific setup plus common agent skill installation from `skills-lock.json`; prompts for project/global when an install or update is needed unless a target flag or environment override is provided.
+8 -14
View File
@@ -21,10 +21,10 @@ use galaxy_core::features::FeatureFlag;
use galaxy_core::user_preferences::GetUserPreferences; use galaxy_core::user_preferences::GetUserPreferences;
use galaxyui::{AppContext, EntityId, SingletonEntity as _}; use galaxyui::{AppContext, EntityId, SingletonEntity as _};
use mcp::TemplatableMCPServerInfo; use mcp::TemplatableMCPServerInfo;
pub use r#impl::generate_multi_agent_output; pub(crate) use r#impl::prepare_direct_provider_params;
use serde::Serialize; use serde::Serialize;
use super::{AIAgentAction, AIAgentInput, MCPContext, MCPServer, RequestMetadata, Suggestions}; use super::{AIAgentInput, MCPContext, MCPServer, RequestMetadata, Suggestions};
use crate::ai::agent::conversation::AIConversationId; use crate::ai::agent::conversation::AIConversationId;
use crate::ai::ambient_agents::AmbientAgentTaskId; use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::blocklist::{BlocklistAIPermissions, RequestInput, SessionContext}; use crate::ai::blocklist::{BlocklistAIPermissions, RequestInput, SessionContext};
@@ -112,7 +112,7 @@ pub struct RequestParams {
/// locally so ACP-provided Galaxy tools can be pinned to the exact pane. /// locally so ACP-provided Galaxy tools can be pinned to the exact pane.
pub terminal_view_id: Option<EntityId>, pub terminal_view_id: Option<EntityId>,
pub input: Vec<AIAgentInput>, pub input: Vec<AIAgentInput>,
/// Normalized results consumed directly by Rig-selected models. /// Normalized action results appended to direct-provider run history.
pub tool_results: Vec<ToolResult>, pub tool_results: Vec<ToolResult>,
pub conversation_token: Option<ServerConversationToken>, pub conversation_token: Option<ServerConversationToken>,
pub forked_from_conversation_token: Option<ServerConversationToken>, pub forked_from_conversation_token: Option<ServerConversationToken>,
@@ -151,8 +151,8 @@ pub struct RequestParams {
pub research_agent_enabled: bool, pub research_agent_enabled: bool,
pub orchestration_enabled: bool, pub orchestration_enabled: bool,
pub supported_tools_override: Option<Vec<warp_multi_agent_api::ToolType>>, pub supported_tools_override: Option<Vec<warp_multi_agent_api::ToolType>>,
/// The root task ID for the conversation — needed for direct Bedrock streaming /// The root task ID used to anchor direct-provider projection when optimistic tasks are not
/// since optimistic tasks don't appear in the proto task_context. /// present in the proto task context.
pub root_task_id: Option<String>, pub root_task_id: Option<String>,
/// The conversation ID of the parent agent that spawned this child agent, if any. /// The conversation ID of the parent agent that spawned this child agent, if any.
pub parent_agent_id: Option<String>, pub parent_agent_id: Option<String>,
@@ -167,9 +167,8 @@ pub struct RequestParams {
/// Kept separately so `recall_tool_history` can search archived results even after /// Kept separately so `recall_tool_history` can search archived results even after
/// they've been summarized away from live history. /// they've been summarized away from live history.
pub tool_result_archive: Vec<crate::ai::provider::types::ConversationMessage>, pub tool_result_archive: Vec<crate::ai::provider::types::ConversationMessage>,
/// Populated by direct-provider paths after building the message list. /// Populated while preparing a direct-provider run with the durable transcript that the
/// Contains the full messages sent (old history + new input) so the controller /// controller persists for restoration and future turns.
/// can store them back into the conversation for the next request cycle.
pub messages_sent: pub messages_sent:
std::sync::Arc<std::sync::Mutex<Vec<crate::ai::provider::types::ConversationMessage>>>, std::sync::Arc<std::sync::Mutex<Vec<crate::ai::provider::types::ConversationMessage>>>,
/// Global rules (name, content) from the local CloudModel (AIFact/AIMemory). /// Global rules (name, content) from the local CloudModel (AIFact/AIMemory).
@@ -177,15 +176,10 @@ pub struct RequestParams {
pub global_rules: Vec<(String, String)>, pub global_rules: Vec<(String, String)>,
} }
/// Provider/runtime events consumed by the local conversation controller. /// Response event projected into the local conversation controller.
///
/// The legacy response envelope remains at the UI boundary while the local Rig runtime emits
/// executable tool proposals directly as Galaxy domain actions. This avoids translating Rig tool
/// calls into protobuf only to immediately translate them back before execution.
#[derive(Debug)] #[derive(Debug)]
pub enum StreamEvent { pub enum StreamEvent {
Response(warp_multi_agent_api::ResponseEvent), Response(warp_multi_agent_api::ResponseEvent),
ToolProposed(AIAgentAction),
} }
pub type Event = Result<StreamEvent, Arc<AIApiError>>; pub type Event = Result<StreamEvent, Arc<AIApiError>>;
@@ -73,6 +73,7 @@ pub fn convert_conversation_data_to_ai_conversation(
let agent_conversation_data = match restoration_mode { let agent_conversation_data = match restoration_mode {
RestorationMode::Fork => AgentConversationData { RestorationMode::Fork => AgentConversationData {
agent_backend: Default::default(), agent_backend: Default::default(),
active_provider_run_json: None,
server_conversation_token: None, server_conversation_token: None,
conversation_usage_metadata: usage_metadata, conversation_usage_metadata: usage_metadata,
reverted_action_ids: None, reverted_action_ids: None,
@@ -96,6 +97,7 @@ pub fn convert_conversation_data_to_ai_conversation(
}, },
RestorationMode::Continue => AgentConversationData { RestorationMode::Continue => AgentConversationData {
agent_backend: Default::default(), agent_backend: Default::default(),
active_provider_run_json: None,
server_conversation_token: Some( server_conversation_token: Some(
metadata.server_conversation_token.as_str().to_string(), metadata.server_conversation_token.as_str().to_string(),
), ),
+22 -262
View File
@@ -1,270 +1,10 @@
use std::collections::HashMap;
use std::sync::Arc;
use futures_util::StreamExt;
use galaxy_core::features::FeatureFlag; use galaxy_core::features::FeatureFlag;
use warp_multi_agent_api as api; use warp_multi_agent_api as api;
use super::convert_to::convert_input; use super::RequestParams;
use super::{ConvertToAPITypeError, RequestParams, ResponseStream, StreamEvent}; use crate::ai::agent::redaction;
use crate::ai::agent::{redaction, AIAgentInput};
use crate::ai::openai::translator as openai_translator;
use crate::ai::provider::ProviderConfig;
use crate::server::server_api::AIApiError;
use crate::terminal::model::session::SessionType; use crate::terminal::model::session::SessionType;
pub async fn generate_multi_agent_output(
provider_config: ProviderConfig,
mut params: RequestParams,
cancellation_rx: futures::channel::oneshot::Receiver<()>,
) -> Result<ResponseStream, ConvertToAPITypeError> {
let supported_tools_override = params.supported_tools_override.take();
let mut supported_tools = supported_tools_override
.clone()
.unwrap_or_else(|| get_supported_tools(&params));
remove_orchestration_tools_if_disabled(&mut supported_tools, params.orchestration_enabled);
let mut supported_cli_agent_tools =
supported_tools_override.unwrap_or_else(|| get_supported_cli_agent_tools(&params));
remove_orchestration_tools_if_disabled(
&mut supported_cli_agent_tools,
params.orchestration_enabled,
);
if params.should_redact_secrets {
redaction::redact_inputs(&mut params.input);
}
match &provider_config {
ProviderConfig::OpenAI(config) if config.use_rig => {
return Ok(crate::ai::runtime::rig_openai_response_stream(
config.clone(),
params,
supported_tools,
supported_cli_agent_tools,
cancellation_rx,
));
}
ProviderConfig::Bedrock(config) if config.use_rig => {
return match crate::ai::runtime::rig_bedrock_response_stream(
config.clone(),
params,
supported_tools,
supported_cli_agent_tools,
cancellation_rx,
)
.await
{
Ok(stream) => Ok(stream),
Err(error) => {
log::error!("[rig/bedrock] Runtime error: {error}");
let error = Arc::new(crate::server::server_api::AIApiError::Stream {
stream_type: "rig_bedrock",
source: error,
});
let (sender, receiver) = async_channel::unbounded();
let _ = sender.send(Err(error)).await;
Ok(Box::pin(receiver))
}
};
}
ProviderConfig::OpenAI(_) | ProviderConfig::Bedrock(_) | ProviderConfig::None => {}
}
let mut logging_metadata = HashMap::new();
if let Some(ref metadata) = params.metadata {
logging_metadata.insert(
"is_autodetected_user_query".to_owned(),
prost_types::Value {
kind: Some(prost_types::value::Kind::BoolValue(
metadata.is_autodetected_user_query,
)),
},
);
logging_metadata.insert(
"entrypoint".to_owned(),
prost_types::Value {
kind: Some(prost_types::value::Kind::StringValue(
metadata.entrypoint.entrypoint(),
)),
},
);
logging_metadata.insert(
"is_auto_resume_after_error".to_owned(),
prost_types::Value {
kind: Some(prost_types::value::Kind::BoolValue(
metadata.is_auto_resume_after_error,
)),
},
);
}
let emit_user_query_message = !params
.input
.iter()
.any(|input| matches!(input, AIAgentInput::CommandCompletionAssessment { .. }));
let mut request = api::Request {
task_context: Some(api::request::TaskContext {
tasks: params.tasks,
}),
input: Some(convert_input(params.input)?),
settings: Some(api::request::Settings {
model_config: Some(api::request::settings::ModelConfig {
base: params.model.clone().into(),
cli_agent: params.cli_agent_model.clone().into(),
computer_use_agent: params.computer_use_model.clone().into(),
base_model_context_window_limit: params.context_window_limit.unwrap_or(0),
..Default::default()
}),
rules_enabled: params.is_memory_enabled,
warp_drive_context_enabled: params.warp_drive_context_enabled,
web_context_retrieval_enabled: true,
supports_parallel_tool_calls: true,
use_anthropic_text_editor_tools: false,
planning_enabled: params.planning_enabled,
supports_create_files: true,
supported_tools: supported_tools.into_iter().map(Into::into).collect(),
supports_long_running_commands: true,
should_preserve_file_content_in_history: true,
supports_todos_ui: true,
supports_linked_code_blocks: FeatureFlag::LinkedCodeBlocks.is_enabled(),
supports_started_child_task_message: true,
// Galaxy's direct providers only receive tools with local schemas and
// executors. Hosted-only suggestion/orchestration capability bits must
// remain false so models do not plan around unavailable Warp services.
supports_suggest_prompt: false,
supports_read_image_files: FeatureFlag::ReadImageFiles.is_enabled(),
supports_reasoning_message: true,
api_keys: params.api_keys,
autonomy_level: params.autonomy_level.into(),
isolation_level: params.isolation_level.into(),
web_search_enabled: params.web_search_enabled,
supported_cli_agent_tools: supported_cli_agent_tools
.into_iter()
.map(Into::into)
.collect(),
supports_v4a_file_diffs: FeatureFlag::V4AFileDiffs.is_enabled(),
supports_summarization_via_message_replacement:
FeatureFlag::SummarizationViaMessageReplacement.is_enabled(),
supports_bundled_skills: FeatureFlag::BundledSkills.is_enabled(),
supports_research_agent: params.research_agent_enabled,
supports_orchestration_v2: false,
supports_background_computer_use: FeatureFlag::BackgroundComputerUse.is_enabled()
&& computer_use::background_supported(),
custom_model_providers: params.custom_model_providers,
custom_model_routers: params.custom_model_routers,
}),
metadata: Some(api::request::Metadata {
logging: logging_metadata,
conversation_id: params
.conversation_token
.as_ref()
.map(|token| token.as_str().to_string())
.unwrap_or_default(),
ambient_agent_task_id: params
.ambient_agent_task_id
.map(|id| id.to_string())
.unwrap_or_default(),
forked_from_conversation_id: if params.conversation_token.is_none() {
// We only include this param on our initial request to the server
// (when the forked conversation has not been assigned a new id yet).
params
.forked_from_conversation_token
.map(|token| token.as_str().to_string())
.unwrap_or_default()
} else {
String::new()
},
parent_agent_id: params.parent_agent_id.unwrap_or_default(),
agent_name: params.agent_name.unwrap_or_default(),
}),
existing_suggestions: params
.existing_suggestions
.map(|suggestions| suggestions.into()),
mcp_context: params.mcp_context.map(Into::into),
};
match provider_config {
ProviderConfig::OpenAI(config) => {
let translator_request = openai_translator::TranslatorRequest {
config,
model_id: params.model.as_str().to_string(),
root_task_id: params.root_task_id.clone(),
message_history: params.message_history.clone(),
tool_result_archive: params.tool_result_archive.clone(),
progressive_summary: params.progressive_summary.clone(),
messages_sent: params.messages_sent.clone(),
global_rules: params.global_rules.clone(),
emit_user_query_message,
};
match openai_translator::execute(translator_request, &mut request).await {
Ok(stream) => {
let output_stream = stream
.map(|event| event.map(StreamEvent::Response))
.take_until(cancellation_rx);
Ok(Box::pin(output_stream))
}
Err(e) => {
log::error!("[openai] Translator error: {e}");
let err = Arc::new(
crate::server::server_api::AIApiError::Stream {
stream_type: "openai_chat_completions",
source: anyhow::anyhow!("{e}"),
}
.into_quota_limit_if_provider_budget_exhausted(),
);
let (tx, rx) = async_channel::unbounded();
let _ = tx.send(Err(err)).await;
Ok(Box::pin(rx))
}
}
}
ProviderConfig::Bedrock(config) => {
let translator_request = crate::ai::bedrock::translator::TranslatorRequest {
config,
model_id: params.model.as_str().to_string(),
root_task_id: params.root_task_id.clone(),
bedrock_message_history: params.message_history.clone(),
bedrock_tool_result_archive: params.tool_result_archive.clone(),
bedrock_progressive_summary: params.progressive_summary.clone(),
bedrock_messages_sent: params.messages_sent.clone(),
global_rules: params.global_rules.clone(),
emit_user_query_message,
};
match crate::ai::bedrock::translator::execute(translator_request, &mut request).await {
Ok(stream) => {
let output_stream = stream
.map(|event| event.map(StreamEvent::Response))
.take_until(cancellation_rx);
Ok(Box::pin(output_stream))
}
Err(e) => {
log::error!("[bedrock] Translator error: {e}");
let err = Arc::new(crate::server::server_api::AIApiError::Stream {
stream_type: "bedrock",
source: anyhow::anyhow!("{e}"),
});
let (tx, rx) = async_channel::unbounded();
let _ = tx.send(Err(err)).await;
Ok(Box::pin(rx))
}
}
}
ProviderConfig::None => {
// No provider configured — do not fall back to Warp's cloud API.
let err = Arc::new(crate::server::server_api::AIApiError::Stream {
stream_type: "none",
source: anyhow::anyhow!(
"No AI runtime configured. Enable an agent runtime or model provider in settings."
),
});
let (tx, rx) = async_channel::unbounded();
let _ = tx.send(Err(err)).await;
Ok(Box::pin(rx))
}
}
}
fn remove_orchestration_tools_if_disabled( fn remove_orchestration_tools_if_disabled(
supported_tools: &mut Vec<api::ToolType>, supported_tools: &mut Vec<api::ToolType>,
orchestration_enabled: bool, orchestration_enabled: bool,
@@ -280,6 +20,26 @@ fn remove_orchestration_tools_if_disabled(
}); });
} }
pub(crate) fn prepare_direct_provider_params(
params: &mut RequestParams,
) -> (Vec<api::ToolType>, Vec<api::ToolType>) {
let supported_tools_override = params.supported_tools_override.take();
let mut supported_tools = supported_tools_override
.clone()
.unwrap_or_else(|| get_supported_tools(params));
remove_orchestration_tools_if_disabled(&mut supported_tools, params.orchestration_enabled);
let mut supported_cli_agent_tools =
supported_tools_override.unwrap_or_else(|| get_supported_cli_agent_tools(params));
remove_orchestration_tools_if_disabled(
&mut supported_cli_agent_tools,
params.orchestration_enabled,
);
if params.should_redact_secrets {
redaction::redact_inputs(&mut params.input);
}
(supported_tools, supported_cli_agent_tools)
}
fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> { fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
let mut supported_tools = vec![ let mut supported_tools = vec![
api::ToolType::Grep, api::ToolType::Grep,
+99
View File
@@ -229,6 +229,9 @@ pub struct AIConversation {
/// Runtime responsible for executing this conversation. /// Runtime responsible for executing this conversation.
agent_backend: AgentBackend, agent_backend: AgentBackend,
/// Opaque, versioned snapshot of the active direct-provider run.
active_provider_run_json: Option<String>,
/// The server-generated unique "token" for this conversation. /// The server-generated unique "token" for this conversation.
/// ///
/// This must be roundtripped to the server when sending follow-ups within a given conversation. /// This must be roundtripped to the server when sending follow-ups within a given conversation.
@@ -380,6 +383,7 @@ impl AIConversation {
has_opened_code_review: false, has_opened_code_review: false,
conversation_usage_metadata: ConversationUsageMetadata::default(), conversation_usage_metadata: ConversationUsageMetadata::default(),
agent_backend, agent_backend,
active_provider_run_json: None,
server_conversation_token: None, server_conversation_token: None,
task_id: None, task_id: None,
forked_from_server_conversation_token: None, forked_from_server_conversation_token: None,
@@ -539,6 +543,7 @@ impl AIConversation {
let ( let (
agent_backend, agent_backend,
active_provider_run_json,
server_conversation_token, server_conversation_token,
forked_from_server_conversation_token, forked_from_server_conversation_token,
conversation_usage_metadata, conversation_usage_metadata,
@@ -589,6 +594,7 @@ impl AIConversation {
}; };
( (
data.agent_backend, data.agent_backend,
data.active_provider_run_json,
server_conversation_token, server_conversation_token,
forked_from_server_conversation_token, forked_from_server_conversation_token,
conversation_usage_metadata, conversation_usage_metadata,
@@ -611,6 +617,7 @@ impl AIConversation {
AgentBackend::default(), AgentBackend::default(),
None, None,
None, None,
None,
ConversationUsageMetadata::default(), ConversationUsageMetadata::default(),
HashSet::new(), HashSet::new(),
Vec::new(), Vec::new(),
@@ -663,6 +670,7 @@ impl AIConversation {
has_opened_code_review: false, has_opened_code_review: false,
conversation_usage_metadata, conversation_usage_metadata,
agent_backend, agent_backend,
active_provider_run_json,
server_conversation_token, server_conversation_token,
task_id: run_id.as_deref().and_then(|id| id.parse().ok()), task_id: run_id.as_deref().and_then(|id| id.parse().ok()),
forked_from_server_conversation_token, forked_from_server_conversation_token,
@@ -705,6 +713,14 @@ impl AIConversation {
&self.agent_backend &self.agent_backend
} }
pub(crate) fn active_provider_run_json(&self) -> Option<&str> {
self.active_provider_run_json.as_deref()
}
pub(crate) fn set_active_provider_run_json(&mut self, snapshot: Option<String>) {
self.active_provider_run_json = snapshot;
}
/// Updates the backend of a conversation that has not produced agent output. /// Updates the backend of a conversation that has not produced agent output.
/// ///
/// Provider failures without output are safe to retry through a newly enabled runtime. Once /// Provider failures without output are safe to retry through a newly enabled runtime. Once
@@ -2109,6 +2125,81 @@ impl AIConversation {
Ok(()) Ok(())
} }
/// Reopens an exact restored exchange for continued provider projection.
///
/// This only restores the process-local stream association; it never adds input or provider
/// history, so the persisted provider run remains the sole continuation source of truth.
pub(crate) fn provider_projection_target(
&self,
response_stream_id: &ResponseStreamId,
) -> Option<(TaskId, AIAgentExchangeId)> {
let mut exchanges = self
.added_exchanges_by_response
.get(response_stream_id)?
.iter();
let target = exchanges.next()?;
exchanges
.next()
.is_none()
.then(|| (target.task_id.clone(), target.exchange_id))
}
pub(crate) fn rebind_provider_projection(
&mut self,
task_id: &TaskId,
exchange_id: AIAgentExchangeId,
response_stream_id: ResponseStreamId,
terminal_surface_id: EntityId,
ctx: &mut ModelContext<BlocklistAIHistoryModel>,
) -> Result<(), UpdateConversationError> {
let Some(task) = self.task_store.get(task_id) else {
return Err(UpdateConversationError::TaskNotFound);
};
if !task.exchanges().any(|exchange| exchange.id == exchange_id) {
return if self.exchange_with_id(exchange_id).is_some() {
Err(UpdateConversationError::ExchangeTaskMismatch)
} else {
Err(UpdateConversationError::ExchangeNotFound)
};
}
if self
.added_exchanges_by_response
.contains_key(&response_stream_id)
{
return Err(UpdateConversationError::ResponseStreamAlreadyBound);
}
let exchange = self.get_exchange_to_update(exchange_id)?;
let previous_status = std::mem::replace(
&mut exchange.output_status,
AIAgentOutputStatus::Streaming { output: None },
);
let output = match previous_status {
AIAgentOutputStatus::Streaming { output } => output,
AIAgentOutputStatus::Finished { finished_output } => match finished_output {
FinishedAIAgentOutput::Cancelled { output, .. }
| FinishedAIAgentOutput::Error { output, .. } => output,
FinishedAIAgentOutput::Success { output } => Some(output),
},
};
exchange.output_status = AIAgentOutputStatus::Streaming { output };
exchange.finish_time = None;
self.added_exchanges_by_response.insert(
response_stream_id,
Vec1::new(AddedExchange {
task_id: task_id.clone(),
exchange_id,
}),
);
ctx.emit(BlocklistAIHistoryEvent::UpdatedStreamingExchange {
exchange_id,
terminal_surface_id,
conversation_id: self.id,
is_hidden: self.hidden_exchanges.contains(&exchange_id),
});
Ok(())
}
pub fn append_reassigned_exchange( pub fn append_reassigned_exchange(
&mut self, &mut self,
response_stream_id: &ResponseStreamId, response_stream_id: &ResponseStreamId,
@@ -2255,6 +2346,9 @@ impl AIConversation {
action: AIAgentAction, action: AIAgentAction,
ctx: &mut ModelContext<BlocklistAIHistoryModel>, ctx: &mut ModelContext<BlocklistAIHistoryModel>,
) -> Result<(), UpdateConversationError> { ) -> Result<(), UpdateConversationError> {
if self.contains_action(&action.id) {
return Ok(());
}
let added_exchanges = self let added_exchanges = self
.added_exchanges_by_response .added_exchanges_by_response
.get(stream_id) .get(stream_id)
@@ -3886,6 +3980,7 @@ impl AIConversation {
.collect(), .collect(),
conversation_data: AgentConversationData { conversation_data: AgentConversationData {
agent_backend: self.agent_backend.clone(), agent_backend: self.agent_backend.clone(),
active_provider_run_json: self.active_provider_run_json.clone(),
server_conversation_token: self server_conversation_token: self
.server_conversation_token .server_conversation_token
.clone() .clone()
@@ -4763,6 +4858,10 @@ fn cleanup_conversation_search_temp_dir(
pub enum UpdateConversationError { pub enum UpdateConversationError {
#[error("Exchange not found.")] #[error("Exchange not found.")]
ExchangeNotFound, ExchangeNotFound,
#[error("Exchange does not belong to the persisted task.")]
ExchangeTaskMismatch,
#[error("Response stream is already bound to an exchange.")]
ResponseStreamAlreadyBound,
#[error("Could not update task: {0:?}")] #[error("Could not update task: {0:?}")]
UpdateTask(#[from] UpdateTaskError), UpdateTask(#[from] UpdateTaskError),
#[error("Could not update upgrade optimistic task for server task: {0:?}")] #[error("Could not update upgrade optimistic task for server task: {0:?}")]
+13
View File
@@ -193,6 +193,19 @@ fn restored_conversation_defaults_autoexecute_override_when_not_persisted() {
); );
} }
#[test]
fn restored_conversation_retains_active_provider_run_json() {
let snapshot = r#"{"version":1,"run":{"state":"awaiting_model"}}"#;
let conversation_data = AgentConversationData {
active_provider_run_json: Some(snapshot.to_string()),
..Default::default()
};
let conversation = restored_conversation(Some(conversation_data));
assert_eq!(conversation.active_provider_run_json(), Some(snapshot));
}
#[test] #[test]
fn restored_conversation_uses_persisted_last_event_sequence() { fn restored_conversation_uses_persisted_last_event_sequence() {
let conversation_data: AgentConversationData = let conversation_data: AgentConversationData =
+10 -15
View File
@@ -6,13 +6,13 @@ use aws_credential_types::provider::ProvideCredentials;
use aws_sdk_bedrockruntime::config::Region; use aws_sdk_bedrockruntime::config::Region;
use aws_sdk_bedrockruntime::Client as BedrockRuntimeClient; use aws_sdk_bedrockruntime::Client as BedrockRuntimeClient;
use galaxy_agent_core::AgentError; use galaxy_agent_core::AgentError;
use galaxy_agent_rig::{BedrockRigConfig, BedrockRuntime};
use super::convert::{build_converse_request, CachingConfig, ConversationMessage, ToolDefinition}; use super::convert::{build_converse_request, CachingConfig, ConversationMessage, ToolDefinition};
use super::diagnostic::BedrockDiagnosticLogger; use super::diagnostic::BedrockDiagnosticLogger;
use super::external_config::ExternalBedrockConfig; use super::external_config::ExternalBedrockConfig;
use super::models::apply_cross_region_prefix; use super::models::apply_cross_region_prefix;
use super::response_translator::bedrock_stream_to_response_events; use super::response_translator::bedrock_stream_to_response_events;
use super::runtime::BedrockAgentRuntime;
use crate::ai::agent::api::LegacyResponseStream; use crate::ai::agent::api::LegacyResponseStream;
use crate::settings::ai::BedrockAuthMethod; use crate::settings::ai::BedrockAuthMethod;
@@ -171,25 +171,20 @@ impl BedrockClient {
}) })
} }
/// Builds the Phase 4 Rig runtime from the AWS SDK client whose region and pub(crate) fn agent_runtime(
/// credentials Galaxy already resolved. This does not change production
/// routing; callers opt in only after the Bedrock parity suite passes.
pub fn rig_runtime(
&self, &self,
model: String, model: String,
cross_region_inference: bool, cross_region_inference: bool,
prompt_caching: bool,
max_output_tokens: Option<u64>, max_output_tokens: Option<u64>,
) -> Result<BedrockRuntime, AgentError> { caching_config: CachingConfig,
BedrockRuntime::from_aws_client( ) -> Result<BedrockAgentRuntime, AgentError> {
BedrockAgentRuntime::new(
self.runtime_client.clone(), self.runtime_client.clone(),
BedrockRigConfig { model,
model, self.region.clone(),
region: self.region.clone(), cross_region_inference,
cross_region_inference, max_output_tokens,
prompt_caching, caching_config,
max_output_tokens,
},
) )
} }
+8 -2
View File
@@ -258,7 +258,10 @@ fn test_system_prompt_separated_from_messages() {
None, None,
None, None,
None, None,
CachingConfig::default(), CachingConfig {
enabled: false,
extended_ttl_requested: false,
},
); );
assert_eq!(result.system.len(), 1); assert_eq!(result.system.len(), 1);
@@ -334,7 +337,10 @@ fn test_tool_definitions_produce_tool_config() {
None, None,
None, None,
None, None,
CachingConfig::default(), CachingConfig {
enabled: false,
extended_ttl_requested: false,
},
); );
assert!(result.tool_config.is_some()); assert!(result.tool_config.is_some());
+1 -1
View File
@@ -7,8 +7,8 @@ pub mod external_config;
pub mod models; pub mod models;
pub mod request_translator; pub mod request_translator;
pub mod response_translator; pub mod response_translator;
pub mod runtime;
pub mod settings_view; pub mod settings_view;
pub mod translator;
#[cfg(test)] #[cfg(test)]
mod convert_tests; mod convert_tests;
+463
View File
@@ -0,0 +1,463 @@
use std::collections::BTreeMap;
use async_trait::async_trait;
use aws_sdk_bedrockruntime::operation::converse_stream::ConverseStreamOutput;
use aws_sdk_bedrockruntime::types::{
ContentBlockDelta, ContentBlockStart, ConverseStreamOutput as AwsStreamEvent,
ReasoningContentBlockDelta, StopReason as AwsStopReason,
};
use aws_sdk_bedrockruntime::Client as AwsBedrockClient;
use futures::{FutureExt, StreamExt};
use galaxy_agent_core::{
AgentError, AgentErrorKind, AgentEvent, AgentEventStream, AgentRuntime, RuntimeCapabilities,
RuntimeDescriptor, RuntimeKind, StopReason, ToolCall, ToolEvent, TurnCommand, TurnControl,
TurnRequest, Usage,
};
use uuid::Uuid;
use super::convert::{build_converse_request, CachingConfig, ConvertedRequest};
const DEFAULT_MAX_OUTPUT_TOKENS: u64 = 64_000;
#[derive(Clone)]
pub(crate) struct BedrockAgentRuntime {
client: AwsBedrockClient,
resolved_model: String,
max_output_tokens: Option<u64>,
caching_config: CachingConfig,
descriptor: RuntimeDescriptor,
}
impl BedrockAgentRuntime {
pub(crate) fn new(
client: AwsBedrockClient,
configured_model: String,
region: String,
cross_region_inference: bool,
max_output_tokens: Option<u64>,
caching_config: CachingConfig,
) -> Result<Self, AgentError> {
let resolved_model = galaxy_agent_rig::resolve_bedrock_model_id(
&configured_model,
&region,
cross_region_inference,
)?;
let descriptor = RuntimeDescriptor {
id: format!("bedrock:{resolved_model}"),
display_name: format!("Bedrock / {resolved_model}"),
kind: RuntimeKind::Provider,
capabilities: RuntimeCapabilities::provider(),
};
Ok(Self {
client,
resolved_model,
max_output_tokens,
caching_config,
descriptor,
})
}
}
#[async_trait]
impl AgentRuntime for BedrockAgentRuntime {
fn descriptor(&self) -> &RuntimeDescriptor {
&self.descriptor
}
async fn start_turn(
&self,
request: TurnRequest,
control: TurnControl,
) -> Result<AgentEventStream, AgentError> {
let converted =
convert_turn_request(request, self.max_output_tokens, self.caching_config.clone());
let mut request = self
.client
.converse_stream()
.model_id(&self.resolved_model)
.set_system(Some(converted.system))
.set_messages(Some(converted.messages))
.inference_config(converted.inference_config);
if let Some(tool_config) = converted.tool_config {
request = request.tool_config(tool_config);
}
let runtime_request_id = Uuid::new_v4().to_string();
let send_future = request.send().fuse();
let initial_control = control.clone();
let control_future = initial_control.receive().fuse();
futures::pin_mut!(send_future, control_future);
let output = futures::select_biased! {
command = control_future => match command {
Ok(TurnCommand::Cancel) => {
return Ok(stopped_before_stream(runtime_request_id));
}
Ok(TurnCommand::Steer { .. }) | Err(_) => {
send_future.await.map_err(map_bedrock_error)?
}
},
result = send_future => result.map_err(map_bedrock_error)?,
};
Ok(translate_bedrock_stream(
output,
runtime_request_id,
control,
))
}
}
fn convert_turn_request(
request: TurnRequest,
configured_max_output_tokens: Option<u64>,
caching_config: CachingConfig,
) -> ConvertedRequest {
let max_output_tokens = request
.max_output_tokens
.or(configured_max_output_tokens)
.unwrap_or(DEFAULT_MAX_OUTPUT_TOKENS)
.min(i32::MAX as u64) as i32;
build_converse_request(
request.messages,
request.system_prompt,
None,
request.tools,
max_output_tokens,
None,
None,
None,
caching_config,
)
}
fn translate_bedrock_stream(
mut output: ConverseStreamOutput,
runtime_request_id: String,
control: TurnControl,
) -> AgentEventStream {
let events = async_stream::stream! {
yield Ok(AgentEvent::TurnStarted {
runtime_request_id,
});
let mut translator = BedrockStreamTranslator::default();
let mut control_open = true;
loop {
let next_event = output.stream.recv().fuse();
let next_command = if control_open {
futures::future::Either::Left(control.receive())
} else {
futures::future::Either::Right(futures::future::pending())
}
.fuse();
futures::pin_mut!(next_event, next_command);
let event = futures::select_biased! {
command = next_command => {
match command {
Ok(TurnCommand::Cancel) => {
yield Ok(AgentEvent::TurnStopped {
reason: StopReason::Cancelled,
});
return;
}
Ok(TurnCommand::Steer { .. }) => continue,
Err(_) => {
control_open = false;
continue;
}
}
}
event = next_event => event,
};
match event {
Ok(Some(event)) => match translator.translate(event) {
Ok(events) => {
for event in events {
yield Ok(event);
}
}
Err(error) => {
yield Err(error);
return;
}
},
Ok(None) => match translator.finish() {
Ok(events) => {
for event in events {
yield Ok(event);
}
return;
}
Err(error) => {
yield Err(error);
return;
}
},
Err(error) => {
yield Err(map_bedrock_error(error));
return;
}
}
}
};
Box::pin(events)
}
#[derive(Default)]
struct BedrockStreamTranslator {
content_blocks: BTreeMap<i32, PendingContentBlock>,
stop_reason: Option<StopReason>,
}
impl BedrockStreamTranslator {
fn translate(&mut self, event: AwsStreamEvent) -> Result<Vec<AgentEvent>, AgentError> {
match event {
AwsStreamEvent::MessageStart(_) => Ok(Vec::new()),
AwsStreamEvent::ContentBlockStart(start) => {
let Some(block_start) = start.start() else {
return Ok(Vec::new());
};
let ContentBlockStart::ToolUse(tool) = block_start else {
return Err(protocol_error(
"Bedrock started an unsupported output content block",
));
};
let index = start.content_block_index();
if self
.content_blocks
.insert(
index,
PendingContentBlock::Tool {
id: tool.tool_use_id().to_string(),
name: tool.name().to_string(),
input: String::new(),
},
)
.is_some()
{
return Err(protocol_error(format!(
"Bedrock started content block {index} more than once"
)));
}
Ok(Vec::new())
}
AwsStreamEvent::ContentBlockDelta(delta) => {
let Some(delta_value) = delta.delta() else {
return Err(protocol_error("Bedrock emitted an empty content delta"));
};
let index = delta.content_block_index();
match delta_value {
ContentBlockDelta::Text(text) => {
Ok(vec![AgentEvent::TextDelta { text: text.clone() }])
}
ContentBlockDelta::ReasoningContent(reasoning) => {
let block = self.content_blocks.entry(index).or_insert_with(|| {
PendingContentBlock::Reasoning {
text: String::new(),
signature: None,
}
});
let PendingContentBlock::Reasoning { text, signature } = block else {
return Err(protocol_error(format!(
"Bedrock mixed reasoning and tool data in content block {index}"
)));
};
match reasoning {
ReasoningContentBlockDelta::Text(delta) => {
text.push_str(delta);
Ok(vec![AgentEvent::ReasoningDelta {
text: delta.clone(),
}])
}
ReasoningContentBlockDelta::Signature(delta) => {
signature.get_or_insert_with(String::new).push_str(delta);
Ok(Vec::new())
}
ReasoningContentBlockDelta::RedactedContent(_) => Ok(Vec::new()),
_ => Err(protocol_error("Bedrock emitted an unknown reasoning delta")),
}
}
ContentBlockDelta::ToolUse(tool_delta) => {
let Some(PendingContentBlock::Tool { input, .. }) =
self.content_blocks.get_mut(&index)
else {
return Err(protocol_error(format!(
"Bedrock emitted tool input before starting content block {index}"
)));
};
input.push_str(tool_delta.input());
Ok(Vec::new())
}
ContentBlockDelta::Citation(_) => Ok(Vec::new()),
ContentBlockDelta::Image(_) => {
Err(protocol_error("Bedrock emitted unsupported image output"))
}
ContentBlockDelta::ToolResult(_) => Err(protocol_error(
"Bedrock emitted an unexpected tool-result delta",
)),
_ => Err(protocol_error("Bedrock emitted an unknown content delta")),
}
}
AwsStreamEvent::ContentBlockStop(stop) => {
let index = stop.content_block_index();
let Some(block) = self.content_blocks.remove(&index) else {
return Ok(Vec::new());
};
match block {
PendingContentBlock::Tool { id, name, input } => {
let arguments = serde_json::from_str(&input).map_err(|error| {
protocol_error(format!(
"Bedrock returned invalid JSON for tool '{name}' ({id}): {error}"
))
})?;
Ok(vec![AgentEvent::Tool {
event: ToolEvent::Proposed {
call: ToolCall {
id,
name,
arguments,
},
},
}])
}
PendingContentBlock::Reasoning { text, signature } => {
Ok(vec![AgentEvent::ReasoningCompleted { text, signature }])
}
}
}
AwsStreamEvent::MessageStop(stop) => {
if self.stop_reason.is_some() {
return Err(protocol_error(
"Bedrock emitted more than one message-stop event",
));
}
self.stop_reason = Some(map_stop_reason(stop.stop_reason()));
Ok(Vec::new())
}
AwsStreamEvent::Metadata(metadata) => {
let Some(usage) = metadata.usage() else {
return Ok(Vec::new());
};
Ok(vec![AgentEvent::UsageUpdated {
usage: Usage {
input_tokens: nonnegative_tokens(usage.input_tokens()),
output_tokens: nonnegative_tokens(usage.output_tokens()),
cached_input_tokens: nonnegative_tokens(
usage.cache_read_input_tokens().unwrap_or(0),
),
cache_creation_input_tokens: nonnegative_tokens(
usage.cache_write_input_tokens().unwrap_or(0),
),
},
}])
}
_ => Err(protocol_error("Bedrock emitted an unknown stream event")),
}
}
fn finish(self) -> Result<Vec<AgentEvent>, AgentError> {
if !self.content_blocks.is_empty() {
return Err(protocol_error(
"Bedrock stream ended with incomplete content blocks",
));
}
let reason = self
.stop_reason
.ok_or_else(|| protocol_error("Bedrock stream ended before the message-stop event"))?;
Ok(vec![AgentEvent::TurnStopped { reason }])
}
}
#[derive(Debug)]
enum PendingContentBlock {
Tool {
id: String,
name: String,
input: String,
},
Reasoning {
text: String,
signature: Option<String>,
},
}
fn map_stop_reason(reason: &AwsStopReason) -> StopReason {
match reason {
AwsStopReason::EndTurn | AwsStopReason::StopSequence | AwsStopReason::ToolUse => {
StopReason::Completed
}
AwsStopReason::MaxTokens => StopReason::MaxTokens,
AwsStopReason::ModelContextWindowExceeded => StopReason::ContextWindowExceeded,
AwsStopReason::ContentFiltered | AwsStopReason::GuardrailIntervened => StopReason::Refusal,
AwsStopReason::MalformedModelOutput | AwsStopReason::MalformedToolUse => {
StopReason::Other(reason.as_str().to_string())
}
other => StopReason::Other(other.as_str().to_string()),
}
}
fn nonnegative_tokens(value: i32) -> u64 {
u64::try_from(value).unwrap_or_default()
}
fn stopped_before_stream(runtime_request_id: String) -> AgentEventStream {
Box::pin(futures::stream::iter([
Ok(AgentEvent::TurnStarted { runtime_request_id }),
Ok(AgentEvent::TurnStopped {
reason: StopReason::Cancelled,
}),
]))
}
fn map_bedrock_error(error: impl std::fmt::Display + std::fmt::Debug) -> AgentError {
let display = error.to_string();
let debug = format!("{error:?}");
let message = if debug.len() > display.len() {
debug
} else {
display
};
let normalized = message.to_ascii_lowercase();
let kind = if normalized.contains("accessdenied")
|| normalized.contains("access denied")
|| normalized.contains("unauthorized")
|| normalized.contains("credential")
{
AgentErrorKind::Authentication
} else if normalized.contains("throttl") || normalized.contains("rate limit") {
AgentErrorKind::RateLimited
} else if normalized.contains("context window")
|| normalized.contains("too many tokens")
|| normalized.contains("modelcontextwindowexceeded")
{
AgentErrorKind::ContextWindowExceeded
} else if normalized.contains("validation")
|| normalized.contains("resource not found")
|| normalized.contains("resourcenotfound")
{
AgentErrorKind::InvalidRequest
} else if normalized.contains("timeout")
|| normalized.contains("dispatchfailure")
|| normalized.contains("connection")
{
AgentErrorKind::Transport
} else {
AgentErrorKind::Provider
};
let mut error = AgentError::new(kind, message);
error.recoverable = matches!(
kind,
AgentErrorKind::RateLimited | AgentErrorKind::Transport
);
error
}
fn protocol_error(message: impl Into<String>) -> AgentError {
AgentError::new(AgentErrorKind::Protocol, message)
}
#[cfg(test)]
#[path = "runtime_tests.rs"]
mod tests;
+350
View File
@@ -0,0 +1,350 @@
use aws_sdk_bedrockruntime::types::{
CacheTtl, ContentBlock, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStart,
ContentBlockStartEvent, ContentBlockStopEvent, ConverseStreamMetadataEvent,
ConverseStreamOutput as AwsStreamEvent, MessageStopEvent, ReasoningContentBlockDelta,
StopReason as AwsStopReason, SystemContentBlock, TokenUsage, Tool, ToolUseBlockDelta,
ToolUseBlockStart,
};
use galaxy_agent_core::{
AgentErrorKind, AgentEvent, ConversationMessage, MessageContent, MessageRole, StopReason,
ToolDefinition, ToolEvent, TurnRequest, Usage,
};
use serde_json::json;
use super::*;
fn turn_request() -> TurnRequest {
let mut request = TurnRequest::new(
"anthropic.claude-test",
vec![
ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("first".to_string()),
},
ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::Text("response".to_string()),
},
ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("continue".to_string()),
},
],
);
request.system_prompt = Some("system".to_string());
request.tools = vec![ToolDefinition {
name: "read_files".to_string(),
description: "Read files".to_string(),
input_schema: json!({"type": "object"}),
}];
request
}
fn cache_ttls(converted: &ConvertedRequest) -> Vec<Option<CacheTtl>> {
let mut ttls = Vec::new();
for message in &converted.messages {
for block in message.content() {
if let ContentBlock::CachePoint(point) = block {
ttls.push(point.ttl().cloned());
}
}
}
for block in &converted.system {
if let SystemContentBlock::CachePoint(point) = block {
ttls.push(point.ttl().cloned());
}
}
if let Some(tool_config) = &converted.tool_config {
for tool in tool_config.tools() {
if let Tool::CachePoint(point) = tool {
ttls.push(point.ttl().cloned());
}
}
}
ttls
}
#[test]
fn one_turn_transport_preserves_disabled_default_and_one_hour_cache_modes() {
let disabled = convert_turn_request(
turn_request(),
Some(4096),
CachingConfig {
enabled: false,
extended_ttl_requested: false,
},
);
assert!(cache_ttls(&disabled).is_empty());
let default = convert_turn_request(turn_request(), Some(4096), CachingConfig::default());
assert_eq!(cache_ttls(&default), vec![None, None, None]);
let one_hour = convert_turn_request(
turn_request(),
Some(4096),
CachingConfig {
enabled: true,
extended_ttl_requested: true,
},
);
assert_eq!(
cache_ttls(&one_hour),
vec![
Some(CacheTtl::OneHour),
Some(CacheTtl::OneHour),
Some(CacheTtl::OneHour),
]
);
}
#[test]
fn one_turn_transport_prefers_request_output_limit() {
let mut request = turn_request();
request.max_output_tokens = Some(8192);
let converted = convert_turn_request(request, Some(4096), CachingConfig::default());
assert_eq!(converted.inference_config.max_tokens(), Some(8192));
}
fn tool_start(index: i32, id: &str, name: &str) -> AwsStreamEvent {
AwsStreamEvent::ContentBlockStart(
ContentBlockStartEvent::builder()
.content_block_index(index)
.start(ContentBlockStart::ToolUse(
ToolUseBlockStart::builder()
.tool_use_id(id)
.name(name)
.build()
.unwrap(),
))
.build()
.unwrap(),
)
}
fn ordinary_start(index: i32) -> AwsStreamEvent {
AwsStreamEvent::ContentBlockStart(
ContentBlockStartEvent::builder()
.content_block_index(index)
.build()
.unwrap(),
)
}
fn content_delta(index: i32, delta: ContentBlockDelta) -> AwsStreamEvent {
AwsStreamEvent::ContentBlockDelta(
ContentBlockDeltaEvent::builder()
.content_block_index(index)
.delta(delta)
.build()
.unwrap(),
)
}
fn content_stop(index: i32) -> AwsStreamEvent {
AwsStreamEvent::ContentBlockStop(
ContentBlockStopEvent::builder()
.content_block_index(index)
.build()
.unwrap(),
)
}
fn message_stop(reason: AwsStopReason) -> AwsStreamEvent {
AwsStreamEvent::MessageStop(
MessageStopEvent::builder()
.stop_reason(reason)
.build()
.unwrap(),
)
}
fn metadata(usage: Usage) -> AwsStreamEvent {
AwsStreamEvent::Metadata(
ConverseStreamMetadataEvent::builder()
.usage(
TokenUsage::builder()
.input_tokens(usage.input_tokens as i32)
.output_tokens(usage.output_tokens as i32)
.total_tokens((usage.input_tokens + usage.output_tokens) as i32)
.cache_read_input_tokens(usage.cached_input_tokens as i32)
.cache_write_input_tokens(usage.cache_creation_input_tokens as i32)
.build()
.unwrap(),
)
.build(),
)
}
#[test]
fn stream_translator_accepts_ordinary_content_block_starts() {
let mut translator = BedrockStreamTranslator::default();
assert!(translator.translate(ordinary_start(0)).unwrap().is_empty());
assert_eq!(
translator
.translate(content_delta(
0,
ContentBlockDelta::Text("response".to_string()),
))
.unwrap(),
vec![AgentEvent::TextDelta {
text: "response".to_string(),
}]
);
assert!(translator.translate(content_stop(0)).unwrap().is_empty());
}
#[test]
fn stream_translator_correlates_tools_by_content_index() {
let mut translator = BedrockStreamTranslator::default();
translator
.translate(tool_start(2, "call-2", "grep"))
.unwrap();
translator
.translate(tool_start(1, "call-1", "read_files"))
.unwrap();
translator
.translate(content_delta(
1,
ContentBlockDelta::ToolUse(
ToolUseBlockDelta::builder()
.input("{\"files\":[\"Cargo.toml\"]}")
.build()
.unwrap(),
),
))
.unwrap();
translator
.translate(content_delta(
2,
ContentBlockDelta::ToolUse(
ToolUseBlockDelta::builder()
.input("{\"query\":\"ProviderRun\"}")
.build()
.unwrap(),
),
))
.unwrap();
let first = translator.translate(content_stop(1)).unwrap();
let second = translator.translate(content_stop(2)).unwrap();
assert!(matches!(
first.as_slice(),
[AgentEvent::Tool {
event: ToolEvent::Proposed { call }
}] if call.id == "call-1"
&& call.name == "read_files"
&& call.arguments == json!({"files": ["Cargo.toml"]})
));
assert!(matches!(
second.as_slice(),
[AgentEvent::Tool {
event: ToolEvent::Proposed { call }
}] if call.id == "call-2"
&& call.name == "grep"
&& call.arguments == json!({"query": "ProviderRun"})
));
}
#[test]
fn stream_translator_defers_stop_until_usage_metadata_arrives() {
let mut translator = BedrockStreamTranslator::default();
assert!(translator
.translate(message_stop(AwsStopReason::EndTurn))
.unwrap()
.is_empty());
let expected_usage = Usage {
input_tokens: 10,
output_tokens: 4,
cached_input_tokens: 7,
cache_creation_input_tokens: 3,
};
assert_eq!(
translator
.translate(metadata(expected_usage.clone()))
.unwrap(),
vec![AgentEvent::UsageUpdated {
usage: expected_usage,
}]
);
assert_eq!(
translator.finish().unwrap(),
vec![AgentEvent::TurnStopped {
reason: StopReason::Completed,
}]
);
}
#[test]
fn stream_translator_preserves_reasoning_text_and_signature() {
let mut translator = BedrockStreamTranslator::default();
assert_eq!(
translator
.translate(content_delta(
0,
ContentBlockDelta::ReasoningContent(ReasoningContentBlockDelta::Text(
"inspect".to_string(),
)),
))
.unwrap(),
vec![AgentEvent::ReasoningDelta {
text: "inspect".to_string(),
}]
);
translator
.translate(content_delta(
0,
ContentBlockDelta::ReasoningContent(ReasoningContentBlockDelta::Signature(
"signature".to_string(),
)),
))
.unwrap();
assert_eq!(
translator.translate(content_stop(0)).unwrap(),
vec![AgentEvent::ReasoningCompleted {
text: "inspect".to_string(),
signature: Some("signature".to_string()),
}]
);
}
#[test]
fn stream_translator_rejects_invalid_tool_json() {
let mut translator = BedrockStreamTranslator::default();
translator
.translate(tool_start(0, "call", "read_files"))
.unwrap();
translator
.translate(content_delta(
0,
ContentBlockDelta::ToolUse(
ToolUseBlockDelta::builder()
.input("not-json")
.build()
.unwrap(),
),
))
.unwrap();
let error = translator.translate(content_stop(0)).unwrap_err();
assert_eq!(error.kind, AgentErrorKind::Protocol);
}
#[test]
fn bedrock_stop_reasons_map_to_domain_reasons() {
assert_eq!(
map_stop_reason(&AwsStopReason::ToolUse),
StopReason::Completed
);
assert_eq!(
map_stop_reason(&AwsStopReason::MaxTokens),
StopReason::MaxTokens
);
assert_eq!(
map_stop_reason(&AwsStopReason::ModelContextWindowExceeded),
StopReason::ContextWindowExceeded
);
assert_eq!(
map_stop_reason(&AwsStopReason::GuardrailIntervened),
StopReason::Refusal
);
}
-217
View File
@@ -1,217 +0,0 @@
#![allow(dead_code)]
use std::sync::{Arc, Mutex};
use warp_multi_agent_api as api;
use crate::ai::agent::api::LegacyResponseStream;
use crate::ai::bedrock::client::{BedrockClient, BedrockClientConfig, BedrockError};
use crate::ai::bedrock::convert::ConversationMessage;
use crate::ai::bedrock::diagnostic::BedrockDiagnosticLogger;
use crate::ai::bedrock::request_translator;
use crate::ai::provider::types::flatten_tool_history_for_no_tools_turn;
pub struct TranslatorRequest {
pub config: BedrockClientConfig,
pub model_id: String,
pub root_task_id: Option<String>,
pub bedrock_message_history: Vec<ConversationMessage>,
pub bedrock_tool_result_archive: Vec<ConversationMessage>,
pub bedrock_progressive_summary: Option<String>,
pub bedrock_messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
/// Global rules (name, content) from the local CloudModel.
pub global_rules: Vec<(String, String)>,
/// Whether the native input should be emitted as a transcript-visible user query.
pub emit_user_query_message: bool,
}
pub async fn execute(
params: TranslatorRequest,
request: &mut api::Request,
) -> Result<LegacyResponseStream, BedrockError> {
let config = params.config.with_external_fallbacks();
let cross_region_inference = config.cross_region_inference;
let bedrock = BedrockClient::from_config(config).await?;
let task_id = params.root_task_id.unwrap_or_else(|| {
request
.task_context
.as_ref()
.and_then(|tc| tc.tasks.first())
.map(|t| t.id.clone())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
});
let needs_create_task = request
.task_context
.as_ref()
.map(|tc| tc.tasks.is_empty())
.unwrap_or(true);
// Use the model from params (selected in UI or defaulted from ANTHROPIC_MODEL)
let mut model_id = params.model_id;
if model_id.is_empty() || model_id == "auto" {
// Fall back to default if nothing is set
model_id = "us.anthropic.claude-opus-4-6[1m]".to_string();
}
log::info!("[bedrock] Translator: model={model_id}, task_id={task_id}, needs_create_task={needs_create_task}");
let diagnostic_logger =
BedrockDiagnosticLogger::try_new(&model_id, "", "", &task_id).map(Arc::new);
if let Some(ref logger) = diagnostic_logger {
logger.log_protobuf_input(request);
}
request_translator::inject_input_messages_into_task(request);
let new_input_messages = request_translator::extract_new_input_messages(request);
let new_input_count = new_input_messages.len();
let mut messages = Vec::new();
// Prepend progressive summary as the first message pair if present
if let Some(ref summary) = params.bedrock_progressive_summary {
use crate::ai::bedrock::convert::{MessageContent, MessageRole};
messages.push(ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text(format!(
"<conversation-history-summary>\n{}\n</conversation-history-summary>\n\n\
The above summarizes earlier conversation history. The detailed messages below are the most recent exchanges.",
summary
)),
});
messages.push(ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::Text(
"Understood, I have the prior context. Continuing with the recent conversation."
.to_string(),
),
});
}
let history_len = params.bedrock_message_history.len();
messages.extend(params.bedrock_message_history);
if !new_input_messages.is_empty() {
log::info!(
"[bedrock] Appending {} new input messages to history of {}",
new_input_messages.len(),
history_len
);
messages.extend(new_input_messages);
}
for message in &mut messages {
message.truncate_tool_results_for_provider_request();
}
request_translator::sanitize_messages_for_bedrock(&mut messages);
let system_prompt = request_translator::extract_system_prompt(request, &params.global_rules);
let tools = request_translator::extract_tools(request);
if tools_are_inline_only(&tools) {
flatten_tool_history_for_no_tools_turn(&mut messages);
}
log::info!(
"[bedrock] Sending {} messages, system_prompt={}, progressive_summary={}, tools={}",
messages.len(),
system_prompt.is_some(),
params.bedrock_progressive_summary.is_some(),
tools.len()
);
for (i, msg) in messages.iter().enumerate() {
let content_desc = describe_message_content(&msg.content);
log::info!(
"[bedrock] msg[{}]: role={:?}, content={}",
i,
msg.role,
content_desc
);
}
let user_query_text = params
.emit_user_query_message
.then(|| request_translator::extract_user_query_text(request))
.flatten();
let stream = bedrock
.converse_stream(
&model_id,
&task_id,
needs_create_task,
messages.clone(),
system_prompt,
None, // progressive summary is in messages array, not system prompt
tools,
64000,
None,
cross_region_inference,
user_query_text,
diagnostic_logger,
params.bedrock_messages_sent.clone(),
params.bedrock_tool_result_archive,
)
.await?;
if let Ok(mut sent) = params.bedrock_messages_sent.lock() {
// Only persist the actual conversation history (history + new inputs), not the
// ephemeral prepended summary pair, so we don't duplicate the summary on every
// subsequent write-back. The summary is prepended at request time each turn.
let persistent_count = history_len + new_input_count;
if persistent_count > 0 && messages.len() >= persistent_count {
*sent = messages.split_off(messages.len() - persistent_count);
} else {
*sent = messages;
}
}
Ok(stream)
}
fn tools_are_inline_only(tools: &[crate::ai::bedrock::convert::ToolDefinition]) -> bool {
tools.iter().all(|tool| tool.name == "recall_tool_history")
}
fn describe_message_content(content: &crate::ai::bedrock::convert::MessageContent) -> String {
use crate::ai::bedrock::convert::{ContentPart, MessageContent};
match content {
MessageContent::Text(t) => format!("Text({}chars)", t.len()),
MessageContent::ToolUse {
tool_use_id, name, ..
} => format!("ToolUse(name={}, id={})", name, tool_use_id),
MessageContent::ToolResult {
tool_use_id,
is_error,
..
} => format!("ToolResult(id={}, is_error={})", tool_use_id, is_error),
MessageContent::MultiPart(parts) => {
let part_descs: Vec<String> = parts
.iter()
.map(|p| match p {
ContentPart::Text(t) => format!("Text({})", t.len()),
ContentPart::Reasoning { text, signature } => {
format!(
"Reasoning({}chars,signed={})",
text.len(),
signature.is_some()
)
}
ContentPart::Image { data, mime_type } => {
format!("Image({mime_type},{}bytes)", data.len())
}
ContentPart::ToolUse {
name, tool_use_id, ..
} => format!("ToolUse({},{})", name, tool_use_id),
ContentPart::ToolResult { tool_use_id, .. } => {
format!("ToolResult({})", tool_use_id)
}
})
.collect();
format!("MultiPart[{}]", part_descs.join(", "))
}
}
}
+239 -38
View File
@@ -35,7 +35,8 @@ pub use execute::{
}; };
use futures::future::{join_all, BoxFuture}; use futures::future::{join_all, BoxFuture};
use galaxy_agent_core::{ use galaxy_agent_core::{
PermissionDecision, PermissionKind, PermissionRequest, ToolEvent, ToolResult, ToolResultStatus, ExternalWorkId, PendingToolBatch, PermissionDecision, PermissionKind, PermissionRequest,
ToolEvent, ToolResult, ToolResultStatus,
}; };
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity}; use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use itertools::Itertools; use itertools::Itertools;
@@ -71,6 +72,7 @@ use crate::ai::document::ai_document_model::AIDocumentModel;
use crate::ai::get_relevant_files::controller::GetRelevantFilesController; use crate::ai::get_relevant_files::controller::GetRelevantFilesController;
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
use crate::ai::remote_logging::{self, RemoteLogLevel, RemoteLogRecord}; use crate::ai::remote_logging::{self, RemoteLogLevel, RemoteLogRecord};
use crate::ai::runtime::ProviderToolExecutionRef;
use crate::terminal::model::session::active_session::ActiveSession; use crate::terminal::model::session::active_session::ActiveSession;
use crate::terminal::model_events::ModelEventDispatcher; use crate::terminal::model_events::ModelEventDispatcher;
use crate::terminal::TerminalModel; use crate::terminal::TerminalModel;
@@ -172,6 +174,22 @@ struct RunningActions {
action_ids: Vec<AIAgentActionId>, action_ids: Vec<AIAgentActionId>,
} }
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub(super) enum ProviderActionQueueError {
#[error("provider action set mismatch: expected {expected:?}, received {received:?}")]
ActionSetMismatch {
expected: Vec<String>,
received: Vec<String>,
},
#[error("provider action '{call_id}' is already correlated to active work")]
ExistingCorrelation { call_id: String },
}
type ProviderActionCorrelation = (
(AIConversationId, AIAgentActionId),
ProviderToolExecutionRef,
);
impl RunningActions { impl RunningActions {
fn new(phase: RunningActionPhase, action_id: AIAgentActionId) -> Self { fn new(phase: RunningActionPhase, action_id: AIAgentActionId) -> Self {
Self { Self {
@@ -268,6 +286,13 @@ fn permission_kind_for_action(action: &AIAgentActionType) -> PermissionKind {
} }
} }
fn sort_action_results_by_order(
results: &mut [Arc<AIAgentActionResult>],
action_order: &HashMap<AIAgentActionId, usize>,
) {
results.sort_by_key(|result| action_order.get(&result.id).copied().unwrap_or(usize::MAX));
}
fn domain_tool_result(action_result: &AIAgentActionResult, permission_denied: bool) -> ToolResult { fn domain_tool_result(action_result: &AIAgentActionResult, permission_denied: bool) -> ToolResult {
let status = if permission_denied { let status = if permission_denied {
ToolResultStatus::Denied ToolResultStatus::Denied
@@ -626,6 +651,10 @@ pub struct BlocklistAIActionModel {
/// than reconstructing them from the legacy request protobuf. /// than reconstructing them from the legacy request protobuf.
finished_tool_results: HashMap<AIConversationId, Vec<ToolResult>>, finished_tool_results: HashMap<AIConversationId, Vec<ToolResult>>,
/// Provider-owned action results retained until their exact tool batch is fully committed.
provider_finished_action_results:
HashMap<(AIConversationId, ExternalWorkId), Vec<Arc<AIAgentActionResult>>>,
/// Original order for the current batch of actions. /// Original order for the current batch of actions.
/// ///
/// We maintain this so that even though we might process actions in parallel, /// We maintain this so that even though we might process actions in parallel,
@@ -635,6 +664,10 @@ pub struct BlocklistAIActionModel {
/// Permission-card rejections that still need a correlated completion event. /// Permission-card rejections that still need a correlated completion event.
denied_permissions: HashSet<(AIConversationId, AIAgentActionId)>, denied_permissions: HashSet<(AIConversationId, AIAgentActionId)>,
/// Durable provider work identity for actions owned by an active provider run.
provider_tool_executions:
HashMap<(AIConversationId, AIAgentActionId), ProviderToolExecutionRef>,
/// Past actions and their corresponding statuses from previous AI exchanges. /// Past actions and their corresponding statuses from previous AI exchanges.
past_action_results: HashMap<AIAgentActionId, Arc<AIAgentActionResult>>, past_action_results: HashMap<AIAgentActionId, Arc<AIAgentActionResult>>,
@@ -669,10 +702,18 @@ impl BlocklistAIActionModel {
) )
}); });
ctx.subscribe_to_model(&executor, move |me, _, event, ctx| match event { ctx.subscribe_to_model(&executor, move |me, _, event, ctx| match event {
BlocklistAIActionExecutorEvent::ExecutingAction { action_id } => { BlocklistAIActionExecutorEvent::ExecutingAction {
ctx.emit(BlocklistAIActionEvent::ExecutingAction(action_id.clone())); action_id,
conversation_id,
} => {
let execution_ref = me.provider_tool_execution_ref(*conversation_id, action_id);
ctx.emit(BlocklistAIActionEvent::ExecutingAction {
action_id: action_id.clone(),
execution_ref: execution_ref.clone(),
});
ctx.emit(BlocklistAIActionEvent::ToolLifecycle { ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
action_id: action_id.clone(), action_id: action_id.clone(),
execution_ref,
event: ToolEvent::Started { event: ToolEvent::Started {
call_id: action_id.to_string(), call_id: action_id.to_string(),
}, },
@@ -710,11 +751,13 @@ impl BlocklistAIActionModel {
pending_actions: Default::default(), pending_actions: Default::default(),
finished_action_results: Default::default(), finished_action_results: Default::default(),
finished_tool_results: Default::default(), finished_tool_results: Default::default(),
provider_finished_action_results: Default::default(),
executor, executor,
past_action_results: HashMap::new(), past_action_results: HashMap::new(),
running_actions: Default::default(), running_actions: Default::default(),
action_order: Default::default(), action_order: Default::default(),
denied_permissions: Default::default(), denied_permissions: Default::default(),
provider_tool_executions: Default::default(),
terminal_view_id, terminal_view_id,
pending_preprocessed_actions: Default::default(), pending_preprocessed_actions: Default::default(),
is_view_only: false, is_view_only: false,
@@ -752,7 +795,10 @@ impl BlocklistAIActionModel {
action_id.clone(), action_id.clone(),
RunningActionPhase::Serial, RunningActionPhase::Serial,
); );
ctx.emit(BlocklistAIActionEvent::ExecutingAction(action_id.clone())); ctx.emit(BlocklistAIActionEvent::ExecutingAction {
action_id: action_id.clone(),
execution_ref: self.provider_tool_execution_ref(conversation_id, action_id),
});
} }
/// Returns true if the action model is operating in view-only mode (used for shared-session viewers). /// Returns true if the action model is operating in view-only mode (used for shared-session viewers).
@@ -942,9 +988,14 @@ impl BlocklistAIActionModel {
fn sort_finished_results(&mut self, conversation_id: AIConversationId) { fn sort_finished_results(&mut self, conversation_id: AIConversationId) {
if let Some(action_order) = self.action_order.get(&conversation_id) { if let Some(action_order) = self.action_order.get(&conversation_id) {
if let Some(finished_results) = self.finished_action_results.get_mut(&conversation_id) { if let Some(finished_results) = self.finished_action_results.get_mut(&conversation_id) {
finished_results.sort_by_key(|result| { sort_action_results_by_order(finished_results, action_order);
action_order.get(&result.id).copied().unwrap_or(usize::MAX) }
}); for ((finished_conversation_id, _), finished_results) in
&mut self.provider_finished_action_results
{
if *finished_conversation_id == conversation_id {
sort_action_results_by_order(finished_results, action_order);
}
} }
if let Some(tool_results) = self.finished_tool_results.get_mut(&conversation_id) { if let Some(tool_results) = self.finished_tool_results.get_mut(&conversation_id) {
let tool_order = action_order let tool_order = action_order
@@ -1113,6 +1164,7 @@ impl BlocklistAIActionModel {
// Search through all conversations' finished action results // Search through all conversations' finished action results
self.finished_action_results self.finished_action_results
.values() .values()
.chain(self.provider_finished_action_results.values())
.flat_map(|results| results.iter()) .flat_map(|results| results.iter())
.find(|result| &result.id == id) .find(|result| &result.id == id)
.or_else(|| self.past_action_results.get(id)) .or_else(|| self.past_action_results.get(id))
@@ -1288,11 +1340,14 @@ impl BlocklistAIActionModel {
"reason": format!("{reason:?}"), "reason": format!("{reason:?}"),
}), }),
); );
ctx.emit(BlocklistAIActionEvent::ActionBlockedOnUserConfirmation( let execution_ref = self.provider_tool_execution_ref(conversation_id, &action.id);
action.id.clone(), ctx.emit(BlocklistAIActionEvent::ActionBlockedOnUserConfirmation {
)); action_id: action.id.clone(),
execution_ref: execution_ref.clone(),
});
ctx.emit(BlocklistAIActionEvent::ToolLifecycle { ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
action_id: action.id.clone(), action_id: action.id.clone(),
execution_ref,
event: ToolEvent::PermissionRequested { event: ToolEvent::PermissionRequested {
request: PermissionRequest { request: PermissionRequest {
id: permission_request_id(&action.id), id: permission_request_id(&action.id),
@@ -1389,6 +1444,7 @@ impl BlocklistAIActionModel {
); );
ctx.emit(BlocklistAIActionEvent::ToolLifecycle { ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
action_id: action_id.clone(), action_id: action_id.clone(),
execution_ref: self.provider_tool_execution_ref(conversation_id, &action_id),
event: ToolEvent::PermissionResolved { event: ToolEvent::PermissionResolved {
request_id: permission_request_id(&action_id), request_id: permission_request_id(&action_id),
call_id: action_id.to_string(), call_id: action_id.to_string(),
@@ -1461,6 +1517,40 @@ impl BlocklistAIActionModel {
}) })
} }
fn provider_tool_execution_ref(
&self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) -> Option<ProviderToolExecutionRef> {
self.provider_tool_executions
.get(&(conversation_id, action_id.clone()))
.cloned()
}
#[allow(dead_code)]
pub(super) fn queue_provider_actions(
&mut self,
actions: Vec<AIAgentAction>,
conversation_id: AIConversationId,
batch: &PendingToolBatch,
ctx: &mut ModelContext<Self>,
) -> Result<(), ProviderActionQueueError> {
let refs = provider_action_correlations(&actions, conversation_id, batch)?;
for ((_, action_id), _) in &refs {
if self
.provider_tool_executions
.contains_key(&(conversation_id, action_id.clone()))
{
return Err(ProviderActionQueueError::ExistingCorrelation {
call_id: action_id.to_string(),
});
}
}
self.provider_tool_executions.extend(refs);
self.queue_actions(actions, conversation_id, ctx);
Ok(())
}
/// Queues the `actions` in the given iterator for the given conversation, /// Queues the `actions` in the given iterator for the given conversation,
/// to be dispatched in the order in which they appear in the iterator. /// to be dispatched in the order in which they appear in the iterator.
pub(super) fn queue_actions( pub(super) fn queue_actions(
@@ -1553,11 +1643,18 @@ impl BlocklistAIActionModel {
// as otherwise tools get stuck in a pending state on the viewer's side of things. This check // as otherwise tools get stuck in a pending state on the viewer's side of things. This check
// must be scoped to the current conversation as some providers generate tool call IDs that // must be scoped to the current conversation as some providers generate tool call IDs that
// only unique within a conversation. // only unique within a conversation.
if self let has_finished_result = self
.finished_action_results .finished_action_results
.get(&conversation_id) .get(&conversation_id)
.is_some_and(|results| results.iter().any(|r| r.id == action_id)) .is_some_and(|results| results.iter().any(|result| result.id == action_id))
{ || self
.provider_finished_action_results
.iter()
.filter(|((finished_conversation_id, _), _)| {
*finished_conversation_id == conversation_id
})
.any(|(_, results)| results.iter().any(|result| result.id == action_id));
if has_finished_result {
continue; continue;
} }
@@ -1577,7 +1674,10 @@ impl BlocklistAIActionModel {
.entry(conversation_id) .entry(conversation_id)
.or_default() .or_default()
.push_back(action); .push_back(action);
ctx.emit(BlocklistAIActionEvent::QueuedAction(action_id)); ctx.emit(BlocklistAIActionEvent::QueuedAction {
execution_ref: self.provider_tool_execution_ref(conversation_id, &action_id),
action_id,
});
} }
self.try_to_execute_available_actions(conversation_id, ctx); self.try_to_execute_available_actions(conversation_id, ctx);
} }
@@ -1681,6 +1781,14 @@ impl BlocklistAIActionModel {
executor.cancel_all_running_async_actions_for_conversation(conversation_id, reason, ctx) executor.cancel_all_running_async_actions_for_conversation(conversation_id, reason, ctx)
}); });
if let Some(preprocessing) = self.pending_preprocessed_actions.remove(&conversation_id) {
self.provider_tool_executions
.retain(|(correlated_conversation_id, action_id), _| {
*correlated_conversation_id != conversation_id
|| !preprocessing.contains(action_id)
});
}
let Some(actions_to_cancel) = self.pending_actions.get_mut(&conversation_id) else { let Some(actions_to_cancel) = self.pending_actions.get_mut(&conversation_id) else {
return; return;
}; };
@@ -1752,10 +1860,14 @@ impl BlocklistAIActionModel {
); );
ctx.emit(BlocklistAIActionEvent::ToolLifecycle { ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
action_id: pending_action.id.clone(), action_id: pending_action.id.clone(),
execution_ref: self
.provider_tool_execution_ref(conversation_id, &pending_action.id),
event: ToolEvent::PermissionResolved { event: ToolEvent::PermissionResolved {
request_id: permission_request_id(&pending_action.id), request_id: permission_request_id(&pending_action.id),
call_id: pending_action.id.to_string(), call_id: pending_action.id.to_string(),
decision: PermissionDecision::Denied { reason: None }, decision: PermissionDecision::Denied {
reason: Some("Permission denied by the user.".to_string()),
},
}, },
}); });
} }
@@ -1817,11 +1929,40 @@ impl BlocklistAIActionModel {
.unwrap_or_default() .unwrap_or_default()
} }
pub(super) fn provider_finished_action_results(
&self,
conversation_id: AIConversationId,
work_id: &ExternalWorkId,
) -> Vec<Arc<AIAgentActionResult>> {
self.provider_finished_action_results
.get(&(conversation_id, work_id.clone()))
.cloned()
.unwrap_or_default()
}
pub(super) fn archive_provider_finished_action_results(
&mut self,
conversation_id: AIConversationId,
work_id: &ExternalWorkId,
) {
let results = self
.provider_finished_action_results
.remove(&(conversation_id, work_id.clone()))
.unwrap_or_default();
for result in results {
self.past_action_results.insert(result.id.clone(), result);
}
}
/// Clears finished action results for a conversation. Used when reverting. /// Clears finished action results for a conversation. Used when reverting.
pub(super) fn clear_finished_action_results(&mut self, conversation_id: AIConversationId) { pub(super) fn clear_finished_action_results(&mut self, conversation_id: AIConversationId) {
self.action_order.remove(&conversation_id); self.action_order.remove(&conversation_id);
self.finished_action_results.remove(&conversation_id); self.finished_action_results.remove(&conversation_id);
self.finished_tool_results.remove(&conversation_id); self.finished_tool_results.remove(&conversation_id);
self.provider_finished_action_results
.retain(|(finished_conversation_id, _), _| {
*finished_conversation_id != conversation_id
});
} }
#[cfg(test)] #[cfg(test)]
@@ -1921,14 +2062,19 @@ impl BlocklistAIActionModel {
} }
} }
let execution_ref = self
.provider_tool_executions
.remove(&(conversation_id, action_result.id.clone()));
let permission_denied = self let permission_denied = self
.denied_permissions .denied_permissions
.remove(&(conversation_id, action_result.id.clone())); .remove(&(conversation_id, action_result.id.clone()));
let tool_result = domain_tool_result(&action_result, permission_denied); let tool_result = domain_tool_result(&action_result, permission_denied);
self.finished_tool_results if execution_ref.is_none() {
.entry(conversation_id) self.finished_tool_results
.or_default() .entry(conversation_id)
.push(tool_result.clone()); .or_default()
.push(tool_result.clone());
}
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
log_tool_event( log_tool_event(
ctx, ctx,
@@ -1955,17 +2101,29 @@ impl BlocklistAIActionModel {
"error": action_result_error_summary(&action_result.result), "error": action_result_error_summary(&action_result.result),
}), }),
); );
ctx.emit(BlocklistAIActionEvent::ToolLifecycle { // Permission denial completes provider-owned calls when the permission decision is
action_id: action_result.id.clone(), // applied, so emitting a second correlated completion would violate exactly-once delivery.
event: ToolEvent::Completed { if execution_ref.is_none() || !permission_denied {
result: tool_result, ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
}, action_id: action_result.id.clone(),
}); execution_ref: execution_ref.clone(),
event: ToolEvent::Completed {
result: tool_result,
},
});
}
self.finished_action_results if let Some(execution_ref) = &execution_ref {
.entry(conversation_id) self.provider_finished_action_results
.or_default() .entry((conversation_id, execution_ref.work_id()))
.push(action_result); .or_default()
.push(action_result);
} else {
self.finished_action_results
.entry(conversation_id)
.or_default()
.push(action_result);
}
if self if self
.running_actions .running_actions
@@ -1986,6 +2144,7 @@ impl BlocklistAIActionModel {
action_id, action_id,
conversation_id, conversation_id,
cancellation_reason, cancellation_reason,
execution_ref: execution_ref.clone(),
}); });
if self if self
@@ -1999,8 +2158,10 @@ impl BlocklistAIActionModel {
// completion (no cancellation reason) is resolved by the controller's // completion (no cancellation reason) is resolved by the controller's
// follow-up handling. Stamping here for any of those would clobber the real // follow-up handling. Stamping here for any of those would clobber the real
// status and message. // status and message.
if cancellation_reason if execution_ref.is_none()
.is_some_and(|r| matches!(r.conversation_outcome(), CancellationOutcome::Cancelled)) && cancellation_reason.is_some_and(|r| {
matches!(r.conversation_outcome(), CancellationOutcome::Cancelled)
})
{ {
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
// Treat action result as authoritative for determining status. // Treat action result as authoritative for determining status.
@@ -2110,23 +2271,63 @@ impl BlocklistAIActionModel {
} }
} }
fn provider_action_correlations(
actions: &[AIAgentAction],
conversation_id: AIConversationId,
batch: &PendingToolBatch,
) -> Result<Vec<ProviderActionCorrelation>, ProviderActionQueueError> {
let expected = batch.unresolved_call_ids();
let received = actions
.iter()
.map(|action| action.id.to_string())
.collect::<Vec<_>>();
if expected != received {
return Err(ProviderActionQueueError::ActionSetMismatch { expected, received });
}
Ok(actions
.iter()
.map(|action| {
(
(conversation_id, action.id.clone()),
ProviderToolExecutionRef::new(
conversation_id,
&batch.work_id,
action.id.to_string(),
),
)
})
.collect())
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum BlocklistAIActionEvent { pub enum BlocklistAIActionEvent {
/// Emitted when the action with the given ID is enqueued for execution. /// Emitted when the action with the given ID is enqueued for execution.
QueuedAction(AIAgentActionId), QueuedAction {
action_id: AIAgentActionId,
execution_ref: Option<ProviderToolExecutionRef>,
},
/// Emitted when the action with the given ID requires user confirmation to execute. /// Emitted when the action with the given ID requires user confirmation to execute.
ActionBlockedOnUserConfirmation(AIAgentActionId), ActionBlockedOnUserConfirmation {
action_id: AIAgentActionId,
execution_ref: Option<ProviderToolExecutionRef>,
},
/// Emitted when the action with the given ID begins execution. /// Emitted when the action with the given ID begins execution.
ExecutingAction(AIAgentActionId), ExecutingAction {
action_id: AIAgentActionId,
execution_ref: Option<ProviderToolExecutionRef>,
},
/// Emitted when the action with the given ID has finished. /// Emitted when the action with the given ID has finished.
FinishedAction { FinishedAction {
action_id: AIAgentActionId, action_id: AIAgentActionId,
conversation_id: AIConversationId, conversation_id: AIConversationId,
cancellation_reason: Option<CancellationReason>, cancellation_reason: Option<CancellationReason>,
execution_ref: Option<ProviderToolExecutionRef>,
}, },
/// Provider-neutral permission and execution lifecycle event for runtime consumers. /// Provider-neutral permission and execution lifecycle event for runtime consumers.
ToolLifecycle { ToolLifecycle {
action_id: AIAgentActionId, action_id: AIAgentActionId,
execution_ref: Option<ProviderToolExecutionRef>,
event: ToolEvent, event: ToolEvent,
}, },
InitProject(AIAgentActionId), InitProject(AIAgentActionId),
@@ -2142,10 +2343,10 @@ pub enum BlocklistAIActionEvent {
impl BlocklistAIActionEvent { impl BlocklistAIActionEvent {
pub fn action_id(&self) -> &AIAgentActionId { pub fn action_id(&self) -> &AIAgentActionId {
match self { match self {
BlocklistAIActionEvent::QueuedAction(action_id) => action_id, BlocklistAIActionEvent::QueuedAction { action_id, .. }
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(action_id) => action_id, | BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { action_id, .. }
BlocklistAIActionEvent::ExecutingAction(action_id) => action_id, | BlocklistAIActionEvent::ExecutingAction { action_id, .. }
BlocklistAIActionEvent::FinishedAction { action_id, .. } => action_id, | BlocklistAIActionEvent::FinishedAction { action_id, .. } => action_id,
BlocklistAIActionEvent::ToolLifecycle { action_id, .. } => action_id, BlocklistAIActionEvent::ToolLifecycle { action_id, .. } => action_id,
BlocklistAIActionEvent::InitProject(action_id) => action_id, BlocklistAIActionEvent::InitProject(action_id) => action_id,
BlocklistAIActionEvent::ToggleCodeReview(action_id) => action_id, BlocklistAIActionEvent::ToggleCodeReview(action_id) => action_id,
+5 -1
View File
@@ -688,6 +688,7 @@ impl BlocklistAIActionExecutor {
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction { ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
action_id: action_id.clone(), action_id: action_id.clone(),
conversation_id,
}); });
ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction { ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction {
result: Arc::new(AIAgentActionResult { result: Arc::new(AIAgentActionResult {
@@ -904,6 +905,7 @@ impl BlocklistAIActionExecutor {
); );
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction { ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
action_id: action_id.clone(), action_id: action_id.clone(),
conversation_id,
}); });
log::info!("[tool-debug] try_to_execute_action: spawning ASYNC execution for action_id={:?}", action_id); log::info!("[tool-debug] try_to_execute_action: spawning ASYNC execution for action_id={:?}", action_id);
ctx.spawn(execute_future, move |me, result, ctx| { ctx.spawn(execute_future, move |me, result, ctx| {
@@ -932,6 +934,7 @@ impl BlocklistAIActionExecutor {
AnyActionExecution::Sync(action_result) => { AnyActionExecution::Sync(action_result) => {
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction { ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
action_id: action_id.clone(), action_id: action_id.clone(),
conversation_id,
}); });
ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction { ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction {
result: Arc::new(AIAgentActionResult { result: Arc::new(AIAgentActionResult {
@@ -1140,9 +1143,10 @@ impl Entity for BlocklistAIActionExecutor {
} }
pub enum BlocklistAIActionExecutorEvent { pub enum BlocklistAIActionExecutorEvent {
/// Emitted when an action is execution starts. /// Emitted when an action begins execution.
ExecutingAction { ExecutingAction {
action_id: AIAgentActionId, action_id: AIAgentActionId,
conversation_id: AIConversationId,
}, },
/// Emitted when an action has finished. /// Emitted when an action has finished.
+61 -3
View File
@@ -4,7 +4,8 @@ use std::sync::Arc;
use super::*; use super::*;
use crate::ai::agent::task::TaskId; use crate::ai::agent::task::TaskId;
use crate::ai::agent::{ use crate::ai::agent::{
AIAgentActionResultType, AnyFileContent, FileContext, GrepResult, ReadFilesResult, AIAgentAction, AIAgentActionResultType, AIAgentActionType, AnyFileContent, FileContext,
GrepResult, ReadFilesResult,
}; };
fn make_action_result(id: &str) -> Arc<AIAgentActionResult> { fn make_action_result(id: &str) -> Arc<AIAgentActionResult> {
@@ -23,6 +24,36 @@ fn action_result(id: &str, result: AIAgentActionResultType) -> AIAgentActionResu
} }
} }
fn action(id: &str) -> AIAgentAction {
AIAgentAction {
id: AIAgentActionId::from(id.to_string()),
action: AIAgentActionType::InitProject,
task_id: TaskId::new("task".to_string()),
requires_result: true,
tool_name: Some("init_project".to_string()),
}
}
fn pending_tool_batch(call_ids: &[&str]) -> PendingToolBatch {
PendingToolBatch {
work_id: galaxy_agent_core::ExternalWorkId {
run_id: galaxy_agent_core::ProviderRunId::new("run"),
epoch: galaxy_agent_core::RunEpoch::new(7),
},
calls: call_ids
.iter()
.map(|call_id| galaxy_agent_core::PendingToolCall {
call: galaxy_agent_core::ToolCall {
id: (*call_id).to_string(),
name: "init_project".to_string(),
arguments: serde_json::json!({}),
},
state: galaxy_agent_core::PendingToolCallState::Proposed,
})
.collect(),
}
}
fn count_startable_actions_for_pass(phases: &[(RunningActionPhase, bool)]) -> usize { fn count_startable_actions_for_pass(phases: &[(RunningActionPhase, bool)]) -> usize {
let mut current_phase = None; let mut current_phase = None;
let mut count = 0; let mut count = 0;
@@ -45,6 +76,34 @@ fn count_startable_actions_for_pass(phases: &[(RunningActionPhase, bool)]) -> us
count count
} }
#[test]
fn provider_action_correlations_require_the_exact_unresolved_batch_order() {
let conversation_id = AIConversationId::new();
let batch = pending_tool_batch(&["first", "second"]);
let actions = vec![action("first"), action("second")];
let correlations = provider_action_correlations(&actions, conversation_id, &batch).unwrap();
assert_eq!(correlations.len(), 2);
assert_eq!(correlations[0].0, (conversation_id, actions[0].id.clone()));
assert_eq!(correlations[0].1.run_id, batch.work_id.run_id);
assert_eq!(correlations[0].1.epoch, batch.work_id.epoch);
assert_eq!(correlations[0].1.call_id, "first");
let error = provider_action_correlations(
&[action("second"), action("first")],
conversation_id,
&batch,
)
.unwrap_err();
assert_eq!(
error,
ProviderActionQueueError::ActionSetMismatch {
expected: vec!["first".to_string(), "second".to_string()],
received: vec!["second".to_string(), "first".to_string()],
}
);
}
#[test] #[test]
fn parallel_phase_only_admits_matching_autoexecutable_actions() { fn parallel_phase_only_admits_matching_autoexecutable_actions() {
let phase = let phase =
@@ -94,8 +153,7 @@ fn finished_results_stay_in_original_action_order() {
make_action_result("second"), make_action_result("second"),
]; ];
finished_results sort_action_results_by_order(&mut finished_results, &action_order);
.sort_by_key(|result| action_order.get(&result.id).copied().unwrap_or(usize::MAX));
assert_eq!( assert_eq!(
finished_results[0].id, finished_results[0].id,
+3 -3
View File
@@ -4723,7 +4723,7 @@ impl AIBlock {
} }
match event { match event {
BlocklistAIActionEvent::ExecutingAction(..) => { BlocklistAIActionEvent::ExecutingAction { .. } => {
match &me.autonomy_setting_speedbump { match &me.autonomy_setting_speedbump {
AutonomySettingSpeedbump::ShouldShowForAutoexecutingReadonlyCommands { AutonomySettingSpeedbump::ShouldShowForAutoexecutingReadonlyCommands {
action_id: speedbump_action_id, action_id: speedbump_action_id,
@@ -4793,7 +4793,7 @@ impl AIBlock {
_ => {} _ => {}
} }
} }
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(..) => { BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { .. } => {
ctx.emit(AIBlockEvent::ActionBlockedOnUserConfirmation); ctx.emit(AIBlockEvent::ActionBlockedOnUserConfirmation);
} }
BlocklistAIActionEvent::FinishedAction { action_id, .. } => { BlocklistAIActionEvent::FinishedAction { action_id, .. } => {
@@ -4950,7 +4950,7 @@ impl AIBlock {
} }
ctx.notify(); ctx.notify();
} }
BlocklistAIActionEvent::QueuedAction(action_id) => { BlocklistAIActionEvent::QueuedAction { action_id, .. } => {
// Update search codebase view status when action is queued // Update search codebase view status when action is queued
if let Some(view) = me.search_codebase_view.get(action_id) { if let Some(view) = me.search_codebase_view.get(action_id) {
view.update(ctx, |view, ctx| { view.update(ctx, |view, ctx| {
+40 -15
View File
@@ -15,6 +15,7 @@ use crate::ai::agent::{
}; };
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewEntryOrigin}; use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewEntryOrigin};
use crate::ai::blocklist::context_model::block_context_from_terminal_model; use crate::ai::blocklist::context_model::block_context_from_terminal_model;
use crate::ai::blocklist::controller::PendingProviderCommandCompletion;
use crate::ai::blocklist::{ use crate::ai::blocklist::{
BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIController, BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIController,
BlocklistAIControllerEvent, BlocklistAIHistoryEvent, BlocklistAIControllerEvent, BlocklistAIHistoryEvent,
@@ -56,6 +57,7 @@ struct PendingCommandCompletion {
initial_requested_command_action_id: Option<AIAgentActionId>, initial_requested_command_action_id: Option<AIAgentActionId>,
prompt: String, prompt: String,
completed_command: RunningCommand, completed_command: RunningCommand,
exit_code: i32,
final_turn_started: bool, final_turn_started: bool,
} }
@@ -169,7 +171,7 @@ impl CLISubagentController {
}); });
ctx.subscribe_to_model(action_model, |me, _, event, ctx| match event { ctx.subscribe_to_model(action_model, |me, _, event, ctx| match event {
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(_) => { BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { .. } => {
let mut terminal_model = me.terminal_model.lock(); let mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut(); let active_block = terminal_model.block_list_mut().active_block_mut();
active_block.update_is_agent_blocked(true); active_block.update_is_agent_blocked(true);
@@ -181,7 +183,7 @@ impl CLISubagentController {
agent_has_control: active_block.is_agent_in_control(), agent_has_control: active_block.is_agent_in_control(),
}); });
} }
BlocklistAIActionEvent::ExecutingAction(..) => { BlocklistAIActionEvent::ExecutingAction { .. } => {
let mut terminal_model = me.terminal_model.lock(); let mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut(); let active_block = terminal_model.block_list_mut().active_block_mut();
active_block.update_is_agent_blocked(false); active_block.update_is_agent_blocked(false);
@@ -303,6 +305,7 @@ impl CLISubagentController {
requested_command_id: requested_command_action_id.clone(), requested_command_id: requested_command_action_id.clone(),
is_alt_screen_active: false, is_alt_screen_active: false,
}, },
exit_code,
final_turn_started: false, final_turn_started: false,
}) })
} }
@@ -319,16 +322,40 @@ impl CLISubagentController {
}; };
drop(terminal_model); drop(terminal_model);
let Some(has_last_snapshot) = me let provider_consumed_completion = completion.as_ref().is_some_and(|completion| {
me.controller.update(ctx, |controller, ctx| {
controller.accept_provider_command_completion(
completion.conversation_id,
PendingProviderCommandCompletion::new(
completion.completed_command.block_id.clone(),
completion.initial_requested_command_action_id.clone(),
completion.completed_command.command.clone(),
completion.completed_command.grid_contents.clone(),
completion.exit_code,
),
ctx,
)
})
});
let has_last_snapshot = me
.active_subagents_by_block .active_subagents_by_block
.get(&block_id) .get(&block_id)
.map(|state| state.last_snapshot_at.is_some()) .is_some_and(|state| state.last_snapshot_at.is_some());
else {
return;
};
if has_last_snapshot { if has_last_snapshot {
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot); ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
} }
if provider_consumed_completion {
me.finish_subagent(
&block_id,
conversation_id,
requested_command_action_id,
ctx,
);
return;
}
if !me.active_subagents_by_block.contains_key(&block_id) {
return;
}
// A Stop takeover intentionally cancels the subagent. The command may still // A Stop takeover intentionally cancels the subagent. The command may still
// finish later, but that completion must not start a new assessment turn. Also // finish later, but that completion must not start a new assessment turn. Also
@@ -483,7 +510,11 @@ impl CLISubagentController {
if self if self
.controller .controller
.as_ref(ctx) .as_ref(ctx)
.has_active_stream_for_conversation(conversation_id, ctx) .has_active_provider_run(conversation_id)
|| self
.controller
.as_ref(ctx)
.has_active_stream_for_conversation(conversation_id, ctx)
|| self || self
.action_model .action_model
.as_ref(ctx) .as_ref(ctx)
@@ -737,13 +768,7 @@ impl CLISubagentController {
.collect() .collect()
}; };
self.controller.update(ctx, |controller, ctx| { self.controller.update(ctx, |controller, ctx| {
controller.resume_conversation( controller.resume_conversation(conversation_id, resume_context, ctx);
conversation_id,
/*can_attempt_resume_on_error*/ true,
/*is_auto_resume_after_error*/ false,
resume_context,
ctx,
);
}); });
} }
} }
+1 -1
View File
@@ -329,7 +329,7 @@ impl BlocklistAIStatusBar {
); );
ctx.subscribe_to_model(&action_model, |_, _, event, ctx| match event { ctx.subscribe_to_model(&action_model, |_, _, event, ctx| match event {
BlocklistAIActionEvent::ExecutingAction(..) BlocklistAIActionEvent::ExecutingAction { .. }
| BlocklistAIActionEvent::FinishedAction { .. } => ctx.notify(), | BlocklistAIActionEvent::FinishedAction { .. } => ctx.notify(),
_ => (), _ => (),
}); });
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,88 +1,4 @@
use super::{is_interactive_remote_command, recovery_action, RecoveryAction}; use super::is_interactive_remote_command;
// Argument order: has_received_client_actions, is_recoverable, has_retry_budget,
// can_attempt_resume_on_error, is_online.
#[test]
fn pre_action_failures_retry() {
assert_eq!(
recovery_action(false, true, true, true, true),
RecoveryAction::RetryNow
);
// Resume eligibility is irrelevant pre-actions.
assert_eq!(
recovery_action(false, true, true, false, true),
RecoveryAction::RetryNow
);
}
#[test]
fn pre_action_failures_wait_for_connectivity_when_offline() {
assert_eq!(
recovery_action(false, true, true, true, false),
RecoveryAction::RetryWhenOnline
);
}
#[test]
fn pre_action_budget_exhaustion_is_terminal() {
// The request has already been retried MAX_RETRIES times; stop.
assert_eq!(
recovery_action(false, true, false, true, true),
RecoveryAction::Fail
);
assert_eq!(
recovery_action(false, true, false, true, false),
RecoveryAction::Fail
);
}
#[test]
fn non_recoverable_pre_action_failure_is_terminal() {
assert_eq!(
recovery_action(false, false, true, true, true),
RecoveryAction::Fail
);
}
#[test]
fn post_action_recoverable_failures_resume() {
assert_eq!(
recovery_action(true, true, true, true, true),
RecoveryAction::Resume
);
// Offline doesn't change the decision; the resume spawn waits for connectivity.
assert_eq!(
recovery_action(true, true, true, true, false),
RecoveryAction::Resume
);
// The in-request retry budget is irrelevant once actions have executed.
assert_eq!(
recovery_action(true, true, false, true, true),
RecoveryAction::Resume
);
}
#[test]
fn post_action_failures_without_resume_eligibility_are_terminal() {
// Resume requests themselves run with can_attempt_resume_on_error=false,
// bounding recovery to a single resume.
assert_eq!(
recovery_action(true, true, true, false, true),
RecoveryAction::Fail
);
}
#[test]
fn non_recoverable_post_action_failure_is_terminal() {
// A non-recoverable error (e.g. a client error) ends the conversation even
// after actions have executed.
assert_eq!(
recovery_action(true, false, true, true, true),
RecoveryAction::Fail
);
}
#[test] #[test]
fn raw_interactive_ssh_is_treated_as_remote_for_acp() { fn raw_interactive_ssh_is_treated_as_remote_for_acp() {
for command in [ for command in [
@@ -194,7 +194,6 @@ impl SlashCommandRequest {
entrypoint, entrypoint,
is_auto_resume_after_error: false, is_auto_resume_after_error: false,
}), }),
/*can_attempt_resume_on_error*/ true,
is_queued_prompt, is_queued_prompt,
ctx, ctx,
) { ) {
File diff suppressed because it is too large Load Diff
+43
View File
@@ -563,6 +563,44 @@ impl BlocklistAIHistoryModel {
conversation.write_updated_conversation_state(ctx); conversation.write_updated_conversation_state(ctx);
} }
pub(crate) fn persist_active_provider_run_json(
&mut self,
conversation_id: AIConversationId,
snapshot: Option<String>,
ctx: &mut ModelContext<Self>,
) -> Result<(), UpdateHistoryError> {
let conversation = self
.conversations_by_id
.get_mut(&conversation_id)
.ok_or(UpdateHistoryError::ConversationNotFound(conversation_id))?;
conversation.set_active_provider_run_json(snapshot);
conversation.write_updated_conversation_state(ctx);
Ok(())
}
pub(crate) fn rebind_provider_projection(
&mut self,
conversation_id: AIConversationId,
task_id: &TaskId,
exchange_id: AIAgentExchangeId,
response_stream_id: ResponseStreamId,
terminal_surface_id: EntityId,
ctx: &mut ModelContext<Self>,
) -> Result<(), UpdateHistoryError> {
let conversation = self
.conversations_by_id
.get_mut(&conversation_id)
.ok_or(UpdateHistoryError::ConversationNotFound(conversation_id))?;
conversation.rebind_provider_projection(
task_id,
exchange_id,
response_stream_id,
terminal_surface_id,
ctx,
)?;
Ok(())
}
fn update_cached_metadata_for_conversation(&mut self, conversation_id: AIConversationId) { fn update_cached_metadata_for_conversation(&mut self, conversation_id: AIConversationId) {
let Some(conversation) = self.conversations_by_id.get(&conversation_id) else { let Some(conversation) = self.conversations_by_id.get(&conversation_id) else {
return; return;
@@ -1652,6 +1690,7 @@ impl BlocklistAIHistoryModel {
let conversation_data = AgentConversationData { let conversation_data = AgentConversationData {
agent_backend: source_conversation.agent_backend().for_fork(), agent_backend: source_conversation.agent_backend().for_fork(),
active_provider_run_json: None,
server_conversation_token: None, server_conversation_token: None,
conversation_usage_metadata: Some(source_conversation.usage_metadata()), conversation_usage_metadata: Some(source_conversation.usage_metadata()),
reverted_action_ids, reverted_action_ids,
@@ -1816,6 +1855,7 @@ impl BlocklistAIHistoryModel {
// be recomputed based on the retained exchanges in a follow-up. // be recomputed based on the retained exchanges in a follow-up.
let conversation_data = AgentConversationData { let conversation_data = AgentConversationData {
agent_backend: conversation.agent_backend().for_fork(), agent_backend: conversation.agent_backend().for_fork(),
active_provider_run_json: None,
server_conversation_token: None, server_conversation_token: None,
conversation_usage_metadata: None, conversation_usage_metadata: None,
reverted_action_ids, reverted_action_ids,
@@ -2824,6 +2864,9 @@ fn merged_remote_child_placeholder_conversation_data(
// Placeholder authoritative. // Placeholder authoritative.
agent_backend: placeholder.agent_backend().clone(), agent_backend: placeholder.agent_backend().clone(),
// Active process-local provider runs cannot be merged from a cloud transcript.
active_provider_run_json: None,
// Cloud authoritative. // Cloud authoritative.
server_conversation_token: cloud_conversation server_conversation_token: cloud_conversation
.server_conversation_token() .server_conversation_token()
@@ -418,21 +418,21 @@ impl RequestedCommandView {
if !is_finished { if !is_finished {
ctx.subscribe_to_model(action_model, |me, _, event, ctx| { ctx.subscribe_to_model(action_model, |me, _, event, ctx| {
match event { match event {
BlocklistAIActionEvent::QueuedAction(action_id) BlocklistAIActionEvent::QueuedAction { action_id, .. }
if *action_id == me.action_id => if *action_id == me.action_id =>
{ {
ctx.notify(); ctx.notify();
} }
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(action_id) BlocklistAIActionEvent::ActionBlockedOnUserConfirmation {
if *action_id == me.action_id => action_id, ..
{ } if *action_id == me.action_id => {
if me.action_type.is_requested_command() { if me.action_type.is_requested_command() {
me.ensure_editor(ctx); me.ensure_editor(ctx);
} }
me.set_is_header_expanded(true, ctx); me.set_is_header_expanded(true, ctx);
ctx.notify(); ctx.notify();
} }
BlocklistAIActionEvent::ExecutingAction(action_id) BlocklistAIActionEvent::ExecutingAction { action_id, .. }
if *action_id == me.action_id => if *action_id == me.action_id =>
{ {
// For shared-session viewers, sync the command text from the action when it starts executing. // For shared-session viewers, sync the command text from the action when it starts executing.
@@ -376,7 +376,7 @@ impl RunAgentsCardView {
{ {
ctx.notify(); ctx.notify();
} }
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(action_id) BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { action_id, .. }
if action_id == &action_id_for_action_events => if action_id == &action_id_for_action_events =>
{ {
// Normal case: streaming is complete and the action is // Normal case: streaming is complete and the action is
@@ -11,7 +11,6 @@ use warpui::r#async::SpawnedFutureHandle;
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity}; use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use super::super::controller::{BlocklistAIController, BlocklistAIControllerEvent}; use super::super::controller::{BlocklistAIController, BlocklistAIControllerEvent};
use crate::ai::agent::api::generate_multi_agent_output;
use crate::ai::agent::conversation::AIConversationId; use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{ use crate::ai::agent::{
AIIdentifiers, FileContext, PassiveCodeDiffEntry, PassiveSuggestionTrigger, AIIdentifiers, FileContext, PassiveCodeDiffEntry, PassiveSuggestionTrigger,
-1
View File
@@ -2,7 +2,6 @@ pub mod client;
pub mod convert; pub mod convert;
pub mod request_translator; pub mod request_translator;
pub mod response_translator; pub mod response_translator;
pub mod translator;
#[cfg(test)] #[cfg(test)]
#[path = "convert_tests.rs"] #[path = "convert_tests.rs"]
-190
View File
@@ -1,190 +0,0 @@
use std::sync::{Arc, Mutex};
use warp_multi_agent_api as api;
use super::client::{OpenAIClient, OpenAIClientConfig, OpenAIError};
use super::convert::build_openai_request;
use super::request_translator::sanitize_messages_for_openai;
use super::response_translator::{openai_stream_to_response_events, OpenAIStreamContext};
use crate::ai::agent::api::LegacyResponseStream;
use crate::ai::bedrock::request_translator;
use crate::ai::provider::types::{
flatten_tool_history_for_no_tools_turn, ConversationMessage, MessageContent, MessageRole,
};
const DEFAULT_MAX_OUTPUT_TOKENS: u32 = 64_000;
pub struct TranslatorRequest {
pub config: OpenAIClientConfig,
pub model_id: String,
pub root_task_id: Option<String>,
pub message_history: Vec<ConversationMessage>,
pub tool_result_archive: Vec<ConversationMessage>,
pub progressive_summary: Option<String>,
pub messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
/// Global rules (name, content) from the local CloudModel.
pub global_rules: Vec<(String, String)>,
/// Whether the native input should be emitted as a transcript-visible user query.
pub emit_user_query_message: bool,
}
pub(crate) struct PreparedTurn {
pub(crate) task_id: String,
pub(crate) needs_create_task: bool,
pub(crate) user_query: Option<String>,
pub(crate) messages: Vec<ConversationMessage>,
pub(crate) system_prompt: Option<String>,
pub(crate) tools: Vec<crate::ai::provider::types::ToolDefinition>,
pub(crate) model_id: String,
pub(crate) persistent_message_count: usize,
}
pub(crate) fn prepare_turn(params: &TranslatorRequest, request: &mut api::Request) -> PreparedTurn {
let task_id = params.root_task_id.clone().unwrap_or_else(|| {
request
.task_context
.as_ref()
.and_then(|tc| tc.tasks.first())
.map(|task| task.id.clone())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
});
let needs_create_task = request
.task_context
.as_ref()
.map(|task_context| task_context.tasks.is_empty())
.unwrap_or(true);
let model_id = if params.model_id.is_empty() || params.model_id == "auto" {
params
.config
.model
.clone()
.unwrap_or_else(|| "anthropic/claude-sonnet-4-6".to_string())
} else {
params
.config
.model
.clone()
.unwrap_or_else(|| params.model_id.clone())
};
request_translator::inject_input_messages_into_task(request);
let new_input_messages = request_translator::extract_new_input_messages(request);
let persistent_message_count = params.message_history.len() + new_input_messages.len();
let mut messages = Vec::new();
if let Some(summary) = &params.progressive_summary {
messages.push(ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text(format!(
"<conversation-history-summary>\n{summary}\n</conversation-history-summary>\n\n\
The above summarizes earlier conversation history. The detailed messages below are the most recent exchanges."
)),
});
messages.push(ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::Text(
"Understood, I have the prior context. Continuing with the recent conversation."
.to_string(),
),
});
}
messages.extend(params.message_history.clone());
messages.extend(new_input_messages);
for message in &mut messages {
message.truncate_tool_results_for_provider_request();
}
sanitize_messages_for_openai(&mut messages);
let tools = request_translator::extract_tools(request);
if tools_are_inline_only(&tools) {
flatten_tool_history_for_no_tools_turn(&mut messages);
}
PreparedTurn {
task_id,
needs_create_task,
user_query: params
.emit_user_query_message
.then(|| request_translator::extract_user_query_text(request))
.flatten(),
messages,
system_prompt: request_translator::extract_system_prompt(request, &params.global_rules),
tools,
model_id,
persistent_message_count,
}
}
fn tools_are_inline_only(tools: &[crate::ai::provider::types::ToolDefinition]) -> bool {
tools.iter().all(|tool| tool.name == "recall_tool_history")
}
pub async fn execute(
params: TranslatorRequest,
request: &mut api::Request,
) -> Result<LegacyResponseStream, OpenAIError> {
let client = OpenAIClient::from_config(params.config.clone());
let PreparedTurn {
task_id,
needs_create_task,
user_query,
mut messages,
system_prompt,
tools,
model_id,
persistent_message_count,
} = prepare_turn(&params, request);
log::info!(
"[openai] Translator: model={model_id}, task_id={task_id}, needs_create_task={needs_create_task}"
);
log::info!(
"[openai] Sending {} messages, system_prompt={}, tools={}",
messages.len(),
system_prompt.is_some(),
tools.len()
);
let max_output_tokens = params
.config
.max_output_tokens
.unwrap_or(DEFAULT_MAX_OUTPUT_TOKENS)
.min(i32::MAX as u32) as i32;
let request_body = build_openai_request(
messages.clone(),
system_prompt,
tools,
max_output_tokens,
None,
&model_id,
);
let byte_stream = client.chat_completions_stream(request_body).await?;
// Store the message history for the controller
if let Ok(mut sent) = params.messages_sent.lock() {
if persistent_message_count > 0 && messages.len() >= persistent_message_count {
*sent = messages.split_off(messages.len() - persistent_message_count);
} else {
*sent = messages;
}
}
let stream = openai_stream_to_response_events(
byte_stream,
OpenAIStreamContext {
task_id,
needs_create_task,
user_query,
messages_sent: params.messages_sent.clone(),
model_id,
max_context_tokens: params.config.max_input_tokens,
tool_result_archive: params.tool_result_archive,
},
);
Ok(stream)
}
+122 -4
View File
@@ -1,12 +1,14 @@
use std::collections::HashMap; use std::collections::HashMap;
use galaxy_agent_core::{ use galaxy_agent_core::{
AgentEvent, RuntimeActivity, RuntimeActivityStatus, RuntimeCapabilities, StopReason, Usage, AgentEvent, ProviderRunOutcome, RuntimeActivity, RuntimeActivityStatus, RuntimeCapabilities,
StopReason, Usage,
}; };
use uuid::Uuid; use uuid::Uuid;
use warp_multi_agent_api::response_event::stream_finished; use warp_multi_agent_api::response_event::stream_finished;
use warp_multi_agent_api::{self as api, ClientAction, ResponseEvent}; 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::agent::runtime_activity;
use crate::ai::bedrock::response_translator::{ use crate::ai::bedrock::response_translator::{
build_add_agent_output_message, build_append_text, build_create_task, build_stream_init, 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}; use crate::ai::openai::response_translator::{build_stream_finished, StreamUsage};
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub(crate) struct RuntimeResponseConfig { pub(crate) struct RuntimeResponseConfig {
pub(crate) task_id: String, pub(crate) task_id: String,
pub(crate) conversation_id: String, pub(crate) conversation_id: String,
@@ -41,17 +44,104 @@ pub(crate) struct RuntimeResponseTranslator {
context_usage: Option<(u64, u64)>, 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 { impl RuntimeResponseTranslator {
pub(crate) fn new(config: RuntimeResponseConfig) -> Self { 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 { Self {
config, config,
request_id: Uuid::new_v4().to_string(), request_id: Uuid::new_v4().to_string(),
initialized: false, initialized,
text_message_id: None, text_message_id: None,
reasoning_message_id: None, reasoning_message_id: None,
activity_message_ids: HashMap::new(), activity_message_ids: HashMap::new(),
activities: HashMap::new(), activities: HashMap::new(),
has_visible_output: false, has_visible_output: initialized,
usage: Usage::default(), usage: Usage::default(),
context_usage: None, context_usage: None,
} }
@@ -152,6 +242,18 @@ impl RuntimeResponseTranslator {
self.reasoning_message_id = None; 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>) { fn initialize(&mut self, events: &mut Vec<ResponseEvent>) {
if self.initialized { if self.initialized {
return; return;
@@ -256,7 +358,23 @@ impl RuntimeResponseTranslator {
} }
fn finished(&self, reason: StopReason) -> ResponseEvent { 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 { if !self.config.capabilities.host_managed_history {
let (used_tokens, context_size) = self.context_usage.unwrap_or_default(); let (used_tokens, context_size) = self.context_usage.unwrap_or_default();
return build_context_finished( 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 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::agent::runtime_activity;
use crate::ai::runtime::provider_run_coordinator::ProviderRunProjection;
fn provider_translator() -> RuntimeResponseTranslator { fn provider_translator() -> RuntimeResponseTranslator {
RuntimeResponseTranslator::new(RuntimeResponseConfig { 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] #[test]
fn provider_and_session_runtimes_share_text_translation() { fn provider_and_session_runtimes_share_text_translation() {
for mut translator in [provider_translator(), session_translator()] { 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] #[test]
fn reasoning_uses_the_native_reasoning_message_contract() { fn reasoning_uses_the_native_reasoning_message_contract() {
let mut translator = provider_translator(); let mut translator = provider_translator();
+11 -4
View File
@@ -1,9 +1,16 @@
mod event_translator; mod event_translator;
mod provider; mod provider_run_coordinator;
mod rig; mod rig;
mod rig_request; mod rig_request;
mod rig_tool; mod rig_tool;
pub(crate) use event_translator::{RuntimeResponseConfig, RuntimeResponseTranslator}; pub(crate) use event_translator::{
pub(crate) use provider::ProviderRuntime; ProviderRunResponseProjector, RuntimeResponseConfig, RuntimeResponseTranslator,
pub(crate) use rig::{rig_bedrock_response_stream, rig_openai_response_stream}; };
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::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use futures::channel::oneshot; use galaxy_agent_core::{AgentRuntime, ToolCall, TurnRequest};
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_rig::{ use galaxy_agent_rig::{
AnthropicRuntime, AnthropicRuntimeConfig, ChatGPTSubscriptionRuntime, AnthropicRuntime, AnthropicRuntimeConfig, ChatGPTSubscriptionRuntime,
ChatGPTSubscriptionRuntimeConfig, GeminiRuntime, GeminiRuntimeConfig, OpenAICompatibleRuntime, ChatGPTSubscriptionRuntimeConfig, GeminiRuntime, GeminiRuntimeConfig, OpenAICompatibleRuntime,
@@ -16,597 +11,242 @@ use uuid::Uuid;
use warp_multi_agent_api::ToolType; use warp_multi_agent_api::ToolType;
use super::rig_request::{ 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 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::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::external_config::ExternalBedrockConfig;
use crate::ai::bedrock::response_translator::build_add_agent_output_message; use crate::ai::provider::types::ConversationMessage;
use crate::ai::openai::client::OpenAIClientConfig; use crate::ai::runtime::RuntimeResponseConfig;
use crate::ai::provider::types::{ContentPart, ConversationMessage};
use crate::ai::runtime::{RuntimeResponseConfig, RuntimeResponseTranslator};
use crate::server::server_api::AIApiError;
use crate::settings::OpenAIProviderKind; use crate::settings::OpenAIProviderKind;
const MAX_INLINE_TOOL_CONTINUATIONS: usize = 3; pub(crate) struct PreparedProviderRun {
const INLINE_TOOL_LOOP_MESSAGE: &str = pub(crate) base_profile: ProviderRunProfile,
"I couldn't continue because the model repeatedly searched prior tool history without making progress. Please retry with a more specific instruction."; 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( #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
config: OpenAIClientConfig, pub(crate) struct ProviderActionContext {
params: RequestParams, task_id: String,
supported_tools: Vec<ToolType>, skill_path_origin: ai::skills::SkillPathOrigin,
supported_cli_agent_tools: Vec<ToolType>, mcp_tool_aliases: HashMap<String, MCPToolTarget>,
cancellation_rx: oneshot::Receiver<()>, }
) -> ResponseStream {
let skill_path_origin = params.session_context.skill_path_origin(); impl ProviderActionContext {
let prepared = prepare_rig_turn(&config, params, supported_tools, supported_cli_agent_tools); pub(crate) fn task_id(&self) -> &str {
let model_id = prepared.request.model.as_str().to_string(); &self.task_id
match config.kind { }
OpenAIProviderKind::OpenAI | OpenAIProviderKind::LiteLLM => {
let runtime = OpenAICompatibleRuntime::new(OpenAICompatibleRuntimeConfig { pub(crate) fn set_task_id(&mut self, task_id: impl Into<String>) {
base_url: config.base_url, self.task_id = task_id.into();
api_key: config.api_key, }
model: model_id.clone(),
max_output_tokens: config.max_output_tokens.map(u64::from), #[cfg(test)]
supports_system_messages: config.supports_system_messages, pub(crate) fn new_for_test(task_id: impl Into<String>) -> Self {
}); Self {
rig_response_stream( task_id: task_id.into(),
runtime, skill_path_origin: ai::skills::SkillPathOrigin::Local,
prepared, mcp_tool_aliases: HashMap::new(),
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,
)
} }
} }
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( pub(crate) async fn prepare_provider_run(
config: BedrockClientConfig, base_provider_config: crate::ai::provider::ProviderConfig,
params: RequestParams, cli_provider_config: crate::ai::provider::ProviderConfig,
supported_tools: Vec<ToolType>, mut params: RequestParams,
supported_cli_agent_tools: Vec<ToolType>, ) -> anyhow::Result<PreparedProviderRun> {
cancellation_rx: oneshot::Receiver<()>, let (supported_tools, supported_cli_agent_tools) =
) -> anyhow::Result<ResponseStream> { crate::ai::agent::api::prepare_direct_provider_params(&mut params);
let skill_path_origin = params.session_context.skill_path_origin(); let skill_path_origin = params.session_context.skill_path_origin();
let max_context_tokens = params.context_window_limit; let max_context_tokens = params.context_window_limit;
let model = params.model.as_str().to_string(); let mut cli_params = params.clone();
let max_output_tokens = Some(64_000); cli_params.model = params.cli_agent_model.clone();
let cross_region_inference = config.cross_region_inference;
let external_config = ExternalBedrockConfig::load(); let (base_runtime, prepared) = prepare_provider_profile(
let prompt_caching = !external_config.disable_prompt_caching; base_provider_config,
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,
params, params,
supported_tools, supported_tools.clone(),
supported_cli_agent_tools, 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 { let PreparedRigTurn {
task_id, task_id,
needs_create_task, needs_create_task,
user_query, user_query,
request: turn_request, request,
persistent_messages, persistent_messages,
tool_result_archive, tool_result_archive,
messages_sent, messages_sent,
mcp_tool_aliases, mcp_tool_aliases,
} = prepared; } = prepared;
store_messages_sent(&messages_sent, &persistent_messages); let persistence_offset = request
.messages
let conversation_id = turn_request.conversation_id.clone(); .len()
let model_id = turn_request.model.as_str().to_string(); .saturating_sub(persistent_messages.len());
let tool_policy = ToolPolicy::new(&turn_request.tools); let response_config = RuntimeResponseConfig {
let stream = async_stream::stream! { task_id: task_id.clone(),
let cancel_future = cancellation_rx.fuse(); conversation_id: request
futures::pin_mut!(cancel_future); .conversation_id
.clone()
let conversation_id = conversation_id.unwrap_or_else(|| Uuid::new_v4().to_string()); .unwrap_or_else(|| Uuid::new_v4().to_string()),
let mut translator = RuntimeResponseTranslator::new(RuntimeResponseConfig { needs_create_task,
task_id: task_id.clone(), user_query,
conversation_id, model_id: request.model.as_str().to_string(),
needs_create_task, max_context_tokens,
user_query, capabilities: base_runtime.descriptor().capabilities.clone(),
model_id, empty_output_message: None,
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));
}
}
}
}
}
}
}
}; };
Ok(PreparedProviderRun {
Box::pin(stream) base_profile: ProviderRunProfile::new(base_runtime, request),
} cli_monitor_profile,
tool_result_archive,
fn store_messages_sent( messages_sent,
messages_sent: &std::sync::Arc<std::sync::Mutex<Vec<ConversationMessage>>>, persistence_offset,
messages: &[ConversationMessage], response_config,
) { action_context: ProviderActionContext {
let Ok(mut sent) = messages_sent.lock() else { task_id,
return; skill_path_origin,
}; mcp_tool_aliases,
*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,
}, },
}; })
if let Ok(mut sent) = messages_sent.lock() {
sent.push(message);
}
} }
fn append_assistant_text( async fn prepare_provider_profile(
messages_sent: &std::sync::Arc<std::sync::Mutex<Vec<ConversationMessage>>>, provider_config: crate::ai::provider::ProviderConfig,
text: &str, params: RequestParams,
) { supported_tools: Vec<ToolType>,
if let Ok(mut sent) = messages_sent.lock() { supported_cli_agent_tools: Vec<ToolType>,
sent.push(ConversationMessage { mode: Option<RigRequestMode>,
role: MessageRole::Assistant, ) -> anyhow::Result<(Arc<dyn AgentRuntime>, PreparedRigTurn)> {
content: MessageContent::Text(text.to_string()), 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,
fn accumulate_usage(total: &mut Usage, usage: &Usage) { params,
total.input_tokens = total.input_tokens.saturating_add(usage.input_tokens); supported_tools,
total.output_tokens = total.output_tokens.saturating_add(usage.output_tokens); supported_cli_agent_tools,
total.cached_input_tokens = total mode,
.cached_input_tokens ),
.saturating_add(usage.cached_input_tokens); None => prepare_rig_turn(config, params, supported_tools, supported_cli_agent_tools),
total.cache_creation_input_tokens = total },
.cache_creation_input_tokens crate::ai::provider::ProviderConfig::Bedrock(_) => prepare_bedrock_rig_turn_for_mode(
.saturating_add(usage.cache_creation_input_tokens); model,
} Some(64_000),
params,
fn sync_assistant_turn( supported_tools,
messages_sent: &std::sync::Arc<std::sync::Mutex<Vec<ConversationMessage>>>, supported_cli_agent_tools,
reasoning_text: &str, mode,
reasoning_signature: Option<&str>, ),
text: &str, crate::ai::provider::ProviderConfig::None => {
tool_calls: &[ToolCall], anyhow::bail!(
history_index: &mut Option<usize>, "No AI runtime configured. Enable an agent runtime or model provider in settings."
) { );
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!(),
} }
} 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 { /// Rebuilds a one-turn provider transport from current settings and a persisted request.
return; /// Credentials remain in the live provider config and never enter the run snapshot.
}; pub(crate) async fn provider_runtime_for_request(
if let Some(index) = *history_index { provider_config: crate::ai::provider::ProviderConfig,
if index < sent.len() { request: &TurnRequest,
sent[index] = message; ) -> anyhow::Result<Arc<dyn AgentRuntime>> {
return; 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,
)?)
} }
} crate::ai::provider::ProviderConfig::None => {
*history_index = Some(sent.len()); anyhow::bail!(
sent.push(message); "No AI runtime configured. Enable an agent runtime or model provider in settings."
} );
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),
} }
.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>, pub mcp_tool_aliases: HashMap<String, MCPToolTarget>,
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub(super) struct MCPToolTarget { pub(crate) struct MCPToolTarget {
pub server_id: Option<Uuid>, pub server_id: Option<Uuid>,
pub name: String, pub name: String,
} }
@@ -52,6 +52,25 @@ pub(crate) fn prepare_rig_turn(
params, params,
supported_tools, supported_tools,
supported_cli_agent_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, params: RequestParams,
supported_tools: Vec<ToolType>, supported_tools: Vec<ToolType>,
supported_cli_agent_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 { ) -> PreparedRigTurn {
prepare_rig_turn_for_provider( prepare_rig_turn_for_provider(
Some(model), Some(model),
@@ -69,6 +106,7 @@ pub(crate) fn prepare_bedrock_rig_turn(
params, params,
supported_tools, supported_tools,
supported_cli_agent_tools, supported_cli_agent_tools,
mode,
) )
} }
@@ -85,6 +123,7 @@ fn prepare_rig_turn_for_provider(
params: RequestParams, params: RequestParams,
supported_tools: Vec<ToolType>, supported_tools: Vec<ToolType>,
supported_cli_agent_tools: Vec<ToolType>, supported_cli_agent_tools: Vec<ToolType>,
mode_override: Option<RigRequestMode>,
) -> PreparedRigTurn { ) -> PreparedRigTurn {
let RequestParams { let RequestParams {
input, input,
@@ -107,7 +146,7 @@ fn prepare_rig_turn_for_provider(
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let needs_create_task = tasks.is_empty(); let needs_create_task = tasks.is_empty();
let user_query = input.iter().find_map(input_user_query); 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 { let available_tools = match mode {
RigRequestMode::Cli => supported_cli_agent_tools, RigRequestMode::Cli => supported_cli_agent_tools,
RigRequestMode::CompletedCommandAssessment => Vec::new(), RigRequestMode::CompletedCommandAssessment => Vec::new(),
@@ -119,11 +158,9 @@ fn prepare_rig_turn_for_provider(
tool_definitions(&available_tools, mcp_context.as_ref()); tool_definitions(&available_tools, mcp_context.as_ref());
match mode { match mode {
RigRequestMode::Cli => { RigRequestMode::Cli => {
// History recall cannot advance a running command and is handled inline by the Rig // History recall cannot advance a running command. Keeping it in the CLI tool list lets
// adapter (without producing a client action that can trigger another turn). Keeping it // the model spend its monitor turn recalling a prior snapshot instead of scheduling
// in the CLI tool list lets the model spend its entire monitor turn recalling the prior // `read_shell_command_output`, so make polling the only inspection path here.
// snapshot instead of scheduling `read_shell_command_output`, so make polling the only
// way to inspect the active command here.
tools.retain(|tool| tool.name != "recall_tool_history"); tools.retain(|tool| tool.name != "recall_tool_history");
} }
RigRequestMode::CompletedCommandAssessment => { RigRequestMode::CompletedCommandAssessment => {
@@ -435,7 +472,7 @@ fn input_user_query(input: &AIAgentInput) -> Option<String> {
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RigRequestMode { pub(crate) enum RigRequestMode {
Normal, Normal,
Plan, Plan,
Orchestrate, 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 galaxy_util::local_or_remote_path::LocalOrRemotePath;
use warp_multi_agent_api::ToolType; 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::api::RequestParams;
use crate::ai::agent::task::TaskId; use crate::ai::agent::task::TaskId;
use crate::ai::agent::{ 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); 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] #[test]
fn lrc_snapshot_follow_up_uses_the_cli_monitor_prompt_and_tools() { 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(); 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; 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, task_id: &str,
call: &ToolCall, call: &ToolCall,
skill_path_origin: &SkillPathOrigin, skill_path_origin: &SkillPathOrigin,
+1
View File
@@ -386,6 +386,7 @@ fn persisted_remote_child_conversation(
conversation_id: conversation_id.to_string(), conversation_id: conversation_id.to_string(),
conversation_data: serde_json::to_string(&AgentConversationData { conversation_data: serde_json::to_string(&AgentConversationData {
agent_backend: Default::default(), agent_backend: Default::default(),
active_provider_run_json: None,
server_conversation_token: Some("restored-child-token".to_string()), server_conversation_token: Some("restored-child-token".to_string()),
conversation_usage_metadata: None, conversation_usage_metadata: None,
reverted_action_ids: None, reverted_action_ids: None,
+5 -17
View File
@@ -6930,13 +6930,7 @@ impl TerminalView {
} }
self.ai_controller.update(ctx, |controller, ctx| { self.ai_controller.update(ctx, |controller, ctx| {
controller.resume_conversation( controller.resume_conversation(*conversation_id, vec![], ctx);
*conversation_id,
/*can_attempt_resume_on_error*/ true,
/*is_auto_resume_after_error*/ false,
vec![],
ctx,
);
}); });
} }
@@ -7261,7 +7255,7 @@ impl TerminalView {
ctx: &mut ViewContext<Self>, ctx: &mut ViewContext<Self>,
) { ) {
match event { match event {
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(_) => { BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { .. } => {
let is_agent_in_control = self let is_agent_in_control = self
.model .model
.lock() .lock()
@@ -7272,7 +7266,7 @@ impl TerminalView {
self.redetermine_terminal_focus(ctx); self.redetermine_terminal_focus(ctx);
} }
} }
BlocklistAIActionEvent::ExecutingAction(..) => { BlocklistAIActionEvent::ExecutingAction { .. } => {
self.redetermine_terminal_focus(ctx); self.redetermine_terminal_focus(ctx);
ctx.notify(); ctx.notify();
} }
@@ -7378,7 +7372,7 @@ impl TerminalView {
); );
} }
} }
BlocklistAIActionEvent::QueuedAction(_) BlocklistAIActionEvent::QueuedAction { .. }
| BlocklistAIActionEvent::ToolLifecycle { .. } => {} | BlocklistAIActionEvent::ToolLifecycle { .. } => {}
} }
} }
@@ -11168,13 +11162,7 @@ impl TerminalView {
}; };
self.ai_controller.update(ctx, |controller, ctx| { self.ai_controller.update(ctx, |controller, ctx| {
controller.resume_conversation( controller.resume_conversation(conversation_id, resume_context, ctx);
conversation_id,
/*can_attempt_resume_on_error*/ true,
/*is_auto_resume_after_error*/ false,
resume_context,
ctx,
);
}); });
} }
@@ -937,6 +937,7 @@ impl TerminalView {
let conversation_data = AgentConversationData { let conversation_data = AgentConversationData {
agent_backend: Default::default(), agent_backend: Default::default(),
active_provider_run_json: None,
server_conversation_token: None, server_conversation_token: None,
conversation_usage_metadata: None, conversation_usage_metadata: None,
reverted_action_ids: None, reverted_action_ids: None,
+1 -1
View File
@@ -34,7 +34,7 @@ pub enum SkillConversionError {
/// Live agent responses can be decoded from the active session's location. Restored payloads do /// Live agent responses can be decoded from the active session's location. Restored payloads do
/// not carry enough session identity to safely reconstruct path-based skill locations, so callers /// not carry enough session identity to safely reconstruct path-based skill locations, so callers
/// must use [`SkillPathOrigin::Unavailable`] rather than silently assuming the local filesystem. /// must use [`SkillPathOrigin::Unavailable`] rather than silently assuming the local filesystem.
#[derive(Debug, Clone, Eq, PartialEq)] #[derive(Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum SkillPathOrigin { pub enum SkillPathOrigin {
Local, Local,
Remote { Remote {
+2
View File
@@ -4,10 +4,12 @@
//! concrete runtimes such as Rig-backed providers or ACP agents. It must not //! concrete runtimes such as Rig-backed providers or ACP agents. It must not
//! depend on GalaxyUI, provider SDKs, persistence, or Warp wire protocols. //! depend on GalaxyUI, provider SDKs, persistence, or Warp wire protocols.
mod provider_run;
mod runtime; mod runtime;
mod tool_policy; mod tool_policy;
mod types; mod types;
pub use provider_run::*;
pub use runtime::*; pub use runtime::*;
pub use tool_policy::*; pub use tool_policy::*;
pub use types::*; pub use types::*;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,811 @@
use serde_json::json;
use super::*;
use crate::{AgentErrorKind, PermissionKind};
fn initial_messages() -> Vec<ConversationMessage> {
vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("Finish the task".to_string()),
}]
}
fn run_with_limits(limits: ProviderRunLimits) -> ProviderRun {
ProviderRun::new("run-1", initial_messages(), "base", limits)
}
fn run() -> ProviderRun {
run_with_limits(ProviderRunLimits::default())
}
fn next_model_call(run: &mut ProviderRun) -> ProviderModelCall {
let Some(ProviderRunStep::CallModel(call)) = run.next_step().unwrap() else {
panic!("expected model call");
};
call
}
fn text_turn(text: &str) -> CompletedModelTurn {
CompletedModelTurn {
assistant_content: vec![ContentPart::Text(text.to_string())],
tool_calls: Vec::new(),
usage: Usage {
input_tokens: 10,
output_tokens: 3,
..Usage::default()
},
stop_reason: StopReason::Completed,
advertised_tools: BTreeSet::new(),
}
}
fn tool_call(id: &str, name: &str) -> ToolCall {
ToolCall {
id: id.to_string(),
name: name.to_string(),
arguments: json!({"id": id}),
}
}
fn tool_turn(calls: Vec<ToolCall>, advertised_tools: &[&str]) -> CompletedModelTurn {
CompletedModelTurn {
assistant_content: vec![ContentPart::Text("I will use tools.".to_string())],
tool_calls: calls,
usage: Usage {
input_tokens: 20,
output_tokens: 5,
cached_input_tokens: 4,
..Usage::default()
},
stop_reason: StopReason::Completed,
advertised_tools: advertised_tools
.iter()
.map(|name| (*name).to_string())
.collect(),
}
}
fn accept_tool_turn(run: &mut ProviderRun, turn: CompletedModelTurn) -> PendingToolBatch {
let model_call = next_model_call(run);
run.accept_model_turn(&model_call.work_id, turn).unwrap();
let Some(ProviderRunStep::DispatchTools(batch)) = run.next_step().unwrap() else {
panic!("expected tool dispatch");
};
batch
}
fn successful_result(call_id: &str, content: &str) -> ToolResult {
ToolResult {
call_id: call_id.to_string(),
content: content.to_string(),
status: ToolResultStatus::Success,
}
}
fn assert_serialization_round_trip(run: &ProviderRun) {
let json = serde_json::to_string(run).unwrap();
let restored: ProviderRun = serde_json::from_str(&json).unwrap();
assert_eq!(&restored, run);
}
#[test]
fn next_step_reemits_identical_pending_model_work() {
let mut run = run();
let first = run.next_step().unwrap();
let second = run.next_step().unwrap();
assert_eq!(first, second);
assert_eq!(run.epoch(), RunEpoch::new(0));
assert_serialization_round_trip(&run);
}
#[test]
fn stale_model_completion_is_rejected_without_mutation() {
let mut run = run();
let call = next_model_call(&mut run);
let stale = ExternalWorkId {
run_id: call.work_id.run_id.clone(),
epoch: RunEpoch::new(call.work_id.epoch.get() + 1),
};
let before = run.clone();
let error = run
.accept_model_turn(&stale, text_turn("done"))
.unwrap_err();
assert!(matches!(
error,
ProviderRunProtocolError::WorkMismatch { .. }
));
assert_eq!(run, before);
}
#[test]
fn model_retries_reuse_work_identity_and_stop_at_the_budget() {
let mut run = run_with_limits(ProviderRunLimits {
max_model_turns: 5,
max_model_retries_per_turn: 1,
});
let call = next_model_call(&mut run);
let mut recoverable = AgentError::new(AgentErrorKind::Transport, "network failed");
recoverable.recoverable = true;
assert_eq!(
run.register_model_failure(&call.work_id, recoverable.clone())
.unwrap(),
ModelFailureDisposition::RetryScheduled
);
let retry = next_model_call(&mut run);
assert_eq!(retry.work_id, call.work_id);
assert_eq!(retry.retry_attempt, 1);
assert_eq!(retry.messages, call.messages);
assert_eq!(run.model_retries(), 1);
assert_eq!(
run.register_model_failure(&retry.work_id, recoverable)
.unwrap(),
ModelFailureDisposition::RunFailed
);
let Some(ProviderRunStep::Done(ProviderRunOutcome::Failed(failure))) = run.next_step().unwrap()
else {
panic!("expected failed run");
};
assert_eq!(failure.kind, ProviderRunFailureKind::RetryLimitExceeded);
}
#[test]
fn text_only_turn_requires_an_explicit_driver_decision() {
let mut run = run();
let call = next_model_call(&mut run);
run.accept_model_turn(&call.work_id, text_turn("finished"))
.unwrap();
assert_eq!(run.next_step().unwrap(), None);
let driver_work = run.active_work_id().unwrap().clone();
assert_eq!(driver_work.epoch, RunEpoch::new(1));
assert!(!run.is_terminal());
run.complete(&driver_work).unwrap();
assert_eq!(
run.next_step().unwrap(),
Some(ProviderRunStep::Done(ProviderRunOutcome::Completed(
ProviderRunCompletion {
stop_reason: StopReason::Completed,
}
)))
);
}
#[test]
fn driver_continuation_appends_observation_switches_profile_and_advances_epoch() {
let mut run = run();
let call = next_model_call(&mut run);
run.accept_model_turn(&call.work_id, text_turn("command is still running"))
.unwrap();
assert_eq!(run.next_step().unwrap(), None);
let driver_work = run.active_work_id().unwrap().clone();
run.continue_with_observation(
&driver_work,
MessageContent::Text("command exited with code 1".to_string()),
"cli-monitor",
)
.unwrap();
assert_eq!(run.epoch(), RunEpoch::new(2));
assert_eq!(run.profile().as_str(), "cli-monitor");
let next = next_model_call(&mut run);
assert_eq!(next.work_id.epoch, RunEpoch::new(2));
assert_eq!(
next.messages.last(),
Some(&ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("command exited with code 1".to_string()),
})
);
}
#[test]
fn ready_continuation_appends_observation_switches_profile_and_rejects_reuse() {
let mut run = run();
let batch = accept_tool_turn(
&mut run,
tool_turn(
vec![tool_call("shell", "run_shell_command")],
&["run_shell_command"],
),
);
run.complete_tool(
&batch.work_id,
successful_result("shell", "command is still running"),
)
.unwrap();
run.commit_tool_batch(&batch.work_id).unwrap();
let ready_work = run.ready_work_id().expect("ready work identity");
run.continue_ready_with_observation(
&ready_work,
MessageContent::Text("Monitor command block-1.".to_string()),
"cli-monitor",
)
.unwrap();
assert_eq!(run.epoch(), RunEpoch::new(3));
assert_eq!(run.profile().as_str(), "cli-monitor");
assert!(matches!(
run.transcript().last(),
Some(ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text(text),
}) if text == "Monitor command block-1."
));
let continued = run.clone();
assert!(matches!(
run.continue_ready_with_observation(
&ready_work,
MessageContent::Text("duplicate".to_string()),
"cli-monitor",
)
.unwrap_err(),
ProviderRunProtocolError::WorkMismatch { .. }
));
assert_eq!(run, continued);
}
#[test]
fn unknown_malformed_and_inline_tools_are_pre_resolved_in_the_same_batch() {
let mut run = ProviderRun::new(
"run-1",
vec![
ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::ToolUse {
tool_use_id: "old-read".to_string(),
name: "read_files".to_string(),
input: json!({"path": "old.txt"}),
},
},
ConversationMessage {
role: MessageRole::User,
content: MessageContent::ToolResult {
tool_use_id: "old-read".to_string(),
content: "old contents".to_string(),
is_error: false,
},
},
],
"base",
ProviderRunLimits::default(),
);
let mut malformed = tool_call("malformed", "read_files");
malformed.arguments = json!("not an object");
let recall = ToolCall {
id: "recall".to_string(),
name: crate::RECALL_TOOL_HISTORY_NAME.to_string(),
arguments: json!({"tool_use_id": "old-read"}),
};
let turn = tool_turn(
vec![
tool_call("external", "read_files"),
tool_call("unknown", "invented_tool"),
malformed,
recall,
],
&["read_files", crate::RECALL_TOOL_HISTORY_NAME],
);
let batch = accept_tool_turn(&mut run, turn);
assert!(matches!(
batch.calls[0].state,
PendingToolCallState::Proposed
));
for index in [1, 2, 3] {
assert!(matches!(
batch.calls[index].state,
PendingToolCallState::Resolved { .. }
));
}
let recall_result = batch.calls[3].state.result().unwrap();
assert!(recall_result.content.contains("old contents"));
assert_eq!(batch.unresolved_call_ids(), vec!["external"]);
}
#[test]
fn parallel_tool_results_commit_atomically_in_original_call_order() {
let mut run = run();
let batch = accept_tool_turn(
&mut run,
tool_turn(
vec![
tool_call("first", "read_files"),
tool_call("second", "grep"),
],
&["read_files", "grep"],
),
);
run.start_tool(&batch.work_id, "first").unwrap();
run.start_tool(&batch.work_id, "second").unwrap();
run.complete_tool(&batch.work_id, successful_result("second", "second result"))
.unwrap();
run.complete_tool(&batch.work_id, successful_result("first", "first result"))
.unwrap();
assert_eq!(run.transcript().len(), 2);
let Some(ProviderRunStep::DispatchTools(completed)) = run.next_step().unwrap() else {
panic!("completed batch must remain recoverable until commit");
};
assert!(completed.is_complete());
assert_eq!(run.epoch(), RunEpoch::new(1));
run.commit_tool_batch(&batch.work_id).unwrap();
assert_eq!(run.epoch(), RunEpoch::new(2));
let MessageContent::MultiPart(parts) = &run.transcript().last().unwrap().content else {
panic!("expected atomic multi-part 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"]);
}
#[test]
fn exact_batch_submission_rejects_missing_duplicate_and_unknown_results_without_mutation() {
let mut run = run();
let batch = accept_tool_turn(
&mut run,
tool_turn(
vec![
tool_call("first", "read_files"),
tool_call("second", "grep"),
],
&["read_files", "grep"],
),
);
let before = run.clone();
let missing = run
.complete_tool_batch(&batch.work_id, vec![successful_result("first", "one")])
.unwrap_err();
assert!(matches!(
missing,
ProviderRunProtocolError::ToolResultSetMismatch { .. }
));
assert_eq!(run, before);
let duplicate = run
.complete_tool_batch(
&batch.work_id,
vec![
successful_result("first", "one"),
successful_result("first", "again"),
],
)
.unwrap_err();
assert_eq!(
duplicate,
ProviderRunProtocolError::DuplicateToolResult {
call_id: "first".to_string(),
}
);
assert_eq!(run, before);
let unknown = run
.complete_tool_batch(
&batch.work_id,
vec![
successful_result("first", "one"),
successful_result("unknown", "bad"),
],
)
.unwrap_err();
assert!(matches!(
unknown,
ProviderRunProtocolError::ToolResultSetMismatch { .. }
));
assert_eq!(run, before);
}
#[test]
fn permission_denial_becomes_one_correlated_result() {
let mut run = run();
let batch = accept_tool_turn(
&mut run,
tool_turn(
vec![tool_call("shell", "run_shell_command")],
&["run_shell_command"],
),
);
let request = PermissionRequest {
id: "permission-shell".to_string(),
call_id: "shell".to_string(),
kind: PermissionKind::Execute,
reason: Some("run a command".to_string()),
};
run.request_tool_permission(&batch.work_id, request)
.unwrap();
let wrong_request = run
.resolve_tool_permission(
&batch.work_id,
"shell",
"wrong",
PermissionDecision::AllowOnce,
)
.unwrap_err();
assert!(matches!(
wrong_request,
ProviderRunProtocolError::PermissionRequestMismatch { .. }
));
run.resolve_tool_permission(
&batch.work_id,
"shell",
"permission-shell",
PermissionDecision::Denied {
reason: Some("not allowed".to_string()),
},
)
.unwrap();
let Some(ProviderRunStep::DispatchTools(completed)) = run.next_step().unwrap() else {
panic!("expected completed tool batch");
};
let result = completed.calls[0].state.result().unwrap();
assert_eq!(result.status, ToolResultStatus::Denied);
assert_eq!(result.content, "not allowed");
assert!(completed.is_complete());
}
#[test]
fn stale_unknown_and_duplicate_tool_updates_do_not_mutate_the_batch() {
let mut run = run();
let batch = accept_tool_turn(
&mut run,
tool_turn(vec![tool_call("read", "read_files")], &["read_files"]),
);
let stale = ExternalWorkId {
run_id: batch.work_id.run_id.clone(),
epoch: RunEpoch::new(batch.work_id.epoch.get() + 1),
};
let before = run.clone();
assert!(matches!(
run.start_tool(&stale, "read").unwrap_err(),
ProviderRunProtocolError::WorkMismatch { .. }
));
assert_eq!(run, before);
assert_eq!(
run.start_tool(&batch.work_id, "missing").unwrap_err(),
ProviderRunProtocolError::UnknownToolCall {
call_id: "missing".to_string(),
}
);
assert_eq!(run, before);
run.complete_tool(&batch.work_id, successful_result("read", "ok"))
.unwrap();
let completed = run.clone();
assert_eq!(
run.complete_tool(&batch.work_id, successful_result("read", "again"))
.unwrap_err(),
ProviderRunProtocolError::DuplicateToolUpdate {
call_id: "read".to_string(),
}
);
assert_eq!(run, completed);
}
#[test]
fn run_cancellation_preserves_completed_results_and_synthesizes_the_rest() {
let mut run = run();
let batch = accept_tool_turn(
&mut run,
tool_turn(
vec![tool_call("done", "read_files"), tool_call("active", "grep")],
&["read_files", "grep"],
),
);
run.complete_tool(&batch.work_id, successful_result("done", "contents"))
.unwrap();
run.start_tool(&batch.work_id, "active").unwrap();
run.cancel("user cancelled").unwrap();
assert_eq!(run.epoch(), RunEpoch::new(2));
let MessageContent::MultiPart(parts) = &run.transcript().last().unwrap().content else {
panic!("expected tool results");
};
assert!(matches!(
&parts[0],
ContentPart::ToolResult {
tool_use_id,
content,
is_error: false,
} if tool_use_id == "done" && content == "contents"
));
assert!(matches!(
&parts[1],
ContentPart::ToolResult {
tool_use_id,
content,
is_error: true,
} if tool_use_id == "active" && content == "user cancelled"
));
assert_eq!(
run.next_step().unwrap(),
Some(ProviderRunStep::Done(ProviderRunOutcome::Cancelled {
reason: "user cancelled".to_string(),
}))
);
}
#[test]
fn failure_while_tools_are_pending_records_correlated_errors_before_terminal_state() {
let mut run = run();
let batch = accept_tool_turn(
&mut run,
tool_turn(vec![tool_call("project", "read_files")], &["read_files"]),
);
assert_eq!(batch.work_id.epoch, RunEpoch::new(1));
run.fail(
ProviderRunFailureKind::Projection,
"proposal could not be projected",
)
.unwrap();
assert_eq!(run.epoch(), RunEpoch::new(2));
let MessageContent::MultiPart(parts) = &run.transcript().last().unwrap().content else {
panic!("expected synthesized tool result");
};
assert!(matches!(
&parts[0],
ContentPart::ToolResult {
tool_use_id,
is_error: true,
..
} if tool_use_id == "project"
));
let Some(ProviderRunStep::Done(ProviderRunOutcome::Failed(failure))) = run.next_step().unwrap()
else {
panic!("expected failed run");
};
assert_eq!(failure.kind, ProviderRunFailureKind::Projection);
}
#[test]
fn turn_limit_cannot_finish_successfully_after_tools_require_another_model_turn() {
let mut run = run_with_limits(ProviderRunLimits {
max_model_turns: 1,
max_model_retries_per_turn: 0,
});
let batch = accept_tool_turn(
&mut run,
tool_turn(vec![tool_call("read", "read_files")], &["read_files"]),
);
run.complete_tool(&batch.work_id, successful_result("read", "ok"))
.unwrap();
run.commit_tool_batch(&batch.work_id).unwrap();
let Some(ProviderRunStep::Done(ProviderRunOutcome::Failed(failure))) = run.next_step().unwrap()
else {
panic!("expected turn-limit failure");
};
assert_eq!(failure.kind, ProviderRunFailureKind::TurnLimitExceeded);
}
#[test]
fn invalid_model_turn_does_not_commit_partial_content_usage_or_epoch() {
let mut run = run();
let call = next_model_call(&mut run);
let duplicate = tool_call("duplicate", "read_files");
let turn = tool_turn(vec![duplicate.clone(), duplicate], &["read_files"]);
let before = run.clone();
assert!(matches!(
run.accept_model_turn(&call.work_id, turn).unwrap_err(),
ProviderRunProtocolError::InvalidModelTurn { .. }
));
assert_eq!(run, before);
}
#[test]
fn every_nonterminal_phase_round_trips_through_json() {
let mut ready = run();
assert_serialization_round_trip(&ready);
let call = next_model_call(&mut ready);
assert_serialization_round_trip(&ready);
let mut resolving_tools = ready.clone();
resolving_tools
.accept_model_turn(
&call.work_id,
tool_turn(vec![tool_call("read", "read_files")], &["read_files"]),
)
.unwrap();
assert_eq!(
resolving_tools.state().phase(),
ProviderRunPhase::ResolvingModel
);
assert_serialization_round_trip(&resolving_tools);
let Some(ProviderRunStep::DispatchTools(_)) = resolving_tools.next_step().unwrap() else {
panic!("expected tool phase");
};
assert_serialization_round_trip(&resolving_tools);
let mut resolving_text = ready;
resolving_text
.accept_model_turn(&call.work_id, text_turn("done"))
.unwrap();
assert_serialization_round_trip(&resolving_text);
assert_eq!(resolving_text.next_step().unwrap(), None);
assert_eq!(
resolving_text.state().phase(),
ProviderRunPhase::AwaitingDriver
);
assert_serialization_round_trip(&resolving_text);
}
#[test]
fn restore_normalization_preserves_safe_nonterminal_states() {
let ready = run();
let mut awaiting_model = ready.clone();
let call = next_model_call(&mut awaiting_model);
let mut resolving = awaiting_model.clone();
resolving
.accept_model_turn(&call.work_id, text_turn("done"))
.unwrap();
let mut awaiting_driver = resolving.clone();
assert_eq!(awaiting_driver.next_step().unwrap(), None);
for mut candidate in [ready, awaiting_model, resolving, awaiting_driver] {
let before = candidate.clone();
assert_eq!(
candidate.normalize_after_restore().unwrap(),
ProviderRunRestoreNormalization::default()
);
assert_eq!(candidate, before);
}
}
#[test]
fn restore_normalization_reproposes_permissions_and_interrupts_unsafe_tools() {
let mut run = run();
let batch = accept_tool_turn(
&mut run,
tool_turn(
vec![
tool_call("proposed", "read_files"),
tool_call("permission", "grep"),
tool_call("approved", "run_shell_command"),
tool_call("executing", "read_files"),
tool_call("resolved", "grep"),
],
&["read_files", "grep", "run_shell_command"],
),
);
let permission = PermissionRequest {
id: "permission-1".to_string(),
call_id: "permission".to_string(),
kind: PermissionKind::Read,
reason: None,
};
run.request_tool_permission(&batch.work_id, permission)
.unwrap();
let approved = PermissionRequest {
id: "permission-2".to_string(),
call_id: "approved".to_string(),
kind: PermissionKind::Execute,
reason: None,
};
run.request_tool_permission(&batch.work_id, approved)
.unwrap();
run.resolve_tool_permission(
&batch.work_id,
"approved",
"permission-2",
PermissionDecision::AllowOnce,
)
.unwrap();
run.start_tool(&batch.work_id, "executing").unwrap();
run.complete_tool(
&batch.work_id,
successful_result("resolved", "already finished"),
)
.unwrap();
let normalization = run.normalize_after_restore().unwrap();
assert_eq!(
normalization.permission_call_ids_reset,
vec!["permission".to_string()]
);
assert_eq!(
normalization.interrupted_call_ids,
vec!["approved".to_string(), "executing".to_string()]
);
assert!(!normalization.committed_tool_batch);
let ProviderRunState::AwaitingTools { batch } = run.state() else {
panic!("partially resolved batch must remain pending");
};
assert!(matches!(
batch.calls[0].state,
PendingToolCallState::Proposed
));
assert!(matches!(
batch.calls[1].state,
PendingToolCallState::Proposed
));
for index in [2, 3] {
let result = batch.calls[index].state.result().unwrap();
assert_eq!(result.status, ToolResultStatus::Error);
assert!(result.content.contains("was not replayed"));
}
assert_eq!(
batch.calls[4].state.result(),
Some(&successful_result("resolved", "already finished"))
);
}
#[test]
fn restore_normalization_commits_a_fully_resolved_batch() {
let mut run = run();
let batch = accept_tool_turn(
&mut run,
tool_turn(
vec![
tool_call("executing", "read_files"),
tool_call("resolved", "grep"),
],
&["read_files", "grep"],
),
);
run.start_tool(&batch.work_id, "executing").unwrap();
run.complete_tool(
&batch.work_id,
successful_result("resolved", "already finished"),
)
.unwrap();
let normalization = run.normalize_after_restore().unwrap();
assert!(normalization.committed_tool_batch);
assert_eq!(
normalization.interrupted_call_ids,
vec!["executing".to_string()]
);
assert_eq!(run.state().phase(), ProviderRunPhase::ReadyToCallModel);
assert_eq!(run.epoch(), RunEpoch::new(2));
let next = next_model_call(&mut run);
assert_eq!(next.work_id.epoch, RunEpoch::new(2));
let MessageContent::MultiPart(parts) = &next.messages.last().unwrap().content else {
panic!("expected committed tool results");
};
assert!(matches!(
&parts[0],
ContentPart::ToolResult {
tool_use_id,
is_error: true,
..
} if tool_use_id == "executing"
));
assert!(matches!(
&parts[1],
ContentPart::ToolResult {
tool_use_id,
is_error: false,
..
} if tool_use_id == "resolved"
));
}
+5 -1
View File
@@ -95,8 +95,12 @@ impl ToolLoopGuard {
impl ToolPolicy { impl ToolPolicy {
pub fn new(tools: &[ToolDefinition]) -> Self { pub fn new(tools: &[ToolDefinition]) -> Self {
Self::from_names(tools.iter().map(|tool| tool.name.clone()))
}
pub fn from_names(names: impl IntoIterator<Item = String>) -> Self {
Self { Self {
advertised_tools: tools.iter().map(|tool| tool.name.clone()).collect(), advertised_tools: names.into_iter().collect(),
} }
} }
+3
View File
@@ -1079,6 +1079,9 @@ pub struct AcpConversationData {
pub struct AgentConversationData { pub struct AgentConversationData {
#[serde(default, skip_serializing_if = "AgentBackend::is_provider")] #[serde(default, skip_serializing_if = "AgentBackend::is_provider")]
pub agent_backend: AgentBackend, pub agent_backend: AgentBackend,
/// Versioned application-owned snapshot for an active direct-provider run.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_provider_run_json: Option<String>,
pub server_conversation_token: Option<String>, pub server_conversation_token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub conversation_usage_metadata: Option<ConversationUsageMetadata>, pub conversation_usage_metadata: Option<ConversationUsageMetadata>,
+31
View File
@@ -137,6 +137,7 @@ fn is_restorable_accepts_empty_and_single_task_conversations() {
fn agent_conversation_data_roundtrips_last_event_sequence() { fn agent_conversation_data_roundtrips_last_event_sequence() {
let data = AgentConversationData { let data = AgentConversationData {
agent_backend: Default::default(), agent_backend: Default::default(),
active_provider_run_json: None,
server_conversation_token: None, server_conversation_token: None,
conversation_usage_metadata: None, conversation_usage_metadata: None,
reverted_action_ids: None, reverted_action_ids: None,
@@ -181,6 +182,31 @@ fn agent_conversation_data_defaults_legacy_rows_to_provider_backend() {
assert_eq!(data.agent_backend, AgentBackend::Provider); assert_eq!(data.agent_backend, AgentBackend::Provider);
} }
#[test]
fn agent_conversation_data_defaults_legacy_rows_without_provider_run_snapshot() {
let data: AgentConversationData = serde_json::from_str(r#"{"server_conversation_token":null}"#)
.expect("legacy rows must deserialize");
assert_eq!(data.active_provider_run_json, None);
}
#[test]
fn agent_conversation_data_roundtrips_provider_run_snapshot() {
let snapshot = r#"{"version":1,"run_id":"run-1"}"#;
let data = AgentConversationData {
active_provider_run_json: Some(snapshot.to_string()),
..Default::default()
};
let json = serde_json::to_string(&data).expect("serialize");
let roundtripped: AgentConversationData = serde_json::from_str(&json).expect("deserialize");
assert_eq!(
roundtripped.active_provider_run_json.as_deref(),
Some(snapshot)
);
}
#[test] #[test]
fn agent_conversation_data_roundtrips_acp_backend() { fn agent_conversation_data_roundtrips_acp_backend() {
let data = AgentConversationData { let data = AgentConversationData {
@@ -214,6 +240,7 @@ fn agent_conversation_data_omits_default_provider_backend() {
fn agent_conversation_data_roundtrips_remote_child_marker() { fn agent_conversation_data_roundtrips_remote_child_marker() {
let data = AgentConversationData { let data = AgentConversationData {
agent_backend: Default::default(), agent_backend: Default::default(),
active_provider_run_json: None,
server_conversation_token: None, server_conversation_token: None,
conversation_usage_metadata: None, conversation_usage_metadata: None,
reverted_action_ids: None, reverted_action_ids: None,
@@ -241,6 +268,7 @@ fn agent_conversation_data_roundtrips_remote_child_marker() {
fn agent_conversation_data_roundtrips_optimistic_root_marker() { fn agent_conversation_data_roundtrips_optimistic_root_marker() {
let data = AgentConversationData { let data = AgentConversationData {
agent_backend: Default::default(), agent_backend: Default::default(),
active_provider_run_json: None,
server_conversation_token: None, server_conversation_token: None,
conversation_usage_metadata: None, conversation_usage_metadata: None,
reverted_action_ids: None, reverted_action_ids: None,
@@ -280,6 +308,7 @@ fn agent_conversation_data_deserializes_legacy_payload_without_last_event_sequen
fn agent_conversation_data_skips_serializing_none_last_event_sequence() { fn agent_conversation_data_skips_serializing_none_last_event_sequence() {
let data = AgentConversationData { let data = AgentConversationData {
agent_backend: Default::default(), agent_backend: Default::default(),
active_provider_run_json: None,
server_conversation_token: None, server_conversation_token: None,
conversation_usage_metadata: None, conversation_usage_metadata: None,
reverted_action_ids: None, reverted_action_ids: None,
@@ -309,6 +338,7 @@ fn agent_conversation_data_skips_serializing_none_last_event_sequence() {
fn agent_conversation_data_roundtrips_pinned() { fn agent_conversation_data_roundtrips_pinned() {
let data = AgentConversationData { let data = AgentConversationData {
agent_backend: Default::default(), agent_backend: Default::default(),
active_provider_run_json: None,
server_conversation_token: None, server_conversation_token: None,
conversation_usage_metadata: None, conversation_usage_metadata: None,
reverted_action_ids: None, reverted_action_ids: None,
@@ -336,6 +366,7 @@ fn agent_conversation_data_roundtrips_pinned() {
fn agent_conversation_data_skips_serializing_unpinned() { fn agent_conversation_data_skips_serializing_unpinned() {
let data = AgentConversationData { let data = AgentConversationData {
agent_backend: Default::default(), agent_backend: Default::default(),
active_provider_run_json: None,
server_conversation_token: None, server_conversation_token: None,
conversation_usage_metadata: None, conversation_usage_metadata: None,
reverted_action_ids: None, reverted_action_ids: None,