Make direct-provider agent runs durable
This commit is contained in:
@@ -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
|
||||||
↓ Bedrock ↓ OpenAI
|
↓ 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
@@ -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
@@ -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(¶ms));
|
|
||||||
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(¶ms));
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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:?}")]
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
®ion,
|
||||||
|
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;
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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, ¶ms.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(", "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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| {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
_ => (),
|
_ => (),
|
||||||
});
|
});
|
||||||
|
|||||||
+2553
-479
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
@@ -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,
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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) = ¶ms.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, ¶ms.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(¶ms, 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)
|
|
||||||
}
|
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
use futures::channel::oneshot;
|
|
||||||
|
|
||||||
use crate::ai::agent::api::{self, ConvertToAPITypeError};
|
|
||||||
use crate::ai::provider::ProviderConfig;
|
|
||||||
|
|
||||||
/// Application-facing provider runtime dispatcher.
|
|
||||||
///
|
|
||||||
/// OpenAI-compatible models can opt into the provider-neutral Rig runtime;
|
|
||||||
/// other models continue through their current translators while migration is
|
|
||||||
/// in progress. Both paths preserve the existing UI response stream contract.
|
|
||||||
pub(crate) struct ProviderRuntime {
|
|
||||||
provider_config: ProviderConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProviderRuntime {
|
|
||||||
pub(crate) fn new(provider_config: ProviderConfig) -> Self {
|
|
||||||
Self { provider_config }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn start_turn(
|
|
||||||
self,
|
|
||||||
params: api::RequestParams,
|
|
||||||
cancellation_rx: oneshot::Receiver<()>,
|
|
||||||
) -> Result<api::ResponseStream, ConvertToAPITypeError> {
|
|
||||||
api::generate_multi_agent_output(self.provider_config, params, cancellation_rx).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,743 @@
|
|||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::error::Error;
|
||||||
|
use std::fmt;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use futures::future::BoxFuture;
|
||||||
|
use futures::StreamExt;
|
||||||
|
use galaxy_agent_core::{
|
||||||
|
AgentError, AgentErrorKind, AgentEvent, AgentRuntime, CompletedModelTurn, ContentPart,
|
||||||
|
ExternalWorkId, ModelFailureDisposition, PendingToolBatch, ProviderModelCall,
|
||||||
|
ProviderRequestProfile, ProviderRun, ProviderRunFailureKind, ProviderRunId, ProviderRunLimits,
|
||||||
|
ProviderRunOutcome, ProviderRunPhase, ProviderRunProtocolError, ProviderRunState,
|
||||||
|
ProviderRunStep, RunEpoch, RuntimeKind, StopReason, ToolEvent, TurnControl, TurnRequest, Usage,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::ai::agent::conversation::AIConversationId;
|
||||||
|
|
||||||
|
pub(crate) const BASE_PROVIDER_PROFILE: &str = "base";
|
||||||
|
pub(crate) const CLI_MONITOR_PROVIDER_PROFILE: &str = "cli-monitor";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub(crate) enum ProviderRunProjection {
|
||||||
|
ModelTurnStarted {
|
||||||
|
work_id: ExternalWorkId,
|
||||||
|
runtime_request_id: String,
|
||||||
|
retry_attempt: u32,
|
||||||
|
},
|
||||||
|
ModelEvent {
|
||||||
|
work_id: ExternalWorkId,
|
||||||
|
event: AgentEvent,
|
||||||
|
},
|
||||||
|
ModelRetry {
|
||||||
|
work_id: ExternalWorkId,
|
||||||
|
retry_attempt: u32,
|
||||||
|
error: AgentError,
|
||||||
|
},
|
||||||
|
ToolBatchReady {
|
||||||
|
batch: PendingToolBatch,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub(crate) enum ProviderRunBlock {
|
||||||
|
Tools(PendingToolBatch),
|
||||||
|
AwaitingDriver {
|
||||||
|
work_id: ExternalWorkId,
|
||||||
|
stop_reason: StopReason,
|
||||||
|
},
|
||||||
|
Done(ProviderRunOutcome),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct ProviderToolExecutionRef {
|
||||||
|
pub(crate) conversation_id: AIConversationId,
|
||||||
|
pub(crate) run_id: ProviderRunId,
|
||||||
|
pub(crate) epoch: RunEpoch,
|
||||||
|
pub(crate) call_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderToolExecutionRef {
|
||||||
|
pub(crate) fn new(
|
||||||
|
conversation_id: AIConversationId,
|
||||||
|
work_id: &ExternalWorkId,
|
||||||
|
call_id: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
conversation_id,
|
||||||
|
run_id: work_id.run_id.clone(),
|
||||||
|
epoch: work_id.epoch,
|
||||||
|
call_id: call_id.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn work_id(&self) -> ExternalWorkId {
|
||||||
|
ExternalWorkId {
|
||||||
|
run_id: self.run_id.clone(),
|
||||||
|
epoch: self.epoch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) enum ProviderToolLifecycleOutcome {
|
||||||
|
Pending,
|
||||||
|
BatchCommitted,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) enum ProviderRunCoordinatorError {
|
||||||
|
InvalidRuntime(String),
|
||||||
|
InvalidToolLifecycle(String),
|
||||||
|
Core(ProviderRunProtocolError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ProviderRunCoordinatorError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::InvalidRuntime(message) | Self::InvalidToolLifecycle(message) => {
|
||||||
|
f.write_str(message)
|
||||||
|
}
|
||||||
|
Self::Core(error) => error.fmt(f),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for ProviderRunCoordinatorError {
|
||||||
|
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::InvalidRuntime(_) | Self::InvalidToolLifecycle(_) => None,
|
||||||
|
Self::Core(error) => Some(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ProviderRunProtocolError> for ProviderRunCoordinatorError {
|
||||||
|
fn from(value: ProviderRunProtocolError) -> Self {
|
||||||
|
Self::Core(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct ProviderRunProfile {
|
||||||
|
pub(crate) runtime: Arc<dyn AgentRuntime>,
|
||||||
|
pub(crate) request: TurnRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderRunProfile {
|
||||||
|
pub(crate) fn new(runtime: Arc<dyn AgentRuntime>, request: TurnRequest) -> Self {
|
||||||
|
Self { runtime, request }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct ProviderRunCoordinator {
|
||||||
|
run: ProviderRun,
|
||||||
|
profiles: BTreeMap<String, ProviderRunProfile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderRunCoordinator {
|
||||||
|
pub(crate) fn from_request(
|
||||||
|
run_id: impl Into<galaxy_agent_core::ProviderRunId>,
|
||||||
|
runtime: Arc<dyn AgentRuntime>,
|
||||||
|
request: TurnRequest,
|
||||||
|
tool_result_archive: Vec<galaxy_agent_core::ConversationMessage>,
|
||||||
|
limits: ProviderRunLimits,
|
||||||
|
) -> Result<Self, ProviderRunCoordinatorError> {
|
||||||
|
let mut run = ProviderRun::new(
|
||||||
|
run_id,
|
||||||
|
request.messages.clone(),
|
||||||
|
BASE_PROVIDER_PROFILE,
|
||||||
|
limits,
|
||||||
|
);
|
||||||
|
run.replace_tool_result_archive(tool_result_archive);
|
||||||
|
let mut profiles = BTreeMap::new();
|
||||||
|
profiles.insert(
|
||||||
|
BASE_PROVIDER_PROFILE.to_string(),
|
||||||
|
ProviderRunProfile::new(runtime, request),
|
||||||
|
);
|
||||||
|
Self::new(run, profiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn new(
|
||||||
|
run: ProviderRun,
|
||||||
|
profiles: BTreeMap<String, ProviderRunProfile>,
|
||||||
|
) -> Result<Self, ProviderRunCoordinatorError> {
|
||||||
|
if !profiles.contains_key(run.profile().as_str()) {
|
||||||
|
return Err(ProviderRunCoordinatorError::InvalidRuntime(format!(
|
||||||
|
"provider request profile '{}' is not configured",
|
||||||
|
run.profile().as_str()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
for (profile, config) in &profiles {
|
||||||
|
validate_profile_runtime(profile, config.runtime.as_ref())?;
|
||||||
|
}
|
||||||
|
Ok(Self { run, profiles })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn run(&self) -> &ProviderRun {
|
||||||
|
&self.run
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn run_mut(&mut self) -> &mut ProviderRun {
|
||||||
|
&mut self.run
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn profile_request(&self, profile: &str) -> Option<&TurnRequest> {
|
||||||
|
self.profiles.get(profile).map(|profile| &profile.request)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn insert_profile(
|
||||||
|
&mut self,
|
||||||
|
profile: impl Into<String>,
|
||||||
|
runtime: Arc<dyn AgentRuntime>,
|
||||||
|
request: TurnRequest,
|
||||||
|
) -> Result<(), ProviderRunCoordinatorError> {
|
||||||
|
let profile = profile.into();
|
||||||
|
validate_profile_runtime(&profile, runtime.as_ref())?;
|
||||||
|
self.profiles
|
||||||
|
.insert(profile, ProviderRunProfile::new(runtime, request));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn apply_tool_lifecycle(
|
||||||
|
&mut self,
|
||||||
|
execution_ref: &ProviderToolExecutionRef,
|
||||||
|
event: &ToolEvent,
|
||||||
|
) -> Result<ProviderToolLifecycleOutcome, ProviderRunCoordinatorError> {
|
||||||
|
let event_call_id = tool_event_call_id(event)?;
|
||||||
|
if event_call_id != execution_ref.call_id {
|
||||||
|
return Err(ProviderRunCoordinatorError::InvalidToolLifecycle(format!(
|
||||||
|
"provider tool lifecycle call ID mismatch: expected '{}', received '{}'",
|
||||||
|
execution_ref.call_id, event_call_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let work_id = execution_ref.work_id();
|
||||||
|
match event {
|
||||||
|
ToolEvent::Proposed { .. } => {
|
||||||
|
return Err(ProviderRunCoordinatorError::InvalidToolLifecycle(
|
||||||
|
"tool proposals must be committed by the model turn before action execution"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
ToolEvent::PermissionRequested { request } => {
|
||||||
|
self.run
|
||||||
|
.request_tool_permission(&work_id, request.clone())?;
|
||||||
|
}
|
||||||
|
ToolEvent::PermissionResolved {
|
||||||
|
request_id,
|
||||||
|
call_id,
|
||||||
|
decision,
|
||||||
|
} => {
|
||||||
|
self.run.resolve_tool_permission(
|
||||||
|
&work_id,
|
||||||
|
call_id,
|
||||||
|
request_id,
|
||||||
|
decision.clone(),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
ToolEvent::Started { call_id } => {
|
||||||
|
self.run.start_tool(&work_id, call_id)?;
|
||||||
|
}
|
||||||
|
ToolEvent::Completed { result } => {
|
||||||
|
self.run.complete_tool(&work_id, result.clone())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let batch_is_complete = match self.run.state() {
|
||||||
|
ProviderRunState::AwaitingTools { batch } => batch.is_complete(),
|
||||||
|
ProviderRunState::ReadyToCallModel
|
||||||
|
| ProviderRunState::AwaitingModel { .. }
|
||||||
|
| ProviderRunState::ResolvingModel { .. }
|
||||||
|
| ProviderRunState::AwaitingDriver { .. }
|
||||||
|
| ProviderRunState::Done { .. }
|
||||||
|
| ProviderRunState::Failed { .. }
|
||||||
|
| ProviderRunState::Cancelled { .. } => false,
|
||||||
|
};
|
||||||
|
if batch_is_complete {
|
||||||
|
self.run.commit_tool_batch(&work_id)?;
|
||||||
|
Ok(ProviderToolLifecycleOutcome::BatchCommitted)
|
||||||
|
} else {
|
||||||
|
Ok(ProviderToolLifecycleOutcome::Pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn drive_until_blocked<F>(
|
||||||
|
&mut self,
|
||||||
|
control: TurnControl,
|
||||||
|
project: F,
|
||||||
|
) -> Result<ProviderRunBlock, ProviderRunCoordinatorError>
|
||||||
|
where
|
||||||
|
F: FnMut(ProviderRunProjection) -> Result<(), String>,
|
||||||
|
{
|
||||||
|
self.drive_until_blocked_with_checkpoint(control, project, |_| Box::pin(async { Ok(()) }))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn drive_until_blocked_with_checkpoint<F, C>(
|
||||||
|
&mut self,
|
||||||
|
control: TurnControl,
|
||||||
|
mut project: F,
|
||||||
|
mut checkpoint: C,
|
||||||
|
) -> Result<ProviderRunBlock, ProviderRunCoordinatorError>
|
||||||
|
where
|
||||||
|
F: FnMut(ProviderRunProjection) -> Result<(), String>,
|
||||||
|
C: FnMut(ProviderRun) -> BoxFuture<'static, Result<(), String>>,
|
||||||
|
{
|
||||||
|
loop {
|
||||||
|
match self.run.next_step()? {
|
||||||
|
Some(ProviderRunStep::CallModel(call)) => {
|
||||||
|
if !self.checkpoint_or_fail(&mut checkpoint).await? {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self.drive_model_call(call, control.clone(), &mut project)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Some(ProviderRunStep::DispatchTools(batch)) => {
|
||||||
|
if !self.checkpoint_or_fail(&mut checkpoint).await? {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !self.project_or_fail(
|
||||||
|
ProviderRunProjection::ToolBatchReady {
|
||||||
|
batch: batch.clone(),
|
||||||
|
},
|
||||||
|
&mut project,
|
||||||
|
)? {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if batch.is_complete() {
|
||||||
|
self.run.commit_tool_batch(&batch.work_id)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Ok(ProviderRunBlock::Tools(batch));
|
||||||
|
}
|
||||||
|
Some(ProviderRunStep::Done(outcome)) => {
|
||||||
|
if !self.checkpoint_or_fail(&mut checkpoint).await? {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Ok(ProviderRunBlock::Done(outcome));
|
||||||
|
}
|
||||||
|
None => match self.run.state() {
|
||||||
|
ProviderRunState::AwaitingDriver {
|
||||||
|
work_id,
|
||||||
|
stop_reason,
|
||||||
|
} => {
|
||||||
|
let work_id = work_id.clone();
|
||||||
|
let stop_reason = stop_reason.clone();
|
||||||
|
if !self.checkpoint_or_fail(&mut checkpoint).await? {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Ok(ProviderRunBlock::AwaitingDriver {
|
||||||
|
work_id,
|
||||||
|
stop_reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ProviderRunState::ReadyToCallModel
|
||||||
|
| ProviderRunState::AwaitingModel { .. }
|
||||||
|
| ProviderRunState::ResolvingModel { .. }
|
||||||
|
| ProviderRunState::AwaitingTools { .. }
|
||||||
|
| ProviderRunState::Done { .. }
|
||||||
|
| ProviderRunState::Failed { .. }
|
||||||
|
| ProviderRunState::Cancelled { .. } => {
|
||||||
|
return Err(ProviderRunCoordinatorError::Core(
|
||||||
|
ProviderRunProtocolError::UnexpectedState {
|
||||||
|
expected: ProviderRunPhase::AwaitingDriver,
|
||||||
|
actual: self.run.state().phase(),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn checkpoint_or_fail<C>(
|
||||||
|
&mut self,
|
||||||
|
checkpoint: &mut C,
|
||||||
|
) -> Result<bool, ProviderRunCoordinatorError>
|
||||||
|
where
|
||||||
|
C: FnMut(ProviderRun) -> BoxFuture<'static, Result<(), String>>,
|
||||||
|
{
|
||||||
|
match checkpoint(self.run.clone()).await {
|
||||||
|
Ok(()) => Ok(true),
|
||||||
|
Err(message) => {
|
||||||
|
self.run.fail(
|
||||||
|
ProviderRunFailureKind::ExternalWork,
|
||||||
|
format!("provider run checkpoint failed: {message}"),
|
||||||
|
)?;
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn drive_model_call<F>(
|
||||||
|
&mut self,
|
||||||
|
call: ProviderModelCall,
|
||||||
|
control: TurnControl,
|
||||||
|
project: &mut F,
|
||||||
|
) -> Result<(), ProviderRunCoordinatorError>
|
||||||
|
where
|
||||||
|
F: FnMut(ProviderRunProjection) -> Result<(), String>,
|
||||||
|
{
|
||||||
|
let Some(profile) = self.profiles.get(call.profile.as_str()).cloned() else {
|
||||||
|
self.run.fail(
|
||||||
|
ProviderRunFailureKind::ExternalWork,
|
||||||
|
format!(
|
||||||
|
"provider request profile '{}' is not configured",
|
||||||
|
call.profile.as_str()
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let advertised_tools = profile
|
||||||
|
.request
|
||||||
|
.tools
|
||||||
|
.iter()
|
||||||
|
.map(|tool| tool.name.clone())
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
let request = request_for_model_call(profile.request, &call);
|
||||||
|
let stream = match profile.runtime.start_turn(request, control).await {
|
||||||
|
Ok(stream) => stream,
|
||||||
|
Err(error) => {
|
||||||
|
self.handle_model_failure(&call.work_id, error, project)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
futures::pin_mut!(stream);
|
||||||
|
let mut buffer = ModelTurnBuffer::default();
|
||||||
|
|
||||||
|
while let Some(event) = stream.next().await {
|
||||||
|
let event = match event {
|
||||||
|
Ok(event) => event,
|
||||||
|
Err(error) => {
|
||||||
|
self.handle_model_failure(&call.work_id, error, project)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match event {
|
||||||
|
AgentEvent::TurnStarted { runtime_request_id } => {
|
||||||
|
if buffer.started {
|
||||||
|
self.handle_model_failure(
|
||||||
|
&call.work_id,
|
||||||
|
protocol_error("provider emitted more than one TurnStarted event"),
|
||||||
|
project,
|
||||||
|
)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if runtime_request_id.is_empty() {
|
||||||
|
self.handle_model_failure(
|
||||||
|
&call.work_id,
|
||||||
|
protocol_error("provider emitted an empty runtime request ID"),
|
||||||
|
project,
|
||||||
|
)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
buffer.started = true;
|
||||||
|
if !self.project_or_fail(
|
||||||
|
ProviderRunProjection::ModelTurnStarted {
|
||||||
|
work_id: call.work_id.clone(),
|
||||||
|
runtime_request_id,
|
||||||
|
retry_attempt: call.retry_attempt,
|
||||||
|
},
|
||||||
|
project,
|
||||||
|
)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AgentEvent::TextDelta { text } => {
|
||||||
|
if !self.ensure_model_started(&call.work_id, &buffer, project)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
buffer.text.push_str(&text);
|
||||||
|
if !self.project_or_fail(
|
||||||
|
ProviderRunProjection::ModelEvent {
|
||||||
|
work_id: call.work_id.clone(),
|
||||||
|
event: AgentEvent::TextDelta { text },
|
||||||
|
},
|
||||||
|
project,
|
||||||
|
)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AgentEvent::ReasoningDelta { text } => {
|
||||||
|
if !self.ensure_model_started(&call.work_id, &buffer, project)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
buffer.reasoning.push_str(&text);
|
||||||
|
if !self.project_or_fail(
|
||||||
|
ProviderRunProjection::ModelEvent {
|
||||||
|
work_id: call.work_id.clone(),
|
||||||
|
event: AgentEvent::ReasoningDelta { text },
|
||||||
|
},
|
||||||
|
project,
|
||||||
|
)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AgentEvent::ReasoningCompleted { text, signature } => {
|
||||||
|
if !self.ensure_model_started(&call.work_id, &buffer, project)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if !text.is_empty() {
|
||||||
|
buffer.reasoning.clone_from(&text);
|
||||||
|
}
|
||||||
|
buffer.reasoning_signature.clone_from(&signature);
|
||||||
|
if !self.project_or_fail(
|
||||||
|
ProviderRunProjection::ModelEvent {
|
||||||
|
work_id: call.work_id.clone(),
|
||||||
|
event: AgentEvent::ReasoningCompleted { text, signature },
|
||||||
|
},
|
||||||
|
project,
|
||||||
|
)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AgentEvent::Tool {
|
||||||
|
event: ToolEvent::Proposed { call: tool_call },
|
||||||
|
} => {
|
||||||
|
if !self.ensure_model_started(&call.work_id, &buffer, project)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
buffer.tool_calls.push(tool_call);
|
||||||
|
}
|
||||||
|
AgentEvent::UsageUpdated { usage } => {
|
||||||
|
if !self.ensure_model_started(&call.work_id, &buffer, project)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
buffer.usage.clone_from(&usage);
|
||||||
|
let cumulative_usage = combined_usage(self.run.usage(), &usage);
|
||||||
|
if !self.project_or_fail(
|
||||||
|
ProviderRunProjection::ModelEvent {
|
||||||
|
work_id: call.work_id.clone(),
|
||||||
|
event: AgentEvent::UsageUpdated {
|
||||||
|
usage: cumulative_usage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
project,
|
||||||
|
)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AgentEvent::TurnStopped { reason } => {
|
||||||
|
if !self.ensure_model_started(&call.work_id, &buffer, project)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if reason == StopReason::Cancelled {
|
||||||
|
self.run.cancel("provider model call was cancelled")?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let turn = buffer.complete(reason, advertised_tools);
|
||||||
|
if let Err(error) = self.run.accept_model_turn(&call.work_id, turn) {
|
||||||
|
self.run.fail(
|
||||||
|
ProviderRunFailureKind::Protocol,
|
||||||
|
format!("provider returned an invalid completed turn: {error}"),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
AgentEvent::Tool {
|
||||||
|
event:
|
||||||
|
ToolEvent::PermissionRequested { .. }
|
||||||
|
| ToolEvent::PermissionResolved { .. }
|
||||||
|
| ToolEvent::Started { .. }
|
||||||
|
| ToolEvent::Completed { .. },
|
||||||
|
}
|
||||||
|
| AgentEvent::RuntimeActivityUpdated { .. }
|
||||||
|
| AgentEvent::ContextUsageUpdated { .. }
|
||||||
|
| AgentEvent::UserInputAccepted { .. }
|
||||||
|
| AgentEvent::RuntimeNotice { .. } => {
|
||||||
|
self.handle_model_failure(
|
||||||
|
&call.work_id,
|
||||||
|
protocol_error(
|
||||||
|
"direct-provider transport emitted a non-model lifecycle event",
|
||||||
|
),
|
||||||
|
project,
|
||||||
|
)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.handle_model_failure(
|
||||||
|
&call.work_id,
|
||||||
|
protocol_error("provider stream ended before TurnStopped"),
|
||||||
|
project,
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_model_started<F>(
|
||||||
|
&mut self,
|
||||||
|
work_id: &ExternalWorkId,
|
||||||
|
buffer: &ModelTurnBuffer,
|
||||||
|
project: &mut F,
|
||||||
|
) -> Result<bool, ProviderRunCoordinatorError>
|
||||||
|
where
|
||||||
|
F: FnMut(ProviderRunProjection) -> Result<(), String>,
|
||||||
|
{
|
||||||
|
if buffer.started {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
self.handle_model_failure(
|
||||||
|
work_id,
|
||||||
|
protocol_error("provider emitted model output before TurnStarted"),
|
||||||
|
project,
|
||||||
|
)?;
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_model_failure<F>(
|
||||||
|
&mut self,
|
||||||
|
work_id: &ExternalWorkId,
|
||||||
|
error: AgentError,
|
||||||
|
project: &mut F,
|
||||||
|
) -> Result<(), ProviderRunCoordinatorError>
|
||||||
|
where
|
||||||
|
F: FnMut(ProviderRunProjection) -> Result<(), String>,
|
||||||
|
{
|
||||||
|
let disposition = self.run.register_model_failure(work_id, error.clone())?;
|
||||||
|
if disposition == ModelFailureDisposition::RetryScheduled {
|
||||||
|
let retry_attempt = match self.run.state() {
|
||||||
|
ProviderRunState::AwaitingModel { call } => call.retry_attempt,
|
||||||
|
ProviderRunState::ReadyToCallModel
|
||||||
|
| ProviderRunState::ResolvingModel { .. }
|
||||||
|
| ProviderRunState::AwaitingTools { .. }
|
||||||
|
| ProviderRunState::AwaitingDriver { .. }
|
||||||
|
| ProviderRunState::Done { .. }
|
||||||
|
| ProviderRunState::Failed { .. }
|
||||||
|
| ProviderRunState::Cancelled { .. } => {
|
||||||
|
return Err(ProviderRunCoordinatorError::Core(
|
||||||
|
ProviderRunProtocolError::UnexpectedState {
|
||||||
|
expected: ProviderRunPhase::AwaitingModel,
|
||||||
|
actual: self.run.state().phase(),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.project_or_fail(
|
||||||
|
ProviderRunProjection::ModelRetry {
|
||||||
|
work_id: work_id.clone(),
|
||||||
|
retry_attempt,
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
project,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_or_fail<F>(
|
||||||
|
&mut self,
|
||||||
|
event: ProviderRunProjection,
|
||||||
|
project: &mut F,
|
||||||
|
) -> Result<bool, ProviderRunCoordinatorError>
|
||||||
|
where
|
||||||
|
F: FnMut(ProviderRunProjection) -> Result<(), String>,
|
||||||
|
{
|
||||||
|
match project(event) {
|
||||||
|
Ok(()) => Ok(true),
|
||||||
|
Err(message) => {
|
||||||
|
self.run.fail(
|
||||||
|
ProviderRunFailureKind::Projection,
|
||||||
|
format!("provider run projection failed: {message}"),
|
||||||
|
)?;
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ModelTurnBuffer {
|
||||||
|
started: bool,
|
||||||
|
text: String,
|
||||||
|
reasoning: String,
|
||||||
|
reasoning_signature: Option<String>,
|
||||||
|
tool_calls: Vec<galaxy_agent_core::ToolCall>,
|
||||||
|
usage: Usage,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelTurnBuffer {
|
||||||
|
fn complete(
|
||||||
|
self,
|
||||||
|
stop_reason: StopReason,
|
||||||
|
advertised_tools: BTreeSet<String>,
|
||||||
|
) -> CompletedModelTurn {
|
||||||
|
let mut assistant_content = Vec::new();
|
||||||
|
if !self.reasoning.is_empty() || self.reasoning_signature.is_some() {
|
||||||
|
assistant_content.push(ContentPart::Reasoning {
|
||||||
|
text: self.reasoning,
|
||||||
|
signature: self.reasoning_signature,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !self.text.is_empty() {
|
||||||
|
assistant_content.push(ContentPart::Text(self.text));
|
||||||
|
}
|
||||||
|
CompletedModelTurn {
|
||||||
|
assistant_content,
|
||||||
|
tool_calls: self.tool_calls,
|
||||||
|
usage: self.usage,
|
||||||
|
stop_reason,
|
||||||
|
advertised_tools,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_profile_runtime(
|
||||||
|
profile: &str,
|
||||||
|
runtime: &dyn AgentRuntime,
|
||||||
|
) -> Result<(), ProviderRunCoordinatorError> {
|
||||||
|
let descriptor = runtime.descriptor();
|
||||||
|
if descriptor.kind != RuntimeKind::Provider {
|
||||||
|
return Err(ProviderRunCoordinatorError::InvalidRuntime(format!(
|
||||||
|
"runtime '{}' for provider request profile '{profile}' is not a direct-provider transport",
|
||||||
|
descriptor.id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !descriptor.capabilities.host_managed_history || !descriptor.capabilities.host_tool_execution
|
||||||
|
{
|
||||||
|
return Err(ProviderRunCoordinatorError::InvalidRuntime(format!(
|
||||||
|
"runtime '{}' for provider request profile '{profile}' does not expose Galaxy-owned history and tools",
|
||||||
|
descriptor.id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_for_model_call(mut template: TurnRequest, call: &ProviderModelCall) -> TurnRequest {
|
||||||
|
template.prompt = None;
|
||||||
|
template.messages = call.messages.clone();
|
||||||
|
template
|
||||||
|
}
|
||||||
|
|
||||||
|
fn combined_usage(previous: &Usage, current: &Usage) -> Usage {
|
||||||
|
Usage {
|
||||||
|
input_tokens: previous.input_tokens.saturating_add(current.input_tokens),
|
||||||
|
output_tokens: previous.output_tokens.saturating_add(current.output_tokens),
|
||||||
|
cached_input_tokens: previous
|
||||||
|
.cached_input_tokens
|
||||||
|
.saturating_add(current.cached_input_tokens),
|
||||||
|
cache_creation_input_tokens: previous
|
||||||
|
.cache_creation_input_tokens
|
||||||
|
.saturating_add(current.cache_creation_input_tokens),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_event_call_id(event: &ToolEvent) -> Result<&str, ProviderRunCoordinatorError> {
|
||||||
|
match event {
|
||||||
|
ToolEvent::Proposed { call } => Ok(&call.id),
|
||||||
|
ToolEvent::PermissionRequested { request } => Ok(&request.call_id),
|
||||||
|
ToolEvent::PermissionResolved { call_id, .. } | ToolEvent::Started { call_id } => {
|
||||||
|
Ok(call_id)
|
||||||
|
}
|
||||||
|
ToolEvent::Completed { result } => Ok(&result.call_id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protocol_error(message: impl Into<String>) -> AgentError {
|
||||||
|
AgentError::new(AgentErrorKind::Protocol, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "provider_run_coordinator_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,963 @@
|
|||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use galaxy_agent_core::{
|
||||||
|
turn_control, AgentEventStream, ConversationMessage, MessageContent, MessageRole,
|
||||||
|
PermissionDecision, PermissionKind, PermissionRequest, ProviderRunFailure, RuntimeCapabilities,
|
||||||
|
RuntimeDescriptor, ToolCall, ToolDefinition, ToolResult, ToolResultStatus,
|
||||||
|
};
|
||||||
|
use warp_multi_agent_api::response_event;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::ai::runtime::event_translator::{ProviderRunResponseProjector, RuntimeResponseConfig};
|
||||||
|
|
||||||
|
type ScriptedEvent = Result<AgentEvent, AgentError>;
|
||||||
|
type ScriptedTurn = Result<Vec<ScriptedEvent>, AgentError>;
|
||||||
|
|
||||||
|
struct ScriptedRuntime {
|
||||||
|
descriptor: RuntimeDescriptor,
|
||||||
|
turns: Mutex<VecDeque<ScriptedTurn>>,
|
||||||
|
requests: Mutex<Vec<TurnRequest>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScriptedRuntime {
|
||||||
|
fn new(turns: Vec<ScriptedTurn>) -> Self {
|
||||||
|
Self::with_id("scripted", turns)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_id(id: &str, turns: Vec<ScriptedTurn>) -> Self {
|
||||||
|
Self {
|
||||||
|
descriptor: RuntimeDescriptor {
|
||||||
|
id: id.to_string(),
|
||||||
|
display_name: "Scripted provider".to_string(),
|
||||||
|
kind: RuntimeKind::Provider,
|
||||||
|
capabilities: RuntimeCapabilities::provider(),
|
||||||
|
},
|
||||||
|
turns: Mutex::new(turns.into()),
|
||||||
|
requests: Mutex::new(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requests(&self) -> Vec<TurnRequest> {
|
||||||
|
self.requests.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AgentRuntime for ScriptedRuntime {
|
||||||
|
fn descriptor(&self) -> &RuntimeDescriptor {
|
||||||
|
&self.descriptor
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_turn(
|
||||||
|
&self,
|
||||||
|
request: TurnRequest,
|
||||||
|
_control: TurnControl,
|
||||||
|
) -> Result<AgentEventStream, AgentError> {
|
||||||
|
self.requests.lock().unwrap().push(request);
|
||||||
|
let turn = self
|
||||||
|
.turns
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.pop_front()
|
||||||
|
.expect("scripted runtime ran out of turns")?;
|
||||||
|
Ok(Box::pin(futures::stream::iter(turn)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request() -> TurnRequest {
|
||||||
|
let mut request = TurnRequest::new(
|
||||||
|
"test-model",
|
||||||
|
vec![ConversationMessage {
|
||||||
|
role: MessageRole::User,
|
||||||
|
content: MessageContent::Text("Inspect and fix the issue".to_string()),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
request.conversation_id = Some("conversation".to_string());
|
||||||
|
request.system_prompt = Some("Use tools and finish the task.".to_string());
|
||||||
|
request.tools = vec![
|
||||||
|
ToolDefinition {
|
||||||
|
name: "read_files".to_string(),
|
||||||
|
description: "Read files".to_string(),
|
||||||
|
input_schema: serde_json::json!({"type": "object"}),
|
||||||
|
},
|
||||||
|
ToolDefinition {
|
||||||
|
name: galaxy_agent_core::RECALL_TOOL_HISTORY_NAME.to_string(),
|
||||||
|
description: "Recall tool results".to_string(),
|
||||||
|
input_schema: serde_json::json!({"type": "object"}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
request
|
||||||
|
}
|
||||||
|
|
||||||
|
fn started(id: &str) -> ScriptedEvent {
|
||||||
|
Ok(AgentEvent::TurnStarted {
|
||||||
|
runtime_request_id: id.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usage(input_tokens: u64, output_tokens: u64) -> ScriptedEvent {
|
||||||
|
Ok(AgentEvent::UsageUpdated {
|
||||||
|
usage: Usage {
|
||||||
|
input_tokens,
|
||||||
|
output_tokens,
|
||||||
|
..Usage::default()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stopped(reason: StopReason) -> ScriptedEvent {
|
||||||
|
Ok(AgentEvent::TurnStopped { reason })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_call(id: &str, name: &str) -> ToolCall {
|
||||||
|
ToolCall {
|
||||||
|
id: id.to_string(),
|
||||||
|
name: name.to_string(),
|
||||||
|
arguments: serde_json::json!({"path": "Cargo.toml"}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_turn() -> ScriptedTurn {
|
||||||
|
Ok(vec![
|
||||||
|
started("request-tools"),
|
||||||
|
Ok(AgentEvent::TextDelta {
|
||||||
|
text: "I will inspect it.".to_string(),
|
||||||
|
}),
|
||||||
|
Ok(AgentEvent::Tool {
|
||||||
|
event: ToolEvent::Proposed {
|
||||||
|
call: tool_call("read-1", "read_files"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
usage(10, 4),
|
||||||
|
stopped(StopReason::Completed),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_turn_with_calls(calls: Vec<ToolCall>) -> ScriptedTurn {
|
||||||
|
let mut events = vec![started("request-tools")];
|
||||||
|
events.extend(calls.into_iter().map(|call| {
|
||||||
|
Ok(AgentEvent::Tool {
|
||||||
|
event: ToolEvent::Proposed { call },
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
events.push(usage(10, 4));
|
||||||
|
events.push(stopped(StopReason::Completed));
|
||||||
|
Ok(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn answer_turn() -> ScriptedTurn {
|
||||||
|
Ok(vec![
|
||||||
|
started("request-answer"),
|
||||||
|
Ok(AgentEvent::TextDelta {
|
||||||
|
text: "The issue is fixed.".to_string(),
|
||||||
|
}),
|
||||||
|
usage(20, 5),
|
||||||
|
stopped(StopReason::Completed),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn coordinator(runtime: Arc<dyn AgentRuntime>) -> ProviderRunCoordinator {
|
||||||
|
ProviderRunCoordinator::from_request(
|
||||||
|
"run-1",
|
||||||
|
runtime,
|
||||||
|
request(),
|
||||||
|
Vec::new(),
|
||||||
|
ProviderRunLimits::default(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn coordinator_awaiting_tools(
|
||||||
|
calls: Vec<ToolCall>,
|
||||||
|
) -> (ProviderRunCoordinator, PendingToolBatch) {
|
||||||
|
let runtime = Arc::new(ScriptedRuntime::new(vec![tool_turn_with_calls(calls)]));
|
||||||
|
let mut coordinator = coordinator(runtime);
|
||||||
|
let (_sender, control) = turn_control();
|
||||||
|
let block = coordinator
|
||||||
|
.drive_until_blocked(control, |_| Ok(()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let ProviderRunBlock::Tools(batch) = block else {
|
||||||
|
panic!("expected external tool work");
|
||||||
|
};
|
||||||
|
(coordinator, batch)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_result(call_id: &str, content: &str, status: ToolResultStatus) -> ToolEvent {
|
||||||
|
ToolEvent::Completed {
|
||||||
|
result: ToolResult {
|
||||||
|
call_id: call_id.to_string(),
|
||||||
|
content: content.to_string(),
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_projection(
|
||||||
|
events: &mut Vec<ProviderRunProjection>,
|
||||||
|
) -> impl FnMut(ProviderRunProjection) -> Result<(), String> + '_ {
|
||||||
|
|event| {
|
||||||
|
events.push(event);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn one_run_drives_model_tool_and_followup_turns_with_atomic_history() {
|
||||||
|
let runtime = Arc::new(ScriptedRuntime::new(vec![tool_turn(), answer_turn()]));
|
||||||
|
let mut coordinator = coordinator(runtime.clone());
|
||||||
|
let mut projections = Vec::new();
|
||||||
|
let (_sender, control) = turn_control();
|
||||||
|
|
||||||
|
let first_block = coordinator
|
||||||
|
.drive_until_blocked(control, collect_projection(&mut projections))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let ProviderRunBlock::Tools(batch) = first_block else {
|
||||||
|
panic!("expected external tool work");
|
||||||
|
};
|
||||||
|
assert_eq!(batch.work_id.epoch.get(), 1);
|
||||||
|
assert_eq!(batch.calls.len(), 1);
|
||||||
|
assert!(projections.iter().all(|projection| !matches!(
|
||||||
|
projection,
|
||||||
|
ProviderRunProjection::ModelEvent {
|
||||||
|
event: AgentEvent::Tool { .. },
|
||||||
|
..
|
||||||
|
}
|
||||||
|
)));
|
||||||
|
assert!(matches!(
|
||||||
|
projections.last(),
|
||||||
|
Some(ProviderRunProjection::ToolBatchReady { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
coordinator
|
||||||
|
.run_mut()
|
||||||
|
.start_tool(&batch.work_id, "read-1")
|
||||||
|
.unwrap();
|
||||||
|
coordinator
|
||||||
|
.run_mut()
|
||||||
|
.complete_tool(
|
||||||
|
&batch.work_id,
|
||||||
|
ToolResult {
|
||||||
|
call_id: "read-1".to_string(),
|
||||||
|
content: "manifest contents".to_string(),
|
||||||
|
status: ToolResultStatus::Success,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
coordinator
|
||||||
|
.run_mut()
|
||||||
|
.commit_tool_batch(&batch.work_id)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
projections.clear();
|
||||||
|
let (_sender, control) = turn_control();
|
||||||
|
let second_block = coordinator
|
||||||
|
.drive_until_blocked(control, collect_projection(&mut projections))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let ProviderRunBlock::AwaitingDriver {
|
||||||
|
work_id,
|
||||||
|
stop_reason,
|
||||||
|
} = second_block
|
||||||
|
else {
|
||||||
|
panic!("expected driver decision");
|
||||||
|
};
|
||||||
|
assert_eq!(stop_reason, StopReason::Completed);
|
||||||
|
assert_eq!(work_id.epoch.get(), 3);
|
||||||
|
assert_eq!(coordinator.run().usage().input_tokens, 30);
|
||||||
|
assert_eq!(coordinator.run().usage().output_tokens, 9);
|
||||||
|
|
||||||
|
let requests = runtime.requests();
|
||||||
|
assert_eq!(requests.len(), 2);
|
||||||
|
assert_eq!(requests[0].messages.len(), 1);
|
||||||
|
assert_eq!(requests[1].messages.len(), 3);
|
||||||
|
let MessageContent::MultiPart(results) = &requests[1].messages[2].content else {
|
||||||
|
panic!("expected atomic tool result message");
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
results.as_slice(),
|
||||||
|
[ContentPart::ToolResult {
|
||||||
|
tool_use_id,
|
||||||
|
content,
|
||||||
|
is_error: false,
|
||||||
|
}] if tool_use_id == "read-1" && content == "manifest contents"
|
||||||
|
));
|
||||||
|
assert!(projections.iter().any(|projection| matches!(
|
||||||
|
projection,
|
||||||
|
ProviderRunProjection::ModelEvent {
|
||||||
|
event: AgentEvent::UsageUpdated { usage },
|
||||||
|
..
|
||||||
|
} if usage.input_tokens == 30 && usage.output_tokens == 9
|
||||||
|
)));
|
||||||
|
|
||||||
|
coordinator.run_mut().complete(&work_id).unwrap();
|
||||||
|
let (_sender, control) = turn_control();
|
||||||
|
assert_eq!(
|
||||||
|
coordinator
|
||||||
|
.drive_until_blocked(control, |_| Ok(()))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
ProviderRunBlock::Done(ProviderRunOutcome::Completed(
|
||||||
|
galaxy_agent_core::ProviderRunCompletion {
|
||||||
|
stop_reason: StopReason::Completed,
|
||||||
|
}
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn request_profiles_route_through_their_own_runtime_and_template() {
|
||||||
|
let base_runtime = Arc::new(ScriptedRuntime::with_id(
|
||||||
|
"base-runtime",
|
||||||
|
vec![answer_turn()],
|
||||||
|
));
|
||||||
|
let cli_runtime = Arc::new(ScriptedRuntime::with_id("cli-runtime", vec![answer_turn()]));
|
||||||
|
let mut base_request = request();
|
||||||
|
base_request.model = "base-model".into();
|
||||||
|
base_request.system_prompt = Some("base prompt".to_string());
|
||||||
|
let mut cli_request = request();
|
||||||
|
cli_request.model = "cli-model".into();
|
||||||
|
cli_request.system_prompt = Some("cli prompt".to_string());
|
||||||
|
let run = ProviderRun::new(
|
||||||
|
"run-1",
|
||||||
|
base_request.messages.clone(),
|
||||||
|
BASE_PROVIDER_PROFILE,
|
||||||
|
ProviderRunLimits::default(),
|
||||||
|
);
|
||||||
|
let mut profiles = BTreeMap::new();
|
||||||
|
profiles.insert(
|
||||||
|
BASE_PROVIDER_PROFILE.to_string(),
|
||||||
|
ProviderRunProfile::new(base_runtime.clone(), base_request),
|
||||||
|
);
|
||||||
|
profiles.insert(
|
||||||
|
CLI_MONITOR_PROVIDER_PROFILE.to_string(),
|
||||||
|
ProviderRunProfile::new(cli_runtime.clone(), cli_request),
|
||||||
|
);
|
||||||
|
let mut coordinator = ProviderRunCoordinator::new(run, profiles).unwrap();
|
||||||
|
|
||||||
|
let (_sender, control) = turn_control();
|
||||||
|
let ProviderRunBlock::AwaitingDriver { work_id, .. } = coordinator
|
||||||
|
.drive_until_blocked(control, |_| Ok(()))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!("expected base driver decision");
|
||||||
|
};
|
||||||
|
coordinator
|
||||||
|
.run_mut()
|
||||||
|
.continue_with_observation(
|
||||||
|
&work_id,
|
||||||
|
MessageContent::Text("command is still running".to_string()),
|
||||||
|
CLI_MONITOR_PROVIDER_PROFILE,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let (_sender, control) = turn_control();
|
||||||
|
assert!(matches!(
|
||||||
|
coordinator
|
||||||
|
.drive_until_blocked(control, |_| Ok(()))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
ProviderRunBlock::AwaitingDriver { .. }
|
||||||
|
));
|
||||||
|
|
||||||
|
let base_requests = base_runtime.requests();
|
||||||
|
let cli_requests = cli_runtime.requests();
|
||||||
|
assert_eq!(base_requests.len(), 1);
|
||||||
|
assert_eq!(base_requests[0].model.as_str(), "base-model");
|
||||||
|
assert_eq!(
|
||||||
|
base_requests[0].system_prompt.as_deref(),
|
||||||
|
Some("base prompt")
|
||||||
|
);
|
||||||
|
assert_eq!(cli_requests.len(), 1);
|
||||||
|
assert_eq!(cli_requests[0].model.as_str(), "cli-model");
|
||||||
|
assert_eq!(cli_requests[0].system_prompt.as_deref(), Some("cli prompt"));
|
||||||
|
assert!(matches!(
|
||||||
|
cli_requests[0].messages.last(),
|
||||||
|
Some(ConversationMessage {
|
||||||
|
role: MessageRole::User,
|
||||||
|
content: MessageContent::Text(text),
|
||||||
|
}) if text == "command is still running"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn correlated_parallel_lifecycle_commits_results_in_original_call_order() {
|
||||||
|
let (mut coordinator, batch) = coordinator_awaiting_tools(vec![
|
||||||
|
tool_call("first", "read_files"),
|
||||||
|
tool_call("second", "read_files"),
|
||||||
|
])
|
||||||
|
.await;
|
||||||
|
let conversation_id = AIConversationId::new();
|
||||||
|
let first = ProviderToolExecutionRef::new(conversation_id, &batch.work_id, "first");
|
||||||
|
let second = ProviderToolExecutionRef::new(conversation_id, &batch.work_id, "second");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
coordinator
|
||||||
|
.apply_tool_lifecycle(
|
||||||
|
&first,
|
||||||
|
&ToolEvent::Started {
|
||||||
|
call_id: "first".to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
ProviderToolLifecycleOutcome::Pending
|
||||||
|
);
|
||||||
|
coordinator
|
||||||
|
.apply_tool_lifecycle(
|
||||||
|
&second,
|
||||||
|
&ToolEvent::Started {
|
||||||
|
call_id: "second".to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
coordinator
|
||||||
|
.apply_tool_lifecycle(
|
||||||
|
&second,
|
||||||
|
&tool_result("second", "second result", ToolResultStatus::Success),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
ProviderToolLifecycleOutcome::Pending
|
||||||
|
);
|
||||||
|
assert_eq!(coordinator.run().transcript().len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
coordinator
|
||||||
|
.apply_tool_lifecycle(
|
||||||
|
&first,
|
||||||
|
&tool_result("first", "first result", ToolResultStatus::Success),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
ProviderToolLifecycleOutcome::BatchCommitted
|
||||||
|
);
|
||||||
|
|
||||||
|
let MessageContent::MultiPart(parts) = &coordinator.run().transcript().last().unwrap().content
|
||||||
|
else {
|
||||||
|
panic!("expected atomic tool result message");
|
||||||
|
};
|
||||||
|
let ids = parts
|
||||||
|
.iter()
|
||||||
|
.map(|part| match part {
|
||||||
|
ContentPart::ToolResult { tool_use_id, .. } => tool_use_id.as_str(),
|
||||||
|
ContentPart::Text(_)
|
||||||
|
| ContentPart::Reasoning { .. }
|
||||||
|
| ContentPart::Image { .. }
|
||||||
|
| ContentPart::ToolUse { .. } => panic!("expected only tool results"),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(ids, vec!["first", "second"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn correlated_lifecycle_rejects_wrong_run_epoch_call_and_duplicate_without_mutation() {
|
||||||
|
let (mut coordinator, batch) = coordinator_awaiting_tools(vec![
|
||||||
|
tool_call("first", "read_files"),
|
||||||
|
tool_call("second", "read_files"),
|
||||||
|
])
|
||||||
|
.await;
|
||||||
|
let conversation_id = AIConversationId::new();
|
||||||
|
let first = ProviderToolExecutionRef::new(conversation_id, &batch.work_id, "first");
|
||||||
|
let second = ProviderToolExecutionRef::new(conversation_id, &batch.work_id, "second");
|
||||||
|
let initial = coordinator.run().clone();
|
||||||
|
|
||||||
|
let mut wrong_run = first.clone();
|
||||||
|
wrong_run.run_id = ProviderRunId::new("wrong-run");
|
||||||
|
assert!(matches!(
|
||||||
|
coordinator
|
||||||
|
.apply_tool_lifecycle(
|
||||||
|
&wrong_run,
|
||||||
|
&ToolEvent::Started {
|
||||||
|
call_id: "first".to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_err(),
|
||||||
|
ProviderRunCoordinatorError::Core(ProviderRunProtocolError::WorkMismatch { .. })
|
||||||
|
));
|
||||||
|
assert_eq!(coordinator.run(), &initial);
|
||||||
|
|
||||||
|
let mut stale = first.clone();
|
||||||
|
stale.epoch = RunEpoch::new(stale.epoch.get() + 1);
|
||||||
|
assert!(matches!(
|
||||||
|
coordinator
|
||||||
|
.apply_tool_lifecycle(
|
||||||
|
&stale,
|
||||||
|
&ToolEvent::Started {
|
||||||
|
call_id: "first".to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_err(),
|
||||||
|
ProviderRunCoordinatorError::Core(ProviderRunProtocolError::WorkMismatch { .. })
|
||||||
|
));
|
||||||
|
assert_eq!(coordinator.run(), &initial);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
coordinator
|
||||||
|
.apply_tool_lifecycle(
|
||||||
|
&first,
|
||||||
|
&ToolEvent::Started {
|
||||||
|
call_id: "other".to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_err(),
|
||||||
|
ProviderRunCoordinatorError::InvalidToolLifecycle(_)
|
||||||
|
));
|
||||||
|
assert_eq!(coordinator.run(), &initial);
|
||||||
|
|
||||||
|
coordinator
|
||||||
|
.apply_tool_lifecycle(
|
||||||
|
&second,
|
||||||
|
&tool_result("second", "done", ToolResultStatus::Success),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let after_completion = coordinator.run().clone();
|
||||||
|
assert!(matches!(
|
||||||
|
coordinator
|
||||||
|
.apply_tool_lifecycle(
|
||||||
|
&second,
|
||||||
|
&tool_result("second", "duplicate", ToolResultStatus::Success),
|
||||||
|
)
|
||||||
|
.unwrap_err(),
|
||||||
|
ProviderRunCoordinatorError::Core(ProviderRunProtocolError::DuplicateToolUpdate { .. })
|
||||||
|
));
|
||||||
|
assert_eq!(coordinator.run(), &after_completion);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn permission_denial_is_the_only_correlated_terminal_result() {
|
||||||
|
let (mut coordinator, batch) =
|
||||||
|
coordinator_awaiting_tools(vec![tool_call("shell", "read_files")]).await;
|
||||||
|
let execution_ref =
|
||||||
|
ProviderToolExecutionRef::new(AIConversationId::new(), &batch.work_id, "shell");
|
||||||
|
let request = PermissionRequest {
|
||||||
|
id: "permission-shell".to_string(),
|
||||||
|
call_id: "shell".to_string(),
|
||||||
|
kind: PermissionKind::Execute,
|
||||||
|
reason: Some("run a command".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
coordinator
|
||||||
|
.apply_tool_lifecycle(
|
||||||
|
&execution_ref,
|
||||||
|
&ToolEvent::PermissionRequested {
|
||||||
|
request: request.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
ProviderToolLifecycleOutcome::Pending
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
coordinator
|
||||||
|
.apply_tool_lifecycle(
|
||||||
|
&execution_ref,
|
||||||
|
&ToolEvent::PermissionResolved {
|
||||||
|
request_id: request.id,
|
||||||
|
call_id: request.call_id,
|
||||||
|
decision: PermissionDecision::Denied {
|
||||||
|
reason: Some("not allowed".to_string()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
ProviderToolLifecycleOutcome::BatchCommitted
|
||||||
|
);
|
||||||
|
let committed = coordinator.run().clone();
|
||||||
|
assert!(matches!(
|
||||||
|
&committed.transcript().last().unwrap().content,
|
||||||
|
MessageContent::MultiPart(parts)
|
||||||
|
if matches!(parts.as_slice(), [ContentPart::ToolResult {
|
||||||
|
tool_use_id,
|
||||||
|
content,
|
||||||
|
is_error: true,
|
||||||
|
}] if tool_use_id == "shell" && content == "not allowed")
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(coordinator
|
||||||
|
.apply_tool_lifecycle(
|
||||||
|
&execution_ref,
|
||||||
|
&tool_result("shell", "duplicate denial", ToolResultStatus::Denied),
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
assert_eq!(coordinator.run(), &committed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn execution_failure_commits_one_correlated_error_result() {
|
||||||
|
let (mut coordinator, batch) =
|
||||||
|
coordinator_awaiting_tools(vec![tool_call("read", "read_files")]).await;
|
||||||
|
let execution_ref =
|
||||||
|
ProviderToolExecutionRef::new(AIConversationId::new(), &batch.work_id, "read");
|
||||||
|
|
||||||
|
coordinator
|
||||||
|
.apply_tool_lifecycle(
|
||||||
|
&execution_ref,
|
||||||
|
&ToolEvent::Started {
|
||||||
|
call_id: "read".to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
coordinator
|
||||||
|
.apply_tool_lifecycle(
|
||||||
|
&execution_ref,
|
||||||
|
&tool_result("read", "file missing", ToolResultStatus::Error),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
ProviderToolLifecycleOutcome::BatchCommitted
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
&coordinator.run().transcript().last().unwrap().content,
|
||||||
|
MessageContent::MultiPart(parts)
|
||||||
|
if matches!(parts.as_slice(), [ContentPart::ToolResult {
|
||||||
|
tool_use_id,
|
||||||
|
content,
|
||||||
|
is_error: true,
|
||||||
|
}] if tool_use_id == "read" && content == "file missing")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn inline_tool_batches_continue_without_leaving_the_coordinator() {
|
||||||
|
let recall_turn = Ok(vec![
|
||||||
|
started("request-recall"),
|
||||||
|
Ok(AgentEvent::Tool {
|
||||||
|
event: ToolEvent::Proposed {
|
||||||
|
call: ToolCall {
|
||||||
|
id: "recall-1".to_string(),
|
||||||
|
name: galaxy_agent_core::RECALL_TOOL_HISTORY_NAME.to_string(),
|
||||||
|
arguments: serde_json::json!({"search_query": "missing"}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
usage(10, 1),
|
||||||
|
stopped(StopReason::Completed),
|
||||||
|
]);
|
||||||
|
let runtime = Arc::new(ScriptedRuntime::new(vec![recall_turn, answer_turn()]));
|
||||||
|
let mut coordinator = coordinator(runtime.clone());
|
||||||
|
let mut projections = Vec::new();
|
||||||
|
let (_sender, control) = turn_control();
|
||||||
|
|
||||||
|
let block = coordinator
|
||||||
|
.drive_until_blocked(control, collect_projection(&mut projections))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(block, ProviderRunBlock::AwaitingDriver { .. }));
|
||||||
|
assert_eq!(runtime.requests().len(), 2);
|
||||||
|
assert!(projections.iter().any(|projection| matches!(
|
||||||
|
projection,
|
||||||
|
ProviderRunProjection::ToolBatchReady { batch } if batch.is_complete()
|
||||||
|
)));
|
||||||
|
let requests = runtime.requests();
|
||||||
|
assert_eq!(requests[1].messages.len(), 3);
|
||||||
|
let MessageContent::MultiPart(results) = &requests[1].messages[2].content else {
|
||||||
|
panic!("expected inline result batch");
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
results.as_slice(),
|
||||||
|
[ContentPart::ToolResult {
|
||||||
|
tool_use_id,
|
||||||
|
is_error: false,
|
||||||
|
..
|
||||||
|
}] if tool_use_id == "recall-1"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recoverable_start_failure_retries_the_same_work_identity() {
|
||||||
|
let mut recoverable = AgentError::new(AgentErrorKind::Transport, "temporary network error");
|
||||||
|
recoverable.recoverable = true;
|
||||||
|
let runtime = Arc::new(ScriptedRuntime::new(vec![Err(recoverable), answer_turn()]));
|
||||||
|
let mut coordinator = coordinator(runtime.clone());
|
||||||
|
let mut projections = Vec::new();
|
||||||
|
let (_sender, control) = turn_control();
|
||||||
|
|
||||||
|
let block = coordinator
|
||||||
|
.drive_until_blocked(control, collect_projection(&mut projections))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(block, ProviderRunBlock::AwaitingDriver { .. }));
|
||||||
|
assert_eq!(runtime.requests().len(), 2);
|
||||||
|
assert_eq!(coordinator.run().model_retries(), 1);
|
||||||
|
let retry = projections
|
||||||
|
.iter()
|
||||||
|
.find_map(|projection| match projection {
|
||||||
|
ProviderRunProjection::ModelRetry {
|
||||||
|
work_id,
|
||||||
|
retry_attempt,
|
||||||
|
..
|
||||||
|
} => Some((work_id.clone(), *retry_attempt)),
|
||||||
|
ProviderRunProjection::ModelTurnStarted { .. }
|
||||||
|
| ProviderRunProjection::ModelEvent { .. }
|
||||||
|
| ProviderRunProjection::ToolBatchReady { .. } => None,
|
||||||
|
})
|
||||||
|
.expect("retry projection");
|
||||||
|
let started = projections
|
||||||
|
.iter()
|
||||||
|
.find_map(|projection| match projection {
|
||||||
|
ProviderRunProjection::ModelTurnStarted {
|
||||||
|
work_id,
|
||||||
|
retry_attempt: 1,
|
||||||
|
..
|
||||||
|
} => Some(work_id.clone()),
|
||||||
|
ProviderRunProjection::ModelTurnStarted { .. }
|
||||||
|
| ProviderRunProjection::ModelEvent { .. }
|
||||||
|
| ProviderRunProjection::ModelRetry { .. }
|
||||||
|
| ProviderRunProjection::ToolBatchReady { .. } => None,
|
||||||
|
})
|
||||||
|
.expect("retried model start");
|
||||||
|
assert_eq!(retry.0, started);
|
||||||
|
assert_eq!(retry.1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tool_projection_failure_synthesizes_a_result_and_fails_the_run() {
|
||||||
|
let runtime = Arc::new(ScriptedRuntime::new(vec![tool_turn()]));
|
||||||
|
let mut coordinator = coordinator(runtime);
|
||||||
|
let (_sender, control) = turn_control();
|
||||||
|
|
||||||
|
let block = coordinator
|
||||||
|
.drive_until_blocked(control, |event| match event {
|
||||||
|
ProviderRunProjection::ToolBatchReady { .. } => {
|
||||||
|
Err("task projection disappeared".to_string())
|
||||||
|
}
|
||||||
|
ProviderRunProjection::ModelTurnStarted { .. }
|
||||||
|
| ProviderRunProjection::ModelEvent { .. }
|
||||||
|
| ProviderRunProjection::ModelRetry { .. } => Ok(()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let ProviderRunBlock::Done(ProviderRunOutcome::Failed(failure)) = block else {
|
||||||
|
panic!("projection failure must terminate the run visibly");
|
||||||
|
};
|
||||||
|
assert_eq!(failure.kind, ProviderRunFailureKind::Projection);
|
||||||
|
let MessageContent::MultiPart(results) =
|
||||||
|
&coordinator.run().transcript().last().unwrap().content
|
||||||
|
else {
|
||||||
|
panic!("pending tool must receive a synthetic error result");
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
results.as_slice(),
|
||||||
|
[ContentPart::ToolResult {
|
||||||
|
tool_use_id,
|
||||||
|
is_error: true,
|
||||||
|
..
|
||||||
|
}] if tool_use_id == "read-1"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stream_without_terminal_event_fails_instead_of_committing_partial_output() {
|
||||||
|
let runtime = Arc::new(ScriptedRuntime::new(vec![Ok(vec![
|
||||||
|
started("request-eof"),
|
||||||
|
Ok(AgentEvent::TextDelta {
|
||||||
|
text: "partial".to_string(),
|
||||||
|
}),
|
||||||
|
])]));
|
||||||
|
let mut coordinator = coordinator(runtime);
|
||||||
|
let (_sender, control) = turn_control();
|
||||||
|
|
||||||
|
let block = coordinator
|
||||||
|
.drive_until_blocked(control, |_| Ok(()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let ProviderRunBlock::Done(ProviderRunOutcome::Failed(failure)) = block else {
|
||||||
|
panic!("unexpected EOF must fail the run");
|
||||||
|
};
|
||||||
|
assert_eq!(failure.kind, ProviderRunFailureKind::ModelCall);
|
||||||
|
assert_eq!(coordinator.run().transcript().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn provider_cancellation_does_not_commit_partial_assistant_content() {
|
||||||
|
let expected_transcript = request().messages;
|
||||||
|
let runtime = Arc::new(ScriptedRuntime::new(vec![Ok(vec![
|
||||||
|
started("request-cancelled"),
|
||||||
|
Ok(AgentEvent::TextDelta {
|
||||||
|
text: "partial".to_string(),
|
||||||
|
}),
|
||||||
|
stopped(StopReason::Cancelled),
|
||||||
|
])]));
|
||||||
|
let mut coordinator = coordinator(runtime);
|
||||||
|
let (_sender, control) = turn_control();
|
||||||
|
|
||||||
|
let block = coordinator
|
||||||
|
.drive_until_blocked(control, |_| Ok(()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
block,
|
||||||
|
ProviderRunBlock::Done(ProviderRunOutcome::Cancelled {
|
||||||
|
reason: "provider model call was cancelled".to_string(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(coordinator.run().transcript(), expected_transcript);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn transcript_projector_emits_one_ui_stream_for_the_whole_run() {
|
||||||
|
let runtime = Arc::new(ScriptedRuntime::new(vec![answer_turn()]));
|
||||||
|
let mut coordinator = coordinator(runtime);
|
||||||
|
let mut projector = ProviderRunResponseProjector::new(RuntimeResponseConfig {
|
||||||
|
task_id: "task".to_string(),
|
||||||
|
conversation_id: "conversation".to_string(),
|
||||||
|
needs_create_task: true,
|
||||||
|
user_query: Some("Inspect and fix the issue".to_string()),
|
||||||
|
model_id: "test-model".to_string(),
|
||||||
|
max_context_tokens: Some(100_000),
|
||||||
|
capabilities: RuntimeCapabilities::provider(),
|
||||||
|
empty_output_message: None,
|
||||||
|
});
|
||||||
|
let mut ui_events = Vec::new();
|
||||||
|
let (_sender, control) = turn_control();
|
||||||
|
|
||||||
|
let block = coordinator
|
||||||
|
.drive_until_blocked(control, |projection| {
|
||||||
|
ui_events.extend(projector.project(projection)?);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let ProviderRunBlock::AwaitingDriver { work_id, .. } = block else {
|
||||||
|
panic!("expected driver decision");
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
count_response_events(&ui_events, ResponseEventKind::Init),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
count_response_events(&ui_events, ResponseEventKind::Finished),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert!(ui_events
|
||||||
|
.iter()
|
||||||
|
.any(|event| matches!(event.r#type, Some(response_event::Type::ClientActions(_)))));
|
||||||
|
|
||||||
|
coordinator.run_mut().complete(&work_id).unwrap();
|
||||||
|
let (_sender, control) = turn_control();
|
||||||
|
let ProviderRunBlock::Done(outcome) = coordinator
|
||||||
|
.drive_until_blocked(control, |_| Ok(()))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!("expected terminal run");
|
||||||
|
};
|
||||||
|
ui_events.extend(projector.finish(&outcome).unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
count_response_events(&ui_events, ResponseEventKind::Finished),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transcript_projector_preserves_provider_failure_message() {
|
||||||
|
let mut projector = ProviderRunResponseProjector::new(RuntimeResponseConfig {
|
||||||
|
task_id: "task".to_string(),
|
||||||
|
conversation_id: "conversation".to_string(),
|
||||||
|
needs_create_task: false,
|
||||||
|
user_query: None,
|
||||||
|
model_id: "test-model".to_string(),
|
||||||
|
max_context_tokens: Some(100_000),
|
||||||
|
capabilities: RuntimeCapabilities::provider(),
|
||||||
|
empty_output_message: None,
|
||||||
|
});
|
||||||
|
let events = projector
|
||||||
|
.finish(&ProviderRunOutcome::Failed(ProviderRunFailure {
|
||||||
|
kind: ProviderRunFailureKind::ModelCall,
|
||||||
|
message: "upstream provider rejected the request".to_string(),
|
||||||
|
source: None,
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let finished = events
|
||||||
|
.iter()
|
||||||
|
.find_map(|event| match &event.r#type {
|
||||||
|
Some(response_event::Type::Finished(finished)) => Some(finished),
|
||||||
|
Some(response_event::Type::Init(_))
|
||||||
|
| Some(response_event::Type::ClientActions(_))
|
||||||
|
| None => None,
|
||||||
|
})
|
||||||
|
.expect("terminal provider failure");
|
||||||
|
assert!(matches!(
|
||||||
|
&finished.reason,
|
||||||
|
Some(response_event::stream_finished::Reason::InternalError(error))
|
||||||
|
if error.message == "upstream provider rejected the request"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum ResponseEventKind {
|
||||||
|
Init,
|
||||||
|
Finished,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count_response_events(
|
||||||
|
events: &[warp_multi_agent_api::ResponseEvent],
|
||||||
|
kind: ResponseEventKind,
|
||||||
|
) -> usize {
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| match (&event.r#type, kind) {
|
||||||
|
(Some(response_event::Type::Init(_)), ResponseEventKind::Init)
|
||||||
|
| (Some(response_event::Type::Finished(_)), ResponseEventKind::Finished) => true,
|
||||||
|
(Some(response_event::Type::ClientActions(_)), ResponseEventKind::Init)
|
||||||
|
| (Some(response_event::Type::ClientActions(_)), ResponseEventKind::Finished)
|
||||||
|
| (Some(response_event::Type::Init(_)), ResponseEventKind::Finished)
|
||||||
|
| (Some(response_event::Type::Finished(_)), ResponseEventKind::Init)
|
||||||
|
| (None, ResponseEventKind::Init)
|
||||||
|
| (None, ResponseEventKind::Finished) => false,
|
||||||
|
})
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_runtime_is_rejected_before_any_turn_can_start() {
|
||||||
|
struct SessionRuntime {
|
||||||
|
descriptor: RuntimeDescriptor,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AgentRuntime for SessionRuntime {
|
||||||
|
fn descriptor(&self) -> &RuntimeDescriptor {
|
||||||
|
&self.descriptor
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_turn(
|
||||||
|
&self,
|
||||||
|
_request: TurnRequest,
|
||||||
|
_control: TurnControl,
|
||||||
|
) -> Result<AgentEventStream, AgentError> {
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let runtime = Arc::new(SessionRuntime {
|
||||||
|
descriptor: RuntimeDescriptor {
|
||||||
|
id: "session".to_string(),
|
||||||
|
display_name: "Session runtime".to_string(),
|
||||||
|
kind: RuntimeKind::Acp,
|
||||||
|
capabilities: RuntimeCapabilities::session_runtime(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let error = ProviderRunCoordinator::from_request(
|
||||||
|
"run-1",
|
||||||
|
runtime,
|
||||||
|
request(),
|
||||||
|
Vec::new(),
|
||||||
|
ProviderRunLimits::default(),
|
||||||
|
)
|
||||||
|
.err()
|
||||||
|
.expect("session runtime must be rejected");
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
ProviderRunCoordinatorError::InvalidRuntime(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
+206
-566
@@ -1,12 +1,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::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;
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -1,468 +0,0 @@
|
|||||||
use std::collections::{HashMap, VecDeque};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
use ai::skills::SkillPathOrigin;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use futures::channel::oneshot;
|
|
||||||
use futures::StreamExt;
|
|
||||||
use galaxy_agent_core::{
|
|
||||||
AgentError, AgentErrorKind, AgentEvent, AgentEventStream, AgentRuntime, ContentPart,
|
|
||||||
ConversationMessage, MessageContent, MessageRole, RuntimeCapabilities, RuntimeDescriptor,
|
|
||||||
RuntimeKind, StopReason, ToolCall, ToolDefinition, ToolEvent, ToolResult, ToolResultStatus,
|
|
||||||
TurnControl, TurnRequest, Usage, RECALL_TOOL_HISTORY_NAME,
|
|
||||||
};
|
|
||||||
use warp_multi_agent_api::{client_action, message, response_event};
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
append_tool_result, build_tool_proposed, rig_response_stream, sync_assistant_turn,
|
|
||||||
PreparedRigTurn, INLINE_TOOL_LOOP_MESSAGE, MAX_INLINE_TOOL_CONTINUATIONS,
|
|
||||||
};
|
|
||||||
use crate::ai::agent::api::StreamEvent;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tool_proposal_matches_the_domain_permission_contract() {
|
|
||||||
let action = build_tool_proposed(
|
|
||||||
"task",
|
|
||||||
&ToolCall {
|
|
||||||
id: "call-1".to_string(),
|
|
||||||
name: "run_shell_command".to_string(),
|
|
||||||
arguments: serde_json::json!({
|
|
||||||
"command": "cargo test",
|
|
||||||
"is_read_only": true
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
&SkillPathOrigin::Local,
|
|
||||||
&HashMap::new(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(action.id.to_string(), "call-1");
|
|
||||||
assert!(matches!(
|
|
||||||
action.action,
|
|
||||||
crate::ai::agent::AIAgentActionType::RequestCommandOutput {
|
|
||||||
command,
|
|
||||||
is_read_only: Some(true),
|
|
||||||
..
|
|
||||||
} if command == "cargo test"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mcp_tool_proposal_routes_directly_to_the_mcp_executor_contract() {
|
|
||||||
let action = build_tool_proposed(
|
|
||||||
"task",
|
|
||||||
&ToolCall {
|
|
||||||
id: "call-mcp".to_string(),
|
|
||||||
name: "mcp__11111111-1111-4111-8111-111111111111__read_file".to_string(),
|
|
||||||
arguments: serde_json::json!({"path": "Cargo.toml"}),
|
|
||||||
},
|
|
||||||
&SkillPathOrigin::Local,
|
|
||||||
&HashMap::new(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
action.action,
|
|
||||||
crate::ai::agent::AIAgentActionType::CallMCPTool {
|
|
||||||
server_id: Some(server_id),
|
|
||||||
name,
|
|
||||||
..
|
|
||||||
} if server_id.to_string() == "11111111-1111-4111-8111-111111111111"
|
|
||||||
&& name == "read_file"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn assistant_history_is_updated_before_fast_tool_execution_can_continue() {
|
|
||||||
let messages = Arc::new(Mutex::new(Vec::new()));
|
|
||||||
let mut history_index = None;
|
|
||||||
let first_call = ToolCall {
|
|
||||||
id: "call-1".to_string(),
|
|
||||||
name: "read_files".to_string(),
|
|
||||||
arguments: serde_json::json!({"files": ["Cargo.toml"]}),
|
|
||||||
};
|
|
||||||
let second_call = ToolCall {
|
|
||||||
id: "call-2".to_string(),
|
|
||||||
name: "grep".to_string(),
|
|
||||||
arguments: serde_json::json!({"queries": ["rig"]}),
|
|
||||||
};
|
|
||||||
|
|
||||||
sync_assistant_turn(
|
|
||||||
&messages,
|
|
||||||
"",
|
|
||||||
None,
|
|
||||||
"I'll inspect both.",
|
|
||||||
std::slice::from_ref(&first_call),
|
|
||||||
&mut history_index,
|
|
||||||
);
|
|
||||||
sync_assistant_turn(
|
|
||||||
&messages,
|
|
||||||
"",
|
|
||||||
None,
|
|
||||||
"I'll inspect both.",
|
|
||||||
&[first_call, second_call],
|
|
||||||
&mut history_index,
|
|
||||||
);
|
|
||||||
|
|
||||||
let messages = messages.lock().unwrap();
|
|
||||||
assert_eq!(messages.len(), 1);
|
|
||||||
let MessageContent::MultiPart(parts) = &messages[0].content else {
|
|
||||||
panic!("expected combined assistant content");
|
|
||||||
};
|
|
||||||
assert_eq!(parts.len(), 3);
|
|
||||||
assert!(
|
|
||||||
matches!(&parts[0], galaxy_agent_core::ContentPart::Text(text) if text == "I'll inspect both.")
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
matches!(&parts[1], galaxy_agent_core::ContentPart::ToolUse { tool_use_id, .. } if tool_use_id == "call-1")
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
matches!(&parts[2], galaxy_agent_core::ContentPart::ToolUse { tool_use_id, .. } if tool_use_id == "call-2")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn signed_reasoning_is_persisted_before_the_tool_call() {
|
|
||||||
let messages = Arc::new(Mutex::new(Vec::new()));
|
|
||||||
let mut history_index = None;
|
|
||||||
let call = ToolCall {
|
|
||||||
id: "call-1".to_string(),
|
|
||||||
name: "read_files".to_string(),
|
|
||||||
arguments: serde_json::json!({"files": ["Cargo.toml"]}),
|
|
||||||
};
|
|
||||||
|
|
||||||
sync_assistant_turn(
|
|
||||||
&messages,
|
|
||||||
"I should inspect the manifest.",
|
|
||||||
Some("signed-reasoning"),
|
|
||||||
"",
|
|
||||||
std::slice::from_ref(&call),
|
|
||||||
&mut history_index,
|
|
||||||
);
|
|
||||||
|
|
||||||
let messages = messages.lock().unwrap();
|
|
||||||
let MessageContent::MultiPart(parts) = &messages[0].content else {
|
|
||||||
panic!("expected reasoning and tool call parts");
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
parts.as_slice(),
|
|
||||||
[
|
|
||||||
ContentPart::Reasoning {
|
|
||||||
text,
|
|
||||||
signature: Some(signature),
|
|
||||||
},
|
|
||||||
ContentPart::ToolUse { tool_use_id, .. },
|
|
||||||
] if text == "I should inspect the manifest."
|
|
||||||
&& signature == "signed-reasoning"
|
|
||||||
&& tool_use_id == "call-1"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejected_tool_result_is_paired_with_the_assistant_call_in_history() {
|
|
||||||
let messages = Arc::new(Mutex::new(Vec::new()));
|
|
||||||
let mut history_index = None;
|
|
||||||
let call = ToolCall {
|
|
||||||
id: "call-unknown".to_string(),
|
|
||||||
name: "invented_tool".to_string(),
|
|
||||||
arguments: serde_json::json!({}),
|
|
||||||
};
|
|
||||||
sync_assistant_turn(
|
|
||||||
&messages,
|
|
||||||
"",
|
|
||||||
None,
|
|
||||||
"",
|
|
||||||
std::slice::from_ref(&call),
|
|
||||||
&mut history_index,
|
|
||||||
);
|
|
||||||
append_tool_result(
|
|
||||||
&messages,
|
|
||||||
ToolResult {
|
|
||||||
call_id: call.id.clone(),
|
|
||||||
content: "tool is unavailable".to_string(),
|
|
||||||
status: ToolResultStatus::Error,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let messages = messages.lock().unwrap();
|
|
||||||
assert_eq!(messages.len(), 2);
|
|
||||||
assert_eq!(messages[0].role, MessageRole::Assistant);
|
|
||||||
assert!(matches!(
|
|
||||||
&messages[0].content,
|
|
||||||
MessageContent::ToolUse {
|
|
||||||
tool_use_id,
|
|
||||||
name,
|
|
||||||
..
|
|
||||||
} if tool_use_id == "call-unknown" && name == "invented_tool"
|
|
||||||
));
|
|
||||||
assert_eq!(messages[1].role, MessageRole::User);
|
|
||||||
assert!(matches!(
|
|
||||||
&messages[1].content,
|
|
||||||
MessageContent::ToolResult {
|
|
||||||
tool_use_id,
|
|
||||||
content,
|
|
||||||
is_error: true,
|
|
||||||
} if tool_use_id == "call-unknown" && content == "tool is unavailable"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ScriptedRuntime {
|
|
||||||
descriptor: RuntimeDescriptor,
|
|
||||||
turns: Mutex<VecDeque<Vec<AgentEvent>>>,
|
|
||||||
requests: Arc<Mutex<Vec<TurnRequest>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ScriptedRuntime {
|
|
||||||
fn new(turns: Vec<Vec<AgentEvent>>, requests: Arc<Mutex<Vec<TurnRequest>>>) -> Self {
|
|
||||||
Self {
|
|
||||||
descriptor: RuntimeDescriptor {
|
|
||||||
id: "scripted-provider".to_string(),
|
|
||||||
display_name: "Scripted provider".to_string(),
|
|
||||||
kind: RuntimeKind::Provider,
|
|
||||||
capabilities: RuntimeCapabilities::provider(),
|
|
||||||
},
|
|
||||||
turns: Mutex::new(turns.into()),
|
|
||||||
requests,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl AgentRuntime for ScriptedRuntime {
|
|
||||||
fn descriptor(&self) -> &RuntimeDescriptor {
|
|
||||||
&self.descriptor
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn start_turn(
|
|
||||||
&self,
|
|
||||||
request: TurnRequest,
|
|
||||||
_control: TurnControl,
|
|
||||||
) -> Result<AgentEventStream, AgentError> {
|
|
||||||
self.requests.lock().unwrap().push(request);
|
|
||||||
let events = self.turns.lock().unwrap().pop_front().ok_or_else(|| {
|
|
||||||
AgentError::new(
|
|
||||||
AgentErrorKind::Protocol,
|
|
||||||
"scripted provider ran out of turns",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn recall_turn(index: usize) -> Vec<AgentEvent> {
|
|
||||||
vec![
|
|
||||||
AgentEvent::TurnStarted {
|
|
||||||
runtime_request_id: format!("request-{index}"),
|
|
||||||
},
|
|
||||||
AgentEvent::Tool {
|
|
||||||
event: ToolEvent::Proposed {
|
|
||||||
call: ToolCall {
|
|
||||||
id: format!("recall-{index}"),
|
|
||||||
name: RECALL_TOOL_HISTORY_NAME.to_string(),
|
|
||||||
arguments: serde_json::json!({"search_query": "missing"}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AgentEvent::UsageUpdated {
|
|
||||||
usage: Usage {
|
|
||||||
input_tokens: 10,
|
|
||||||
output_tokens: 1,
|
|
||||||
..Usage::default()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AgentEvent::TurnStopped {
|
|
||||||
reason: StopReason::Completed,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn answer_turn() -> Vec<AgentEvent> {
|
|
||||||
vec![
|
|
||||||
AgentEvent::TurnStarted {
|
|
||||||
runtime_request_id: "request-answer".to_string(),
|
|
||||||
},
|
|
||||||
AgentEvent::TextDelta {
|
|
||||||
text: "Continuing with the answer.".to_string(),
|
|
||||||
},
|
|
||||||
AgentEvent::UsageUpdated {
|
|
||||||
usage: Usage {
|
|
||||||
input_tokens: 20,
|
|
||||||
output_tokens: 3,
|
|
||||||
..Usage::default()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AgentEvent::TurnStopped {
|
|
||||||
reason: StopReason::Completed,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prepared_turn(messages_sent: Arc<Mutex<Vec<ConversationMessage>>>) -> PreparedRigTurn {
|
|
||||||
let initial_messages = vec![ConversationMessage {
|
|
||||||
role: MessageRole::User,
|
|
||||||
content: MessageContent::Text("Inspect the issue.".to_string()),
|
|
||||||
}];
|
|
||||||
let mut request = TurnRequest::new("test-model", initial_messages.clone());
|
|
||||||
request.conversation_id = Some("conversation".to_string());
|
|
||||||
request.tools = vec![ToolDefinition {
|
|
||||||
name: RECALL_TOOL_HISTORY_NAME.to_string(),
|
|
||||||
description: "Recall prior tool output".to_string(),
|
|
||||||
input_schema: serde_json::json!({"type": "object"}),
|
|
||||||
}];
|
|
||||||
PreparedRigTurn {
|
|
||||||
task_id: "task".to_string(),
|
|
||||||
needs_create_task: false,
|
|
||||||
user_query: None,
|
|
||||||
request,
|
|
||||||
persistent_messages: initial_messages,
|
|
||||||
tool_result_archive: Vec::new(),
|
|
||||||
messages_sent,
|
|
||||||
mcp_tool_aliases: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_scripted_turn(
|
|
||||||
turns: Vec<Vec<AgentEvent>>,
|
|
||||||
) -> (Vec<StreamEvent>, Vec<TurnRequest>, Vec<ConversationMessage>) {
|
|
||||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
|
||||||
let messages_sent = Arc::new(Mutex::new(Vec::new()));
|
|
||||||
let runtime = ScriptedRuntime::new(turns, requests.clone());
|
|
||||||
let (cancel_tx, cancellation_rx) = oneshot::channel();
|
|
||||||
let events = rig_response_stream(
|
|
||||||
runtime,
|
|
||||||
prepared_turn(messages_sent.clone()),
|
|
||||||
SkillPathOrigin::Local,
|
|
||||||
Some(100_000),
|
|
||||||
"scripted",
|
|
||||||
cancellation_rx,
|
|
||||||
)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.await
|
|
||||||
.into_iter()
|
|
||||||
.collect::<Result<Vec<_>, _>>()
|
|
||||||
.expect("scripted response should succeed");
|
|
||||||
drop(cancel_tx);
|
|
||||||
let requests = requests.lock().unwrap().clone();
|
|
||||||
let messages_sent = messages_sent.lock().unwrap().clone();
|
|
||||||
(events, requests, messages_sent)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn agent_output_texts(events: &[StreamEvent]) -> Vec<&str> {
|
|
||||||
let mut texts = Vec::new();
|
|
||||||
for event in events {
|
|
||||||
let StreamEvent::Response(response) = event else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(response_event::Type::ClientActions(actions)) = &response.r#type else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
for action in &actions.actions {
|
|
||||||
let Some(client_action::Action::AddMessagesToTask(add)) = &action.action else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
for message in &add.messages {
|
|
||||||
if let Some(message::Message::AgentOutput(output)) = &message.message {
|
|
||||||
texts.push(output.text.as_str());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
texts
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn inline_recall_starts_a_followup_provider_turn_with_the_paired_result() {
|
|
||||||
let (events, requests, messages_sent) =
|
|
||||||
run_scripted_turn(vec![recall_turn(1), answer_turn()]).await;
|
|
||||||
|
|
||||||
assert_eq!(requests.len(), 2);
|
|
||||||
assert_eq!(requests[1].messages.len(), 3);
|
|
||||||
assert!(matches!(
|
|
||||||
&requests[1].messages[1].content,
|
|
||||||
MessageContent::ToolUse {
|
|
||||||
tool_use_id,
|
|
||||||
name,
|
|
||||||
..
|
|
||||||
} if tool_use_id == "recall-1" && name == RECALL_TOOL_HISTORY_NAME
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
&requests[1].messages[2].content,
|
|
||||||
MessageContent::ToolResult {
|
|
||||||
tool_use_id,
|
|
||||||
content,
|
|
||||||
is_error: false,
|
|
||||||
} if tool_use_id == "recall-1"
|
|
||||||
&& content == "No matching tool calls found in conversation history."
|
|
||||||
));
|
|
||||||
assert_eq!(
|
|
||||||
events
|
|
||||||
.iter()
|
|
||||||
.filter(|event| matches!(
|
|
||||||
event,
|
|
||||||
StreamEvent::Response(response)
|
|
||||||
if matches!(response.r#type, Some(response_event::Type::Init(_)))
|
|
||||||
))
|
|
||||||
.count(),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
events
|
|
||||||
.iter()
|
|
||||||
.filter(|event| matches!(
|
|
||||||
event,
|
|
||||||
StreamEvent::Response(response)
|
|
||||||
if matches!(response.r#type, Some(response_event::Type::Finished(_)))
|
|
||||||
))
|
|
||||||
.count(),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
assert!(events
|
|
||||||
.iter()
|
|
||||||
.all(|event| !matches!(event, StreamEvent::ToolProposed(_))));
|
|
||||||
assert_eq!(agent_output_texts(&events), ["Continuing with the answer."]);
|
|
||||||
|
|
||||||
let finished = events.iter().find_map(|event| {
|
|
||||||
let StreamEvent::Response(response) = event else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
let Some(response_event::Type::Finished(finished)) = &response.r#type else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
Some(finished)
|
|
||||||
});
|
|
||||||
let finished = finished.expect("stream should finish");
|
|
||||||
assert_eq!(finished.token_usage[0].total_input, 30);
|
|
||||||
assert_eq!(finished.token_usage[0].output, 4);
|
|
||||||
assert!(matches!(
|
|
||||||
messages_sent.last().map(|message| &message.content),
|
|
||||||
Some(MessageContent::Text(text)) if text == "Continuing with the answer."
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn repeated_inline_recall_stops_with_a_visible_loop_limit_message() {
|
|
||||||
let turns = (0..=MAX_INLINE_TOOL_CONTINUATIONS)
|
|
||||||
.map(recall_turn)
|
|
||||||
.collect();
|
|
||||||
let (events, requests, messages_sent) = run_scripted_turn(turns).await;
|
|
||||||
|
|
||||||
assert_eq!(requests.len(), MAX_INLINE_TOOL_CONTINUATIONS + 1);
|
|
||||||
assert!(agent_output_texts(&events).contains(&INLINE_TOOL_LOOP_MESSAGE));
|
|
||||||
assert!(matches!(
|
|
||||||
messages_sent.last().map(|message| &message.content),
|
|
||||||
Some(MessageContent::Text(text)) if text == INLINE_TOOL_LOOP_MESSAGE
|
|
||||||
));
|
|
||||||
let finished = events.iter().find_map(|event| {
|
|
||||||
let StreamEvent::Response(response) = event else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
let Some(response_event::Type::Finished(finished)) = &response.r#type else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
Some(finished)
|
|
||||||
});
|
|
||||||
assert!(matches!(
|
|
||||||
finished.and_then(|finished| finished.reason.as_ref()),
|
|
||||||
Some(response_event::stream_finished::Reason::Other(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,7 @@ use crate::ai::agent::{
|
|||||||
};
|
};
|
||||||
use crate::ai::document::ai_document_model::AIDocumentId;
|
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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user