v1.3.0: Bedrock translator refactor, usage metrics, session restore fixes, and predefined rules

Major changes:

- **Bedrock translator architecture**: Extract orchestration logic from `impl.rs` into
  a dedicated `translator.rs` module. Rename `convert_request.rs` → `request_translator.rs`
  and `stream.rs` → `response_translator.rs` for clarity. Remove `tool_docs.rs` (inlined).
  Remove `fallback_to_warp` setting and server fallback path — Bedrock is now the sole backend.

- **Unknown tool handling**: The response translator now detects hallucinated/unknown tool
  calls from the model and synthesizes error tool_results so the conversation doesn't
  deadlock waiting for a result that will never come.

- **Usage display overhaul**: Replace credit-based usage display with detailed token metrics
  showing context window %, cache hit rate (read/write/miss), and estimated cost in dollars.
  Add `total_input_tokens`, `total_cache_read_tokens`, `total_cache_write_tokens`, and
  `cache_miss_tokens` accessors to `AIConversation`.

- **Predefined rules system**: Add `predefined_rules.rs` with 11 system-defined behavioral
  rules that are auto-seeded on first launch. Add "Add Predefined Rules" button to the
  Rules UI for re-adding them later. Track seeding state via `has_seeded_predefined_rules`
  setting.

- **Session restore improvements**: Rename database file from `warp.sqlite` to
  `galaxy.sqlite` with automatic migration from both same-directory and state_dir legacy
  paths. Improve CWD persistence by falling back to `session_startup_path` for agent-mode
  and fresh tabs. Add extensive session-save/restore logging.

- **Shell bootstrap rebrand**: Rename `WARP_INITIAL_WORKING_DIR` environment variable to
  `GALAXY_INITIAL_WORKING_DIR` across bash, zsh, and fish bootstrap scripts.

- **Model defaults**: Change default Bedrock model from Opus 4.7 to Opus 4.6.
  Add `context_window_for_model()` helper with model-aware context sizes.
  Remove `is_bedrock_model()` (no longer needed without server fallback).

- **User query persistence**: The response translator now emits a `UserQuery` proto message
  at stream start so the user's prompt persists across sessions for conversation titles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ryan Ward
2026-05-20 15:15:28 -05:00
co-authored by Claude Opus 4.6
parent ec99146ccc
commit eaa2ddc75e
38 changed files with 1687 additions and 1129 deletions
+54
View File
@@ -41,6 +41,34 @@ Environment variables:
- `Error_<timestamp>.txt` snapshot files written to the repository root on request/stream failures (includes the serialized Bedrock context window, tool definitions, protobuf request debug payload, captured Bedrock diagnostic lines, and log tails)
- Per-event Bedrock diagnostic logs written to `bedrock-diagnostics.log` in the active Warp log directory
### Bedrock Translator Architecture
The Bedrock integration uses a **translator service pattern** where Warp proto types flow in, get converted to Bedrock SDK types, and responses are translated back:
```
Warp UI (proto) → translator.rs → request_translator.rs → Bedrock API
Warp UI (proto) ← response_translator.rs ← Bedrock stream
```
Key files in `app/src/ai/bedrock/`:
- `translator.rs` — Orchestrator: takes `api::Request` + config, returns `ResponseStream`
- `request_translator.rs` — Converts Warp proto → Bedrock SDK types (messages, system prompt, tools, sanitization)
- `response_translator.rs` — Converts Bedrock stream events → Warp proto `ResponseEvent`s
- `convert.rs` — Shared types (`ConversationMessage`, `ToolDefinition`) and Bedrock SDK type builders
- `client.rs` — AWS SDK client construction and `converse_stream` call
- `models.rs` — Model registry and cross-region inference prefix logic
- `discovery.rs` — AWS profile and model listing
- `diagnostic.rs` — Debug logging (enabled via `GALAXY_BEDROCK_DIAGNOSTICS=1`)
- `external_config.rs` — Fallback config from Claude Code/OpenCode settings
Key invariants:
- Known tools are in `KNOWN_TOOLS` constant in `response_translator.rs`
- Tool definitions are built via `tool_definition_for_name()` in `request_translator.rs`
- Unknown/hallucinated tool calls are caught in the stream, paired with synthetic error results
- Prompt caching uses three cache points: system prompt, conversation history (second-to-last message), tool config
- `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
- The stream emits a `UserQuery` proto message at the start of each response for conversation title
### Platform Setup
- `./script/bootstrap` - Platform-specific setup (calls platform-specific bootstrap scripts)
- `./script/install_cargo_build_deps` - Install Cargo build dependencies
@@ -141,6 +169,14 @@ This is a Rust-based terminal emulator with a custom UI framework called **WarpU
- Uses Diesel ORM with SQLite
- Migrations in `migrations/` directory
- Schema defined in `app/src/persistence/schema.rs`
- Database file is `galaxy.sqlite` (renamed from Warp's `warp.sqlite`); legacy filename migration is handled in `init_db()`
**Session Restoration**:
- Controlled by `general.restore_session` setting
- App state (windows, tabs, pane tree, CWD, agent conversations) is snapshotted to SQLite on window events (close, move, resize, focus change)
- `TerminalView::active_session_path_if_local()` provides the CWD for each pane; falls back to `session_startup_path` for agent-mode or fresh tabs
- Agent conversations are persisted via `BlocklistAIHistoryEvent` → `ModelEvent::UpsertAIQuery` and restored via `RestoredAgentConversations` singleton
- The `active_conversation_id` field in `TerminalPaneSnapshot` controls whether agent view restores in fullscreen mode
**GraphQL**:
- Schema and client code generation from `graphql/api/schema.graphql`
@@ -184,6 +220,24 @@ if FeatureFlag::YourNewFeature.is_enabled() {
When adding/editing match statements, avoid using the wildcard _ when at all possible. Exhaustive matching is helpful for ensuring that all variants are handled, especially when adding new variants to enums in the future.
### Rules System
Global rules (behavioral instructions for the AI agent) are stored as `AIFact::Memory` cloud objects and managed via the Rules settings pane.
Key files:
- `app/src/ai/facts/mod.rs` — `AIFact` / `AIMemory` data model
- `app/src/ai/facts/predefined_rules.rs` — Default system-defined rules (seeded on first launch)
- `app/src/ai/facts/view/rule.rs` — `RuleView` UI with Global/Project tabs and "Add Predefined Rules" button
- `app/src/ai/facts/view/mod.rs` — `AIFactView` parent container (Rules + RuleEditor pages)
- `app/src/ai/facts/manager.rs` — `AIFactManager` singleton for pane tracking
- `app/src/settings/ai.rs` — `has_seeded_predefined_rules` setting (one-time flag)
Behavior:
- On first launch (no existing global rules and `has_seeded_predefined_rules` is false), predefined rules are automatically created
- The "Add Predefined Rules" button in the Global rules tab will add/update system-defined rules (identified by the "System Defined Rule" name prefix)
- Rules are persisted via the cloud object sync system (`UpdateManager::create_ai_fact` / `update_ai_fact`)
- The `memory_enabled` setting (`agents.knowledge.rules_enabled`) controls whether rules are sent to the AI
### Appearance Settings Notes
- Samsung-inspired built-in themes are available as `SamsungDark` and `SamsungLight`.
+1 -1
View File
@@ -5,7 +5,7 @@ description = "Galaxy - AI-powered terminal"
edition = "2021"
autobins = false
name = "galaxy"
version = "1.2.1"
version = "1.3.0"
publish.workspace = true
license.workspace = true
+3 -3
View File
@@ -37,9 +37,9 @@ if [ -z "$WARP_BOOTSTRAPPED" ]; then
# Attempt to cd to the desired initial working directory, swallowing any
# errors. If this fails, the user will end up in their home directory.
if [[ ! -z "$WARP_INITIAL_WORKING_DIR" ]]; then
cd "$WARP_INITIAL_WORKING_DIR" >/dev/null 2>&1
unset WARP_INITIAL_WORKING_DIR
if [[ ! -z "$GALAXY_INITIAL_WORKING_DIR" ]]; then
cd "$GALAXY_INITIAL_WORKING_DIR" >/dev/null 2>&1
unset GALAXY_INITIAL_WORKING_DIR
fi
# We configure history to `ignorespace` to avoid leaking our bootstrap script
+3 -3
View File
@@ -34,9 +34,9 @@ set -g OSC_PARAM_SEPARATOR ';'
set -g RESET_GRID_OSC (printf '\e]9279\a')
if test -n "$WARP_INITIAL_WORKING_DIR"
cd "$WARP_INITIAL_WORKING_DIR" >/dev/null 2>&1
set -e WARP_INITIAL_WORKING_DIR
if test -n "$GALAXY_INITIAL_WORKING_DIR"
cd "$GALAXY_INITIAL_WORKING_DIR" >/dev/null 2>&1
set -e GALAXY_INITIAL_WORKING_DIR
end
# Append additional PATH entries if provided via WARP_PATH_APPEND.
+3 -3
View File
@@ -46,9 +46,9 @@ if [[ -z $WARP_BOOTSTRAPPED ]]; then
# Attempt to cd to the desired initial working directory, swallowing any
# errors. If this fails, the user will end up in their home directory.
if [[ ! -z "$WARP_INITIAL_WORKING_DIR" ]]; then
cd "$WARP_INITIAL_WORKING_DIR" >/dev/null 2>&1
unset WARP_INITIAL_WORKING_DIR
if [[ ! -z "$GALAXY_INITIAL_WORKING_DIR" ]]; then
cd "$GALAXY_INITIAL_WORKING_DIR" >/dev/null 2>&1
unset GALAXY_INITIAL_WORKING_DIR
fi
# We configure history to ignore commands starting with space to avoid leaking
+45 -214
View File
@@ -5,14 +5,12 @@ use futures_util::StreamExt;
use galaxy_core::features::FeatureFlag;
use warp_multi_agent_api as api;
use crate::ai::bedrock::client::{BedrockClient, BedrockClientConfig};
use crate::ai::bedrock::diagnostic::BedrockDiagnosticLogger;
use crate::server::server_api::ServerApi;
use crate::ai::bedrock::client::BedrockClientConfig;
use crate::ai::bedrock::translator::{self, TranslatorRequest};
use super::{convert_to::convert_input, ConvertToAPITypeError, RequestParams, ResponseStream};
pub async fn generate_multi_agent_output(
_server_api: Arc<ServerApi>,
bedrock_config: Option<BedrockClientConfig>,
mut params: RequestParams,
cancellation_rx: futures::channel::oneshot::Receiver<()>,
@@ -59,7 +57,7 @@ pub async fn generate_multi_agent_output(
api_keys.allow_use_of_warp_credits = params.allow_use_of_warp_credits_with_byok;
}
let request = api::Request {
let mut request = api::Request {
task_context: Some(api::request::TaskContext {
tasks: params.tasks,
}),
@@ -114,8 +112,6 @@ pub async fn generate_multi_agent_output(
.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 asigned a new id yet).
params
.forked_from_conversation_token
.map(|token| token.as_str().to_string())
@@ -132,208 +128,50 @@ pub async fn generate_multi_agent_output(
mcp_context: params.mcp_context.map(Into::into),
};
if let Some(config) = bedrock_config {
let model_id_for_fallback_check = request
.settings
.as_ref()
.and_then(|s| s.model_config.as_ref())
.map(|mc| mc.base.clone())
.unwrap_or_default();
let is_arn = model_id_for_fallback_check.starts_with("arn:");
let fallback_to_warp = config.fallback_to_warp && !is_arn;
if is_arn && config.fallback_to_warp {
log::info!(
"[bedrock] Fallback disabled for ARN-based model (not available on Warp server)"
);
let Some(config) = bedrock_config else {
log::error!("[bedrock] No Bedrock config available. Cannot process request.");
let err = Arc::new(crate::server::server_api::AIApiError::Stream {
stream_type: "bedrock_converse",
source: anyhow::anyhow!(
"No AI backend available. Please configure Bedrock credentials in Settings > AI."
),
});
let (tx, rx) = async_channel::unbounded();
let _ = tx.send(Err(err)).await;
return Ok(Box::pin(rx));
};
let model_id = request
.settings
.as_ref()
.and_then(|s| s.model_config.as_ref())
.map(|mc| mc.base.clone())
.unwrap_or_default();
let translator_request = TranslatorRequest {
config,
model_id,
root_task_id: params.root_task_id.clone(),
bedrock_message_history: params.bedrock_message_history.clone(),
bedrock_messages_sent: params.bedrock_messages_sent.clone(),
};
match translator::execute(translator_request, &mut request).await {
Ok(stream) => {
let output_stream = stream.take_until(cancellation_rx);
Ok(Box::pin(output_stream))
}
match BedrockClient::from_config(config).await {
Ok(bedrock) => {
let task_id = params.root_task_id.clone().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())
});
log::info!("[bedrock] Starting stream with task_id={task_id}");
let needs_create_task = request
.task_context
.as_ref()
.map(|tc| tc.tasks.is_empty())
.unwrap_or(true);
log::info!("[bedrock] needs_create_task={needs_create_task}");
let mut model_id = request
.settings
.as_ref()
.and_then(|s| s.model_config.as_ref())
.map(|mc| mc.base.clone())
.unwrap_or_default();
if model_id.is_empty() || model_id == "auto" {
model_id = "us.anthropic.claude-opus-4-6".to_string();
}
log::info!("[bedrock] Model: {model_id}");
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);
}
// Build message list from bedrock_message_history + new input messages.
// The history contains all prior messages. We extract only NEW messages
// from the current request input and append them.
let new_input_messages =
crate::ai::bedrock::convert_request::extract_new_input_messages(&request);
let mut messages = params.bedrock_message_history.clone();
if !new_input_messages.is_empty() {
log::info!(
"[bedrock] Appending {} new input messages to history of {}",
new_input_messages.len(),
messages.len()
);
messages.extend(new_input_messages);
}
// Sanitize: ensure every tool_use has a matching tool_result
// immediately after, and that the conversation starts with a
// user message. Without this, interrupted tool calls cause
// Bedrock ValidationException errors.
crate::ai::bedrock::convert_request::sanitize_messages_for_bedrock(&mut messages);
let system_prompt =
crate::ai::bedrock::convert_request::extract_system_prompt(&request);
let tools = crate::ai::bedrock::convert_request::extract_tools(&request);
log::info!(
"[bedrock] Sending {} messages, system_prompt={}, tools={}",
messages.len(),
system_prompt.is_some(),
tools.len()
);
for (i, msg) in messages.iter().enumerate() {
let content_desc = match &msg.content {
crate::ai::bedrock::convert::MessageContent::Text(t) => {
format!("Text({}chars)", t.len())
}
crate::ai::bedrock::convert::MessageContent::ToolUse {
tool_use_id,
name,
..
} => {
format!("ToolUse(name={}, id={})", name, tool_use_id)
}
crate::ai::bedrock::convert::MessageContent::ToolResult {
tool_use_id,
is_error,
..
} => {
format!("ToolResult(id={}, is_error={})", tool_use_id, is_error)
}
crate::ai::bedrock::convert::MessageContent::MultiPart(parts) => {
let part_descs: Vec<String> = parts
.iter()
.map(|p| match p {
crate::ai::bedrock::convert::ContentPart::Text(t) => {
format!("Text({})", t.len())
}
crate::ai::bedrock::convert::ContentPart::ToolUse {
name,
tool_use_id,
..
} => format!("ToolUse({},{})", name, tool_use_id),
crate::ai::bedrock::convert::ContentPart::ToolResult {
tool_use_id,
..
} => format!("ToolResult({})", tool_use_id),
})
.collect();
format!("MultiPart[{}]", part_descs.join(", "))
}
};
log::info!(
"[bedrock] msg[{}]: role={:?}, content={}",
i,
msg.role,
content_desc
);
}
match bedrock
.converse_stream(
&model_id,
&task_id,
needs_create_task,
messages.clone(),
system_prompt,
tools,
64000,
None,
true,
diagnostic_logger,
params.bedrock_messages_sent.clone(),
)
.await
{
Ok(stream) => {
// Store the input messages we sent so the controller can
// persist them. The stream will append the assistant response.
if let Ok(mut sent) = params.bedrock_messages_sent.lock() {
*sent = messages;
}
let output_stream = stream.take_until(cancellation_rx);
return Ok(Box::pin(output_stream));
}
Err(e) => {
if fallback_to_warp {
log::warn!("Bedrock stream failed, falling back to server: {e}");
} else {
let err = Arc::new(crate::server::server_api::AIApiError::Stream {
stream_type: "bedrock_converse",
source: anyhow::anyhow!("{e}"),
});
let (tx, rx) = async_channel::unbounded();
let _ = tx.send(Err(err)).await;
return Ok(Box::pin(rx));
}
}
}
}
Err(e) => {
if fallback_to_warp {
log::warn!("Bedrock client creation failed, falling back to server: {e}");
} else {
let err = Arc::new(crate::server::server_api::AIApiError::Stream {
stream_type: "bedrock_converse",
source: anyhow::anyhow!("{e}"),
});
let (tx, rx) = async_channel::unbounded();
let _ = tx.send(Err(err)).await;
return Ok(Box::pin(rx));
}
}
Err(e) => {
log::error!("[bedrock] Translator error: {e}");
let err = Arc::new(crate::server::server_api::AIApiError::Stream {
stream_type: "bedrock_converse",
source: anyhow::anyhow!("{e}"),
});
let (tx, rx) = async_channel::unbounded();
let _ = tx.send(Err(err)).await;
Ok(Box::pin(rx))
}
}
log::error!("[bedrock] No Bedrock config available and server fallback is disabled. Cannot process request.");
let err = Arc::new(crate::server::server_api::AIApiError::Stream {
stream_type: "bedrock_converse",
source: anyhow::anyhow!(
"No AI backend available. Please configure Bedrock credentials in Settings > AI."
),
});
let (tx, rx) = async_channel::unbounded();
let _ = tx.send(Err(err)).await;
Ok(Box::pin(rx))
}
fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
@@ -373,16 +211,9 @@ fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
}
}
Some(SessionType::WarpifiedRemote { host_id: Some(_) }) => {
// Remote session with a known host — enable tools that route
// through RemoteServerClient. The host_id is only populated
// after a successful connection handshake, so its presence is a
// sufficient proxy for client availability.
// SearchCodebase remains disabled (follow-up work).
supported_tools.extend(&[api::ToolType::ReadFiles, api::ToolType::ApplyFileDiffs]);
}
Some(SessionType::WarpifiedRemote { host_id: None }) => {
// Feature flag off or not yet connected — no remote tools.
}
Some(SessionType::WarpifiedRemote { host_id: None }) => {}
}
if FeatureFlag::AgentModeComputerUse.is_enabled() && params.computer_use_enabled {
+28
View File
@@ -3076,6 +3076,34 @@ impl AIConversation {
.sum()
}
pub fn total_input_tokens(&self) -> u32 {
self.total_token_usage_by_model
.values()
.map(|u| u.total_input + u.input_cache_read + u.input_cache_write)
.sum()
}
pub fn total_cache_read_tokens(&self) -> u32 {
self.total_token_usage_by_model
.values()
.map(|u| u.input_cache_read)
.sum()
}
pub fn total_cache_write_tokens(&self) -> u32 {
self.total_token_usage_by_model
.values()
.map(|u| u.input_cache_write)
.sum()
}
pub fn cache_miss_tokens(&self) -> u32 {
self.total_token_usage_by_model
.values()
.map(|u| u.total_input)
.sum()
}
fn total_tokens_for_usage(usage: &TokenUsage) -> u32 {
usage.total_input + usage.output + usage.input_cache_read + usage.input_cache_write
}
+4 -3
View File
@@ -5,7 +5,7 @@ use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::ambient_agents::{AgentSource, AmbientAgentTask, AmbientAgentTaskState};
use crate::ai::artifacts::Artifact;
use crate::ai::blocklist::{
format_credits, format_token_count, BlocklistAIHistoryEvent, BlocklistAIHistoryModel,
format_cost_cents, format_token_count, BlocklistAIHistoryEvent, BlocklistAIHistoryModel,
};
use crate::ai::cloud_environments::CloudAmbientAgentEnvironment;
use crate::ai::conversation_navigation::ConversationNavigationData;
@@ -498,7 +498,8 @@ impl ConversationOrTask<'_> {
let history_model = BlocklistAIHistoryModel::as_ref(app);
history_model
.conversation(&metadata.nav_data.id)
.map(|conv| conv.credits_spent())
.map(|conv| conv.total_cost_cents())
.filter(|&c| c > 0.0)
.or_else(|| {
history_model
.get_conversation_metadata(&metadata.nav_data.id)
@@ -510,7 +511,7 @@ impl ConversationOrTask<'_> {
/// Formats the request usage for display.
pub fn display_request_usage(&self, app: &AppContext) -> Option<String> {
self.request_usage(app).map(format_credits)
self.request_usage(app).map(format_cost_cents)
}
pub fn display_total_tokens(&self, app: &AppContext) -> Option<String> {
+4 -11
View File
@@ -11,7 +11,7 @@ use super::external_config::ExternalBedrockConfig;
use super::convert::{build_converse_request, ConversationMessage, ToolDefinition};
use super::diagnostic::BedrockDiagnosticLogger;
use super::models::apply_cross_region_prefix;
use super::stream::bedrock_stream_to_response_events;
use super::response_translator::bedrock_stream_to_response_events;
use crate::ai::agent::api::ResponseStream;
pub struct BedrockClient {
@@ -27,7 +27,6 @@ pub struct BedrockClientConfig {
pub access_key_id: String,
pub secret_access_key: String,
pub cross_region_inference: bool,
pub fallback_to_warp: bool,
}
impl BedrockClientConfig {
@@ -63,8 +62,6 @@ pub enum BedrockError {
CredentialsNotConfigured,
#[error("Bedrock region not configured and could not be auto-detected")]
RegionNotConfigured,
#[error("AWS credential error: {0}")]
CredentialError(String),
#[error("Bedrock API error: {0}")]
ApiError(String),
#[error("Model not found: {0}")]
@@ -140,6 +137,7 @@ impl BedrockClient {
max_tokens: i32,
temperature: Option<f32>,
cross_region_inference: bool,
user_query: Option<String>,
diagnostic_logger: Option<Arc<BedrockDiagnosticLogger>>,
messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
) -> Result<ResponseStream, BedrockError> {
@@ -226,16 +224,11 @@ impl BedrockClient {
output,
task_id.to_string(),
needs_create_task,
user_query,
diagnostic_logger,
messages_sent,
effective_model_id,
)))
}
pub fn runtime_client(&self) -> &BedrockRuntimeClient {
&self.runtime_client
}
pub fn region(&self) -> &str {
&self.region
}
}
+13 -6
View File
@@ -261,7 +261,6 @@ fn get_test_config() -> Option<BedrockClientConfig> {
access_key_id: String::new(),
secret_access_key: String::new(),
cross_region_inference: false,
fallback_to_warp: false,
})
}
@@ -556,6 +555,7 @@ impl AgentSimulation {
None,
false,
None,
None,
Arc::new(Mutex::new(Vec::new())),
)
.await
@@ -1142,6 +1142,7 @@ async fn test_reasoning_model_produces_substantial_output() {
None,
false,
None,
None,
Arc::new(Mutex::new(Vec::new())),
)
.await
@@ -1264,6 +1265,7 @@ async fn test_event_sequence_matches_controller_expectations() {
None,
false,
None,
None,
Arc::new(Mutex::new(Vec::new())),
)
.await
@@ -1378,6 +1380,7 @@ async fn test_followup_turn_does_not_send_create_task() {
None,
false,
None,
None,
Arc::new(Mutex::new(Vec::new())),
)
.await
@@ -1461,6 +1464,7 @@ async fn run_slash_command_test(
None,
true,
None,
None,
Arc::new(Mutex::new(Vec::new())),
)
.await
@@ -1688,6 +1692,7 @@ async fn test_slash_resume_conversation() {
None,
true,
None,
None,
Arc::new(Mutex::new(Vec::new())),
)
.await
@@ -1799,6 +1804,7 @@ async fn test_empty_messages_safety_check() {
None,
true,
None,
None,
Arc::new(Mutex::new(Vec::new())),
)
.await
@@ -1866,9 +1872,9 @@ async fn test_full_proto_round_trip_with_tool_history() {
&model,
);
let messages = super::convert_request::extract_messages_from_request(&request);
let system_prompt = super::convert_request::extract_system_prompt(&request);
let tools = super::convert_request::extract_tools(&request);
let messages = super::request_translator::extract_messages_from_request(&request);
let system_prompt = super::request_translator::extract_system_prompt(&request);
let tools = super::request_translator::extract_tools(&request);
println!("\n=== FULL PROTO ROUND-TRIP TEST ===");
println!("Extracted {} messages:", messages.len());
@@ -1959,6 +1965,7 @@ async fn test_full_proto_round_trip_with_tool_history() {
None,
true,
None,
None,
Arc::new(Mutex::new(Vec::new())),
)
.await;
@@ -2043,7 +2050,7 @@ async fn test_proto_tool_call_id_mapping() {
"",
);
let messages = super::convert_request::extract_messages_from_request(&request);
let messages = super::request_translator::extract_messages_from_request(&request);
println!("\n=== PROTO TOOL_CALL_ID MAPPING TEST ===");
for (i, msg) in messages.iter().enumerate() {
@@ -2133,7 +2140,7 @@ async fn test_tool_call_id_uses_proto_field_not_message_id() {
mcp_context: None,
};
let messages = super::convert_request::extract_messages_from_request(&request);
let messages = super::request_translator::extract_messages_from_request(&request);
let tool_use_msg = messages
.iter()
+83 -1
View File
@@ -23,7 +23,6 @@ fn get_test_config() -> Option<BedrockClientConfig> {
access_key_id: String::new(),
secret_access_key: String::new(),
cross_region_inference: false,
fallback_to_warp: false,
})
}
@@ -65,6 +64,7 @@ async fn collect_stream_output(
None,
false,
None,
None,
Arc::new(Mutex::new(Vec::new())),
)
.await
@@ -606,3 +606,85 @@ async fn test_reasoning_model_output() {
&output.text[..output.text.len().min(300)]
);
}
#[tokio::test]
async fn test_all_tools_visible_to_model() {
let Some(config) = get_test_config() else {
eprintln!("Skipping: BEDROCK_INTEGRATION_TEST not set");
return;
};
let client = BedrockClient::from_config(config)
.await
.expect("client creation");
let model = get_test_model();
let tools = super::request_translator::default_tool_definitions();
let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
println!("[test] Sending {} tools to Bedrock: {:?}", tools.len(), tool_names);
let messages = vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text(
"List every tool you have access to. Output ONLY the tool names, one per line, no descriptions, no formatting, no markdown."
.into(),
),
}];
let output = collect_stream_output(
&client,
&model,
messages,
Some("You are a helpful assistant. When asked about your tools, list them exactly as they appear in your tool configuration.".into()),
tools.clone(),
)
.await;
println!("[test] Model's tool list response:\n{}", output.text);
println!("[test] Total tokens: {}", output.total_tokens);
let response_lower = output.text.to_lowercase();
let mut missing_tools = Vec::new();
for tool in &tools {
if !response_lower.contains(&tool.name.to_lowercase()) {
missing_tools.push(&tool.name);
}
}
if !missing_tools.is_empty() {
println!("[test] WARNING: Model did not mention these tools: {:?}", missing_tools);
}
let expected_core_tools = [
"run_shell_command",
"read_files",
"apply_file_diffs",
"grep",
"file_glob",
"search_codebase",
"start_agent",
"ask_user_question",
];
let mut missing_core = Vec::new();
for name in &expected_core_tools {
if !response_lower.contains(name) {
missing_core.push(*name);
}
}
assert!(
missing_core.is_empty(),
"Model failed to list these core tools: {:?}\n\nFull response:\n{}",
missing_core,
output.text
);
assert!(
tools.len() >= 17,
"Expected at least 17 tool definitions, got {}",
tools.len()
);
}
+6 -3
View File
@@ -1,19 +1,22 @@
pub mod client;
pub mod convert;
pub mod convert_request;
pub mod diagnostic;
pub mod discovery;
pub mod external_config;
pub mod models;
pub mod stream;
pub mod request_translator;
pub mod response_translator;
pub mod translator;
#[cfg(test)]
mod convert_tests;
#[cfg(test)]
#[allow(dead_code)]
mod e2e_tests;
#[cfg(test)]
#[allow(dead_code)]
mod integration_tests;
#[cfg(test)]
mod models_tests;
#[cfg(test)]
mod stream_tests;
mod response_translator_tests;
-22
View File
@@ -115,25 +115,3 @@ pub fn apply_cross_region_prefix(model_id: &str, region: &str) -> String {
};
format!("{}.{}", prefix, model_id)
}
pub fn is_bedrock_model(model_id: &str, configured_models: &[BedrockModelConfig]) -> bool {
if model_id.starts_with("arn:aws:bedrock:") {
return true;
}
let effective = get_effective_models(configured_models);
effective.iter().any(|m| m.model_id == model_id)
|| model_id.starts_with("anthropic.")
|| model_id.starts_with("amazon.")
|| model_id.starts_with("meta.")
|| model_id.starts_with("mistral.")
|| model_id.starts_with("cohere.")
|| model_id.starts_with("ai21.")
|| model_id.starts_with("deepseek.")
|| has_cross_region_prefix(model_id)
}
fn has_cross_region_prefix(model_id: &str) -> bool {
let prefixes = ["us.", "eu.", "jp.", "apac.", "au.", "global."];
prefixes.iter().any(|p| model_id.starts_with(p))
}
+2 -63
View File
@@ -82,8 +82,8 @@ fn test_cross_region_prefix_unknown_region() {
fn test_get_effective_models_empty_returns_defaults() {
let models = get_effective_models(&[]);
assert_eq!(models.len(), DEFAULT_BEDROCK_MODELS.len());
assert_eq!(models[0].model_id, "anthropic.claude-opus-4-7");
assert_eq!(models[0].display_name, "Claude Opus 4.7");
assert_eq!(models[0].model_id, "anthropic.claude-opus-4-6");
assert_eq!(models[0].display_name, "Claude Opus 4.6");
}
#[test]
@@ -98,69 +98,8 @@ fn test_get_effective_models_custom_overrides() {
assert_eq!(models[0].model_id, "custom.model-v1:0");
}
#[test]
fn test_is_bedrock_model_known_prefix() {
assert!(is_bedrock_model("anthropic.claude-sonnet-4-6", &[]));
assert!(is_bedrock_model("amazon.nova-pro-v1:0", &[]));
assert!(is_bedrock_model("meta.llama3-70b-instruct-v1:0", &[]));
assert!(is_bedrock_model("mistral.mistral-large-v1:0", &[]));
assert!(is_bedrock_model("deepseek.r1-v1:0", &[]));
}
#[test]
fn test_is_bedrock_model_cross_region_prefix() {
assert!(is_bedrock_model("us.anthropic.claude-sonnet-4-6", &[]));
assert!(is_bedrock_model("eu.anthropic.claude-sonnet-4-6", &[]));
assert!(is_bedrock_model("global.anthropic.claude-opus-4-7", &[]));
}
#[test]
fn test_is_bedrock_model_unknown() {
assert!(!is_bedrock_model("gpt-4o", &[]));
assert!(!is_bedrock_model("gemini-pro", &[]));
}
#[test]
fn test_is_bedrock_model_custom_config() {
let custom = vec![BedrockModelConfig {
model_id: "custom.my-model-v1:0".to_string(),
display_name: "Custom".to_string(),
vision_supported: false,
}];
assert!(is_bedrock_model("custom.my-model-v1:0", &custom));
}
#[test]
fn test_is_bedrock_model_arn() {
assert!(is_bedrock_model(
"arn:aws:bedrock:us-east-1:156729649053:application-inference-profile/fma5bsw4oxhy",
&[],
));
}
#[test]
fn test_cross_region_prefix_skips_arn() {
let arn = "arn:aws:bedrock:us-east-1:156729649053:application-inference-profile/fma5bsw4oxhy";
assert_eq!(apply_cross_region_prefix(arn, "us-east-1"), arn);
}
#[test]
fn test_is_bedrock_model_coding_agent_arn() {
assert!(is_bedrock_model(
"arn:aws:bedrock:us-east-1:156729649053:application-inference-profile/coding-agent-anthropic-claude-opus-4-6-lt2v72",
&[],
));
}
#[test]
fn test_is_bedrock_model_custom_config_with_arn() {
let custom = vec![BedrockModelConfig {
model_id: "arn:aws:bedrock:us-east-1:156729649053:application-inference-profile/coding-assistant-inference-profile".to_string(),
display_name: "Coding Assistant".to_string(),
vision_supported: true,
}];
assert!(is_bedrock_model(
"arn:aws:bedrock:us-east-1:156729649053:application-inference-profile/coding-assistant-inference-profile",
&custom,
));
}
@@ -1,10 +1,38 @@
#![allow(dead_code, unused_imports, unused_variables, deprecated)]
use warp_multi_agent_api as api;
use super::convert::{
ContentPart, ConversationMessage, MessageContent, MessageRole, ToolDefinition,
};
/// Convert a prost_types::Struct to a serde_json::Value for tool input schemas.
fn prost_struct_to_json(s: &prost_types::Struct) -> serde_json::Value {
struct_to_value(s)
}
fn struct_to_value(s: &prost_types::Struct) -> serde_json::Value {
let map: serde_json::Map<String, serde_json::Value> = s
.fields
.iter()
.map(|(k, v)| (k.clone(), prost_value_to_json(v)))
.collect();
serde_json::Value::Object(map)
}
fn prost_value_to_json(v: &prost_types::Value) -> serde_json::Value {
use prost_types::value::Kind;
match &v.kind {
Some(Kind::NullValue(_)) => serde_json::Value::Null,
Some(Kind::NumberValue(n)) => serde_json::json!(n),
Some(Kind::StringValue(s)) => serde_json::Value::String(s.clone()),
Some(Kind::BoolValue(b)) => serde_json::Value::Bool(*b),
Some(Kind::StructValue(s)) => struct_to_value(s),
Some(Kind::ListValue(l)) => {
serde_json::Value::Array(l.values.iter().map(prost_value_to_json).collect())
}
None => serde_json::Value::Null,
}
}
/// Extract new input messages from the current request and convert them directly
/// to ConversationMessage format for the Bedrock message history.
/// This extracts UserQuery and ToolCallResult from request.input only.
@@ -169,6 +197,37 @@ pub fn extract_new_input_messages(request: &api::Request) -> Vec<ConversationMes
results
}
/// Extract the user's query text from the request input (if present).
/// Used to emit a UserQuery proto message in the stream for persistence.
pub fn extract_user_query_text(request: &api::Request) -> Option<String> {
let input = request.input.as_ref()?;
let input_type = input.r#type.as_ref()?;
match input_type {
api::request::input::Type::UserInputs(user_inputs) => {
for user_input in &user_inputs.inputs {
if let Some(api::request::input::user_inputs::user_input::Input::UserQuery(query)) =
&user_input.input
{
if !query.query.is_empty() {
return Some(query.query.clone());
}
}
}
None
}
#[allow(deprecated)]
api::request::input::Type::UserQuery(query) => {
if !query.query.is_empty() {
Some(query.query.clone())
} else {
None
}
}
_ => None,
}
}
/// For the Bedrock direct path: inject all input messages (user queries and tool call
/// results) into the task's messages so they persist in conversation history for future
/// requests. Without this, inputs are lost after the current request cycle because they
@@ -224,6 +283,7 @@ fn extract_input_messages(request: &api::Request) -> Vec<api::Message> {
.map(|t| t.id.clone())
.unwrap_or_default();
#[allow(deprecated)]
match input_type {
api::request::input::Type::UserInputs(user_inputs) => {
for user_input in &user_inputs.inputs {
@@ -324,152 +384,6 @@ fn extract_input_messages(request: &api::Request) -> Vec<api::Message> {
results
}
pub fn extract_messages_from_request(request: &api::Request) -> Vec<ConversationMessage> {
let mut messages = Vec::new();
if let Some(task_context) = &request.task_context {
log::info!(
"[bedrock-debug] extract_messages: {} tasks in task_context",
task_context.tasks.len()
);
for task in &task_context.tasks {
log::info!(
"[bedrock-debug] extract_messages: task '{}' has {} messages",
task.id,
task.messages.len()
);
for msg in &task.messages {
let msg_type = msg
.message
.as_ref()
.map(|m| match m {
api::message::Message::UserQuery(_) => "UserQuery",
api::message::Message::AgentOutput(_) => "AgentOutput",
api::message::Message::ToolCall(_) => "ToolCall",
api::message::Message::ToolCallResult(_) => "ToolCallResult",
api::message::Message::AgentReasoning(_) => "AgentReasoning",
_ => "Other",
})
.unwrap_or("None");
log::info!(
"[bedrock-debug] extract_messages: msg id='{}' type={}",
msg.id,
msg_type
);
if let Some(converted) = convert_proto_message(msg) {
messages.push(converted);
}
}
}
} else {
log::warn!("[bedrock-debug] extract_messages: NO task_context in request!");
}
if let Some(input) = &request.input {
if let Some(input_type) = &input.r#type {
#[allow(deprecated)]
match input_type {
// UserInputs (UserQuery + ToolCallResult) are already injected
// into task messages by inject_input_messages_into_task().
api::request::input::Type::UserInputs(_) => {}
api::request::input::Type::UserQuery(_) => {}
api::request::input::Type::ToolCallResult(_) => {}
api::request::input::Type::InitProjectRules(_) => {
messages.push(ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text(
"Initialize this project. Analyze the codebase structure and files, \
generate an AGENTS.md file documenting project conventions and setup \
instructions, and offer to create a development environment configuration. \
Use the available tools to inspect the project before responding."
.to_string(),
),
});
}
api::request::input::Type::CreateEnvironment(env) => {
let repo_info = if env.repo_paths.is_empty() {
String::new()
} else {
format!(" Repositories: {}", env.repo_paths.join(", "))
};
messages.push(ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text(format!(
"Create a development environment for this project. \
Set up necessary dependencies, configuration files, and tooling.{}",
repo_info
)),
});
}
api::request::input::Type::CreateNewProject(project) => {
messages.push(ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text(format!(
"Create a new project: {}",
project.query
)),
});
}
api::request::input::Type::CloneRepository(repo) => {
messages.push(ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text(format!(
"Clone the repository at {} and set it up for development.",
repo.url
)),
});
}
api::request::input::Type::AutoCodeDiffQuery(diff) => {
messages.push(ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text(format!(
"Apply code changes: {}",
diff.query
)),
});
}
api::request::input::Type::ResumeConversation(_) => {
messages.push(ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text(
"Continue where we left off. Review the conversation history and proceed with the next steps."
.to_string(),
),
});
}
api::request::input::Type::QueryWithCannedResponse(canned) => {
messages.push(ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text(canned.query.clone()),
});
}
api::request::input::Type::CodeReview(_) => {
messages.push(ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text(
"Review the following code changes and provide detailed feedback on correctness, style, and potential issues."
.to_string(),
),
});
}
_ => {}
}
}
}
ensure_starts_with_user_message(&mut messages);
ensure_tool_results_paired(&mut messages);
log::info!(
"[bedrock-debug] extract_messages: TOTAL {} messages to send to Bedrock (User={}, Assistant={}, ToolResult={}, ToolUse={})",
messages.len(),
messages.iter().filter(|m| m.role == MessageRole::User && matches!(&m.content, MessageContent::Text(_))).count(),
messages.iter().filter(|m| m.role == MessageRole::Assistant && matches!(&m.content, MessageContent::Text(_))).count(),
messages.iter().filter(|m| matches!(&m.content, MessageContent::ToolResult { .. })).count(),
messages.iter().filter(|m| matches!(&m.content, MessageContent::ToolUse { .. })).count(),
);
messages
}
/// Sanitizes a message list to satisfy Bedrock Converse API invariants:
/// 1. Messages must start with a user message.
/// 2. Every assistant tool_use must be immediately followed by a user
@@ -791,88 +705,76 @@ pub fn extract_system_prompt(request: &api::Request) -> Option<String> {
}
}
prompt.push_str("## Tools\nYou have access to the following tools. Use them proactively to explore codebases and complete tasks:\n");
prompt.push_str("- `run_shell_command`: Execute shell commands. Use absolute paths based on the working directory.\n");
prompt.push_str(
"- `read_files`: Read file contents. Pass all files you need in a single call.\n",
);
prompt.push_str("- `apply_file_diffs`: Apply search/replace edits to files.\n");
prompt.push_str("- `grep`: Search for patterns in files. Pass all patterns in one call.\n");
prompt.push_str(
"- `file_glob`: Find files matching glob patterns. Pass all patterns in one call.\n",
);
prompt.push_str("- `get_tool_documentation`: Get detailed documentation for any tool or system capabilities.\n");
prompt.push_str("- `suggest_next_prompt`: After completing a task, suggest a follow-up action the user might want.\n\n");
prompt.push_str("## Guidelines\n");
prompt.push_str("- You ALWAYS have access to run shell commands via `run_shell_command`. Never tell the user you cannot execute commands — use the tool directly.\n");
prompt.push_str(
"- ALWAYS use tools to explore the codebase before answering questions about code.\n",
);
prompt.push_str("## Tool Usage\n");
prompt.push_str("You have been given every tool you need to complete your tasks. Use them to achieve results with as few calls and as little back-and-forth as possible.\n\n");
prompt.push_str("**How to choose tools:**\n");
prompt.push_str("- For reading, writing, searching, and navigating files on the local filesystem, use your filesystem tools (`read_files`, `file_glob`, `grep`, `apply_file_diffs`).\n");
prompt.push_str("- For running commands, installing packages, building, testing, and any shell operation, use `run_shell_command`.\n");
prompt.push_str("- For tasks that require interacting with external services, web UIs, or capabilities not covered by your filesystem and shell tools, use your MCP tools.\n");
prompt.push_str("- For complex multi-step tasks where a single script would replace many tool calls, write code (Python, Node, bash) via `run_shell_command` to reduce round-trips. But never use scripts for simple operations that a single command handles.\n\n");
prompt.push_str("**Critical rules:**\n");
prompt.push_str("- Use ONLY the tools in your tool configuration. Never invent or guess tool names.\n");
prompt.push_str("- ALWAYS pass `--no-pager` (or equivalent) flags to CLI tools like git, less, man, etc. Tools that lock stdin will freeze the session.\n");
prompt.push_str("- Output text directly in your response instead of using `echo` — echo requires user approval and adds unnecessary friction.\n");
prompt.push_str("- Use absolute paths based on the working directory shown above.\n");
prompt.push_str("- When asked about a project, start by listing files with `file_glob` or `run_shell_command`.\n");
prompt
.push_str("- Read relevant files before making claims about code structure or behavior.\n");
prompt.push_str("- Be concise and direct in responses.\n");
prompt.push_str("- IMPORTANT: After EVERY response, you MUST call `suggest_next_prompt` to suggest a relevant follow-up action or question the user might want to take next.\n");
prompt.push_str("- Be logical in your tool choices. Read files before making claims about code. List files before assuming project structure.\n");
prompt.push_str("- Be concise and direct.\n");
Some(prompt)
}
pub fn extract_tools(request: &api::Request) -> Vec<ToolDefinition> {
let mut tools = Vec::new();
let mut seen_names = std::collections::HashSet::new();
// Always start with the full set of default tools.
let mut tools = default_tool_definitions();
let mut seen_names: std::collections::HashSet<String> =
tools.iter().map(|t| t.name.clone()).collect();
if let Some(task_context) = &request.task_context {
for task in &task_context.tasks {
for msg in &task.messages {
if let Some(api::message::Message::ToolCall(tool_call)) = &msg.message {
let (name, _) = extract_tool_call_info(tool_call);
if name != "unknown_tool" && seen_names.insert(name.clone()) {
tools.push(tool_definition_for_name(&name));
}
// Include MCP tools from connected servers so the model can invoke them.
if let Some(mcp_context) = &request.mcp_context {
for server in &mcp_context.servers {
for tool in &server.tools {
let name = format!("mcp__{}__{}", server.name, tool.name);
if seen_names.insert(name.clone()) {
let input_schema = tool
.input_schema
.as_ref()
.map(|s| prost_struct_to_json(s))
.unwrap_or_else(|| serde_json::json!({"type": "object", "properties": {}}));
tools.push(ToolDefinition {
name,
description: if tool.description.is_empty() {
format!("MCP tool from {} server", server.name)
} else {
tool.description.clone()
},
input_schema,
});
}
}
}
}
if let Some(input) = &request.input {
if let Some(input_type) = &input.r#type {
#[allow(deprecated)]
match input_type {
api::request::input::Type::UserInputs(user_inputs) => {
for user_input in &user_inputs.inputs {
if let Some(
api::request::input::user_inputs::user_input::Input::ToolCallResult(_),
) = &user_input.input
{
break;
}
}
}
api::request::input::Type::ToolCallResult(_) => {}
_ => {}
// Also handle flat (deprecated) tool list
#[allow(deprecated)]
for tool in &mcp_context.tools {
let name = format!("mcp__{}", tool.name);
if seen_names.insert(name.clone()) {
let input_schema = tool
.input_schema
.as_ref()
.map(|s| prost_struct_to_json(s))
.unwrap_or_else(|| serde_json::json!({"type": "object", "properties": {}}));
tools.push(ToolDefinition {
name,
description: if tool.description.is_empty() {
"MCP tool".to_string()
} else {
tool.description.clone()
},
input_schema,
});
}
}
}
if tools.is_empty() {
let messages = extract_messages_from_request(request);
let has_tool_content = messages.iter().any(|m| {
matches!(
m.content,
MessageContent::ToolUse { .. } | MessageContent::ToolResult { .. }
)
});
if has_tool_content {
tools = default_tool_definitions();
}
}
if tools.is_empty() {
tools = default_tool_definitions();
}
// Filter out suggest_next_prompt — its action executor waits on a oneshot
// channel for UI interaction that never fires in the Bedrock path, causing
// the conversation to stay InProgress forever.
@@ -881,185 +783,217 @@ pub fn extract_tools(request: &api::Request) -> Vec<ToolDefinition> {
tools
}
fn tool_definition_for_name(name: &str) -> ToolDefinition {
match name {
"run_shell_command" => ToolDefinition {
pub fn default_tool_definitions() -> Vec<ToolDefinition> {
vec![
ToolDefinition {
name: "run_shell_command".to_string(),
description: "Execute a shell command and return its output.".to_string(),
description: "Execute a shell command in the user's terminal and return its output. Use for running builds, tests, git operations, installing packages, or any shell operation. Commands run in the user's actual shell with their environment. Set is_read_only=true for read-only commands (ls, cat, git status) to enable auto-execution. Always use --no-pager for git commands.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"command": { "type": "string", "description": "The shell command to execute" }
"command": { "type": "string", "description": "The shell command to execute" },
"is_read_only": { "type": "boolean", "description": "True if command only reads data and makes no changes" },
"is_risky": { "type": "boolean", "description": "True if command is destructive or irreversible (rm -rf, git push --force)" }
},
"required": ["command"]
}),
},
"read_files" => ToolDefinition {
ToolDefinition {
name: "read_files".to_string(),
description: "Read the contents of one or more files. ALWAYS pass all files you need in a single call rather than making multiple separate calls.".to_string(),
description: "Read the contents of one or more files. Pass ALL file paths you need in a single call for efficiency. Returns file contents with path headers. Binary files are detected and skipped. Use absolute paths.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"files": { "type": "array", "items": { "type": "string" }, "description": "File paths to read. Include ALL files you need in one call for efficiency." }
"files": { "type": "array", "items": { "type": "string" }, "description": "Absolute file paths to read" }
},
"required": ["files"]
}),
},
"apply_file_diffs" => ToolDefinition {
ToolDefinition {
name: "apply_file_diffs".to_string(),
description: "Apply search/replace diffs to files.".to_string(),
description: "Apply search/replace edits to files. Creates files if they don't exist (use empty search string). The search string must uniquely match one location in the file. Include enough surrounding context for uniqueness. For new files, use search=\"\" and put full content in replace.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"diffs": { "type": "array", "items": { "type": "object", "properties": { "file_path": { "type": "string" }, "search": { "type": "string" }, "replace": { "type": "string" } }, "required": ["file_path", "search", "replace"] } }
"diffs": { "type": "array", "items": { "type": "object", "properties": { "file_path": { "type": "string", "description": "Absolute path to the file" }, "search": { "type": "string", "description": "Exact text to find (must match uniquely). Empty string to create a new file." }, "replace": { "type": "string", "description": "Text to replace with" } }, "required": ["file_path", "search", "replace"] }, "description": "Array of file edits to apply" }
},
"required": ["diffs"]
}),
},
"grep" => ToolDefinition {
ToolDefinition {
name: "grep".to_string(),
description: "Search for patterns in files. Pass all search patterns in one call.".to_string(),
description: "Search for regex patterns in files. Uses git grep in git repos (respects .gitignore) or ripgrep otherwise. Returns file paths and matching line numbers. Use read_files afterward to see context around matches. Pass ALL patterns you need in one call.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"queries": { "type": "array", "items": { "type": "string" }, "description": "Search patterns. Include ALL patterns you need in one call." },
"path": { "type": "string", "description": "Directory to search in" }
"queries": { "type": "array", "items": { "type": "string" }, "description": "Regex patterns to search for" },
"path": { "type": "string", "description": "Directory to scope the search to" }
},
"required": ["queries"]
}),
},
"file_glob" => ToolDefinition {
ToolDefinition {
name: "file_glob".to_string(),
description: "Find files matching glob patterns. Pass all patterns in one call.".to_string(),
description: "Find files matching glob patterns. Uses git ls-files in git repos. Returns absolute file paths of matches. Common patterns: '**/*.rs', 'src/**/*.ts', '**/Cargo.toml'. Pass ALL patterns in one call.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"patterns": { "type": "array", "items": { "type": "string" }, "description": "Glob patterns to match" }
"patterns": { "type": "array", "items": { "type": "string" }, "description": "Glob patterns to match files" },
"path": { "type": "string", "description": "Directory to search from" }
},
"required": ["patterns"]
}),
},
"suggest_next_prompt" => ToolDefinition {
name: "suggest_next_prompt".to_string(),
description: "After completing a task, suggest a relevant follow-up prompt the user might want to try next. Use this to suggest a natural next step based on what was just accomplished.".to_string(),
ToolDefinition {
name: "search_codebase".to_string(),
description: "Semantic code search across the indexed codebase. Use for finding relevant code by meaning rather than exact text match. Better than grep for conceptual queries like 'authentication logic' or 'error handling for database connections'.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"prompt": { "type": "string", "description": "The suggested prompt text that will be sent to the agent if the user accepts" },
"label": { "type": "string", "description": "Short display label for the suggestion chip (keep under 40 chars)" }
"query": { "type": "string", "description": "Natural language search query describing what you're looking for" },
"path": { "type": "string", "description": "Optional directory path to narrow search scope" }
},
"required": ["query"]
}),
},
ToolDefinition {
name: "write_to_long_running_shell_command".to_string(),
description: "Send input (stdin) to a currently running shell command. Use this to interact with commands that are waiting for input, like interactive prompts, REPLs, or commands that accept piped input.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"input": { "type": "string", "description": "Text to send as stdin to the running command" }
},
"required": ["input"]
}),
},
ToolDefinition {
name: "read_shell_command_output".to_string(),
description: "Read the latest output from a previously started long-running shell command. Use to check progress or get results from commands that are still running.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
ToolDefinition {
name: "read_mcp_resource".to_string(),
description: "Read a resource from a connected MCP (Model Context Protocol) server. Resources provide context like database schemas, API docs, or live system state.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"server_id": { "type": "string", "description": "MCP server identifier" },
"uri": { "type": "string", "description": "Resource URI to read" }
},
"required": ["server_id", "uri"]
}),
},
ToolDefinition {
name: "read_documents".to_string(),
description: "Read the contents of one or more Galaxy notebook documents by their IDs.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"document_ids": { "type": "array", "items": { "type": "string" }, "description": "Document IDs to read" }
},
"required": ["document_ids"]
}),
},
ToolDefinition {
name: "create_documents".to_string(),
description: "Create new Galaxy notebook documents with the specified title and content.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"documents": { "type": "array", "items": { "type": "object", "properties": { "title": { "type": "string" }, "content": { "type": "string" } }, "required": ["title", "content"] }, "description": "Documents to create" }
},
"required": ["documents"]
}),
},
ToolDefinition {
name: "edit_documents".to_string(),
description: "Edit existing Galaxy notebook documents using search/replace diffs.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"diffs": { "type": "array", "items": { "type": "object", "properties": { "document_id": { "type": "string" }, "search": { "type": "string" }, "replace": { "type": "string" } }, "required": ["document_id", "search", "replace"] }, "description": "Edits to apply to documents" }
},
"required": ["diffs"]
}),
},
ToolDefinition {
name: "start_agent".to_string(),
description: "Start a sub-agent to handle a specific task autonomously. Use for delegating independent work that can run in parallel. The agent gets its own conversation context and tool access.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"name": { "type": "string", "description": "Name for the sub-agent (used for identification)" },
"prompt": { "type": "string", "description": "The task/instructions for the sub-agent to execute" }
},
"required": ["name", "prompt"]
}),
},
ToolDefinition {
name: "send_message_to_agent".to_string(),
description: "Send a message to a running sub-agent. Use to provide additional context, ask for updates, or redirect the agent's work.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"agent_id": { "type": "string", "description": "ID of the target sub-agent" },
"message": { "type": "string", "description": "Message to send to the agent" }
},
"required": ["agent_id", "message"]
}),
},
ToolDefinition {
name: "ask_user_question".to_string(),
description: "Ask the user a question when you need clarification or a decision. Present clear options when possible. Use sparingly — prefer making reasonable assumptions.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"question": { "type": "string", "description": "The question to ask the user" },
"options": { "type": "array", "items": { "type": "string" }, "description": "Optional multiple-choice options to present" }
},
"required": ["question"]
}),
},
ToolDefinition {
name: "suggest_next_prompt".to_string(),
description: "After completing a task, suggest a relevant follow-up action. Only call once at the end of your response. Keep labels concise and action-oriented.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"prompt": { "type": "string", "description": "The full prompt text sent to the agent if the user clicks" },
"label": { "type": "string", "description": "Short display label (under 40 chars)" }
},
"required": ["prompt", "label"]
}),
},
_ => ToolDefinition {
name: name.to_string(),
description: format!("Tool: {}", name),
ToolDefinition {
name: "read_skill".to_string(),
description: "Read a skill definition to understand available capabilities and how to use them.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {}
"properties": {
"skill": { "type": "string", "description": "Skill identifier to read" }
},
"required": ["skill"]
}),
},
ToolDefinition {
name: "fetch_conversation".to_string(),
description: "Fetch the contents of a previous conversation for context. Use when the user references prior work or you need history from another session.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"conversation_id": { "type": "string", "description": "ID of the conversation to fetch" }
},
"required": ["conversation_id"]
}),
},
}
}
fn default_tool_definitions() -> Vec<ToolDefinition> {
vec![
tool_definition_for_name("run_shell_command"),
tool_definition_for_name("read_files"),
tool_definition_for_name("apply_file_diffs"),
tool_definition_for_name("grep"),
tool_definition_for_name("file_glob"),
]
}
fn convert_proto_message(msg: &api::Message) -> Option<ConversationMessage> {
let message_content = msg.message.as_ref()?;
match message_content {
api::message::Message::UserQuery(query) => Some(ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text(query.query.clone()),
}),
api::message::Message::AgentOutput(output) => Some(ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::Text(output.text.clone()),
}),
api::message::Message::ToolCall(tool_call) => {
let (name, input) = extract_tool_call_info(tool_call);
Some(ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::ToolUse {
tool_use_id: tool_call.tool_call_id.clone(),
name,
input,
},
})
}
api::message::Message::ToolCallResult(result) => {
let content = format_tool_call_result(result);
Some(ConversationMessage {
role: MessageRole::User,
content: MessageContent::ToolResult {
tool_use_id: result.tool_call_id.clone(),
content,
is_error: false,
},
})
}
api::message::Message::AgentReasoning(_) => None,
_ => None,
}
}
fn extract_tool_call_info(tool_call: &api::message::ToolCall) -> (String, serde_json::Value) {
if let Some(tool) = &tool_call.tool {
match tool {
api::message::tool_call::Tool::RunShellCommand(cmd) => (
"run_shell_command".to_string(),
serde_json::json!({ "command": cmd.command }),
),
api::message::tool_call::Tool::ReadFiles(read) => (
"read_files".to_string(),
serde_json::json!({ "files": read.files.iter().map(|f| &f.name).collect::<Vec<_>>() }),
),
api::message::tool_call::Tool::ApplyFileDiffs(diffs) => (
"apply_file_diffs".to_string(),
serde_json::json!({ "diffs": diffs.diffs.iter().map(|d| {
serde_json::json!({
"file_path": d.file_path,
"search": d.search,
"replace": d.replace
})
}).collect::<Vec<_>>() }),
),
api::message::tool_call::Tool::Grep(grep) => (
"grep".to_string(),
serde_json::json!({ "queries": grep.queries, "path": grep.path }),
),
#[allow(deprecated)]
api::message::tool_call::Tool::FileGlob(glob) => (
"file_glob".to_string(),
serde_json::json!({ "patterns": glob.patterns }),
),
api::message::tool_call::Tool::SuggestPrompt(sp) => {
let (prompt, label) = match &sp.display_mode {
Some(api::message::tool_call::suggest_prompt::DisplayMode::PromptChip(
chip,
)) => (chip.prompt.clone(), chip.label.clone()),
_ => (String::new(), String::new()),
};
(
"suggest_next_prompt".to_string(),
serde_json::json!({ "prompt": prompt, "label": label }),
)
}
_ => ("unknown_tool".to_string(), serde_json::json!({})),
}
} else {
("unknown_tool".to_string(), serde_json::json!({}))
}
}
fn extract_tool_result_content(result: &api::request::input::ToolCallResult) -> String {
if let Some(result_type) = &result.result {
match result_type {
@@ -1172,119 +1106,42 @@ fn extract_tool_result_content(result: &api::request::input::ToolCallResult) ->
None => "Apply diffs completed.".to_string(),
}
}
_ => "Tool completed successfully.".to_string(),
}
} else {
"Tool completed.".to_string()
}
}
fn format_tool_call_result(result: &api::message::ToolCallResult) -> String {
if let Some(result_type) = &result.result {
match result_type {
api::message::tool_call_result::Result::RunShellCommand(cmd_result) => {
match &cmd_result.result {
Some(api::run_shell_command_result::Result::CommandFinished(finished)) => {
if finished.output.is_empty() {
format!("Exit code: {}\n(no output)", finished.exit_code)
} else {
format!("Exit code: {}\n{}", finished.exit_code, finished.output)
}
}
Some(api::run_shell_command_result::Result::LongRunningCommandSnapshot(
snapshot,
)) => {
format!("Output (running): {}", snapshot.output)
}
_ => "Command completed.".to_string(),
}
}
api::message::tool_call_result::Result::ReadFiles(read_result) => {
match &read_result.result {
Some(api::read_files_result::Result::TextFilesSuccess(success)) => success
.files
.iter()
.map(|f| format!("{}:\n{}", f.file_path, f.content))
.collect::<Vec<_>>()
.join("\n\n"),
_ => "Read files completed.".to_string(),
}
}
api::message::tool_call_result::Result::Grep(grep_result) => {
match &grep_result.result {
Some(api::grep_result::Result::Success(success)) => {
if success.matched_files.is_empty() {
"No matches found.".to_string()
} else {
success
.matched_files
.iter()
.map(|f| {
let lines: String = f
.matched_lines
.iter()
.map(|l| format!(" line {}", l.line_number))
.collect::<Vec<_>>()
.join(", ");
format!("{} (matches at: {})", f.file_path, lines)
})
.collect::<Vec<_>>()
.join("\n")
}
}
Some(api::grep_result::Result::Error(error)) => {
format!("Grep error: {}", error.message)
}
None => "Grep completed (no result).".to_string(),
}
}
api::message::tool_call_result::Result::FileGlobV2(glob_result) => {
api::request::input::tool_call_result::Result::FileGlob(glob_result) => {
match &glob_result.result {
Some(api::file_glob_v2_result::Result::Success(success)) => {
Some(api::file_glob_result::Result::Success(success)) => {
if success.matched_files.is_empty() {
"No files matched.".to_string()
} else {
success
.matched_files
.iter()
.map(|f| f.file_path.as_str())
.collect::<Vec<_>>()
.join("\n")
success.matched_files.clone()
}
}
Some(api::file_glob_v2_result::Result::Error(error)) => {
Some(api::file_glob_result::Result::Error(error)) => {
format!("File glob error: {}", error.message)
}
None => "File glob completed (no result).".to_string(),
}
}
api::message::tool_call_result::Result::ApplyFileDiffs(diff_result) => {
match &diff_result.result {
Some(api::apply_file_diffs_result::Result::Success(success)) => {
let mut parts = Vec::new();
for f in &success.updated_files_v2 {
if let Some(file) = &f.file {
parts.push(format!("Updated: {}", file.file_path));
}
}
for f in &success.deleted_files {
parts.push(format!("Deleted: {}", f.file_path));
}
if parts.is_empty() {
"Diffs applied successfully.".to_string()
} else {
parts.join("\n")
}
api::request::input::tool_call_result::Result::CallMcpTool(mcp_result) => {
match &mcp_result.result {
Some(api::call_mcp_tool_result::Result::Success(success)) => {
success
.results
.iter()
.filter_map(|item| match &item.result {
Some(api::call_mcp_tool_result::success::result::Result::Text(t)) => {
Some(t.text.clone())
}
_ => None,
})
.collect::<Vec<_>>()
.join("\n")
}
Some(api::apply_file_diffs_result::Result::Error(error)) => {
format!("Apply diffs error: {}", error.message)
Some(api::call_mcp_tool_result::Result::Error(error)) => {
format!("MCP tool error: {}", error.message)
}
None => "Apply diffs completed.".to_string(),
None => "MCP tool completed.".to_string(),
}
}
api::message::tool_call_result::Result::Server(server_result) => {
server_result.serialized_result.clone()
}
_ => "Tool completed successfully.".to_string(),
}
} else {
@@ -1293,5 +1150,110 @@ fn format_tool_call_result(result: &api::message::ToolCallResult) -> String {
}
#[cfg(test)]
#[path = "convert_request_tests.rs"]
pub fn extract_messages_from_request(request: &api::Request) -> Vec<ConversationMessage> {
let mut messages = Vec::new();
if let Some(task_context) = &request.task_context {
for task in &task_context.tasks {
for msg in &task.messages {
if let Some(converted) = convert_proto_message_for_test(msg) {
messages.push(converted);
}
}
}
}
let new_inputs = extract_new_input_messages(request);
messages.extend(new_inputs);
ensure_starts_with_user_message(&mut messages);
ensure_tool_results_paired(&mut messages);
messages
}
#[cfg(test)]
fn convert_proto_message_for_test(msg: &api::Message) -> Option<ConversationMessage> {
let message_content = msg.message.as_ref()?;
match message_content {
api::message::Message::UserQuery(query) => Some(ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text(query.query.clone()),
}),
api::message::Message::AgentOutput(output) => Some(ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::Text(output.text.clone()),
}),
api::message::Message::ToolCall(tool_call) => {
let (name, input) = extract_tool_call_info_for_test(tool_call);
Some(ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::ToolUse {
tool_use_id: tool_call.tool_call_id.clone(),
name,
input,
},
})
}
api::message::Message::ToolCallResult(result) => {
let content = match &result.result {
Some(api::message::tool_call_result::Result::Server(s)) => {
s.serialized_result.clone()
}
_ => "Tool completed.".to_string(),
};
Some(ConversationMessage {
role: MessageRole::User,
content: MessageContent::ToolResult {
tool_use_id: result.tool_call_id.clone(),
content,
is_error: false,
},
})
}
_ => None,
}
}
#[cfg(test)]
#[allow(deprecated)]
fn extract_tool_call_info_for_test(
tool_call: &api::message::ToolCall,
) -> (String, serde_json::Value) {
if let Some(tool) = &tool_call.tool {
match tool {
api::message::tool_call::Tool::RunShellCommand(cmd) => (
"run_shell_command".to_string(),
serde_json::json!({ "command": cmd.command }),
),
api::message::tool_call::Tool::ReadFiles(read) => (
"read_files".to_string(),
serde_json::json!({ "files": read.files.iter().map(|f| &f.name).collect::<Vec<_>>() }),
),
api::message::tool_call::Tool::ApplyFileDiffs(diffs) => (
"apply_file_diffs".to_string(),
serde_json::json!({ "diffs": diffs.diffs.iter().map(|d| {
serde_json::json!({
"file_path": d.file_path,
"search": d.search,
"replace": d.replace
})
}).collect::<Vec<_>>() }),
),
api::message::tool_call::Tool::Grep(grep) => (
"grep".to_string(),
serde_json::json!({ "queries": grep.queries, "path": grep.path }),
),
api::message::tool_call::Tool::FileGlob(glob) => (
"file_glob".to_string(),
serde_json::json!({ "patterns": glob.patterns }),
),
_ => ("unknown_tool".to_string(), serde_json::json!({})),
}
} else {
("unknown_tool".to_string(), serde_json::json!({}))
}
}
#[cfg(test)]
#[path = "request_translator_tests.rs"]
mod tests;
@@ -1,6 +1,6 @@
use serde_json::json;
use super::super::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole};
use crate::ai::bedrock::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole};
use super::sanitize_messages_for_bedrock;
#[test]
@@ -16,12 +16,55 @@ use crate::server::server_api::AIApiError;
use super::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole};
use super::diagnostic::BedrockDiagnosticLogger;
fn json_to_prost_struct(value: &serde_json::Value) -> prost_types::Struct {
let fields = match value.as_object() {
Some(map) => map
.iter()
.map(|(k, v)| (k.clone(), json_to_prost_value(v)))
.collect(),
None => std::collections::BTreeMap::new(),
};
prost_types::Struct { fields }
}
fn json_to_prost_value(value: &serde_json::Value) -> prost_types::Value {
use prost_types::value::Kind;
let kind = match value {
serde_json::Value::Null => Kind::NullValue(0),
serde_json::Value::Bool(b) => Kind::BoolValue(*b),
serde_json::Value::Number(n) => Kind::NumberValue(n.as_f64().unwrap_or(0.0)),
serde_json::Value::String(s) => Kind::StringValue(s.clone()),
serde_json::Value::Array(arr) => Kind::ListValue(prost_types::ListValue {
values: arr.iter().map(json_to_prost_value).collect(),
}),
serde_json::Value::Object(_) => Kind::StructValue(json_to_prost_struct(value)),
};
prost_types::Value { kind: Some(kind) }
}
/// Returns the context window size (in tokens) for a given model ID.
/// Models with "[1m]" in their identifier support 1M token context.
pub fn context_window_for_model(model_id: &str) -> u32 {
let lower = model_id.to_lowercase();
if lower.contains("[1m]") {
1_000_000
} else if lower.contains("nova") {
300_000
} else if lower.contains("deepseek") {
128_000
} else {
200_000
}
}
pub fn bedrock_stream_to_response_events(
mut output: ConverseStreamOutput,
task_id: String,
needs_create_task: bool,
user_query: Option<String>,
diagnostic_logger: Option<Arc<BedrockDiagnosticLogger>>,
messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
model_id: String,
) -> BoxStream<'static, Event> {
let request_id = Uuid::new_v4().to_string();
let conversation_id = Uuid::new_v4().to_string();
@@ -46,6 +89,13 @@ pub fn bedrock_stream_to_response_events(
yield Ok(create_task_event);
}
// Emit the user's query as a proto message in the task so it persists
// across sessions and can be used for the conversation title.
if let Some(ref query_text) = user_query {
let user_query_msg = build_user_query_message(&task_id, query_text);
yield Ok(user_query_msg);
}
let mut current_text_message_id: Option<String> = None;
let mut buffered_text = String::new();
let mut text_flushed = false;
@@ -62,6 +112,9 @@ pub fn bedrock_stream_to_response_events(
// Track full assistant text and tool calls for bedrock_message_history
let mut history_text = String::new();
let mut history_tool_calls: Vec<ContentPart> = Vec::new();
// Synthetic tool_results for unknown/hallucinated tools — these get
// paired with their tool_use in history so the next request is valid.
let mut synthetic_tool_results: Vec<ContentPart> = Vec::new();
let mut event_count: u32 = 0;
loop {
@@ -165,6 +218,42 @@ pub fn bedrock_stream_to_response_events(
current_tool_use_id.clear();
current_tool_name.clear();
current_tool_input_json.clear();
} else if !is_known_tool(&current_tool_name) {
// Unknown/hallucinated tool: record it in history
// with a paired error result so the conversation
// doesn't deadlock waiting for a tool_result that
// will never come.
log::warn!(
"[bedrock] Model called unknown tool '{}' (id={}), synthesizing error result",
current_tool_name, current_tool_use_id
);
let input_json: serde_json::Value = serde_json::from_str(&current_tool_input_json)
.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
history_tool_calls.push(ContentPart::ToolUse {
tool_use_id: current_tool_use_id.clone(),
name: current_tool_name.clone(),
input: input_json,
});
// Immediately pair with a synthetic error result
// so ensure_tool_results_paired doesn't need to
// fix it up later (and so the executor doesn't hang).
synthetic_tool_results.push(ContentPart::ToolResult {
tool_use_id: current_tool_use_id.clone(),
content: format!(
"Error: '{}' is not a valid tool. Available tools are: run_shell_command, read_files, apply_file_diffs, grep, file_glob. Please use one of these tools instead.",
current_tool_name
),
is_error: true,
});
if let Some(ref logger) = diagnostic_logger {
logger.log_stream_event(&format!(
"UnknownToolCall: name={}, id={} — synthesized error result",
current_tool_name, current_tool_use_id
));
}
current_tool_use_id.clear();
current_tool_name.clear();
current_tool_input_json.clear();
} else {
log::debug!("[bedrock] Tool call complete: {} ({})", current_tool_name, current_tool_use_id);
// Track for bedrock_message_history
@@ -212,12 +301,17 @@ pub fn bedrock_stream_to_response_events(
};
}
StreamEvent::Metadata(metadata) => {
log::info!("[bedrock-debug] Event #{event_count}: Metadata");
if let Some(usage) = metadata.usage() {
input_tokens = usage.input_tokens();
output_tokens = usage.output_tokens();
cache_read_input_tokens = usage.cache_read_input_tokens().unwrap_or(0);
cache_write_input_tokens = usage.cache_write_input_tokens().unwrap_or(0);
log::info!(
"[bedrock-debug] Event #{event_count}: Metadata (input={}, output={}, cache_read={}, cache_write={})",
input_tokens, output_tokens, cache_read_input_tokens, cache_write_input_tokens
);
} else {
log::warn!("[bedrock-debug] Event #{event_count}: Metadata with NO usage data");
}
}
_ => {
@@ -279,8 +373,15 @@ pub fn bedrock_stream_to_response_events(
}
}
let cost = estimate_cost_cents(
input_tokens as u32,
output_tokens as u32,
cache_read_input_tokens as u32,
cache_write_input_tokens as u32,
&model_id,
);
log::info!(
"[bedrock] Stream finished: input_tokens={input_tokens}, output_tokens={output_tokens}, cache_read={cache_read_input_tokens}, cache_write={cache_write_input_tokens}"
"[bedrock] Stream finished: model={model_id}, input_tokens={input_tokens}, output_tokens={output_tokens}, cache_read={cache_read_input_tokens}, cache_write={cache_write_input_tokens}, cost_cents={cost:.4}"
);
// Build and store the assistant message into bedrock_messages_sent
@@ -317,6 +418,35 @@ pub fn bedrock_stream_to_response_events(
if let Ok(mut sent) = messages_sent.lock() {
sent.push(assistant_msg);
// If the model hallucinated unknown tools, append a user
// message with synthetic error results so the history is
// valid for the next Bedrock request (every tool_use must
// be followed by a tool_result).
if !synthetic_tool_results.is_empty() {
let result_msg = if synthetic_tool_results.len() == 1 {
match synthetic_tool_results.remove(0) {
ContentPart::ToolResult { tool_use_id, content, is_error } => {
ConversationMessage {
role: MessageRole::User,
content: MessageContent::ToolResult { tool_use_id, content, is_error },
}
}
_ => unreachable!(),
}
} else {
ConversationMessage {
role: MessageRole::User,
content: MessageContent::MultiPart(synthetic_tool_results),
}
};
sent.push(result_msg);
log::info!(
"[bedrock] Stored synthetic tool error results in history. Total messages: {}",
sent.len()
);
}
log::info!(
"[bedrock] Stored assistant message in history. Total messages: {}",
sent.len()
@@ -339,6 +469,7 @@ pub fn bedrock_stream_to_response_events(
output_tokens,
cache_read_input_tokens,
cache_write_input_tokens,
&model_id,
);
yield Ok(finished_event);
};
@@ -371,6 +502,40 @@ pub(crate) fn build_create_task(task_id: &str) -> ResponseEvent {
}
}
fn build_user_query_message(task_id: &str, query_text: &str) -> ResponseEvent {
let message = api::Message {
id: Uuid::new_v4().to_string(),
task_id: task_id.to_string(),
request_id: String::new(),
timestamp: None,
server_message_data: String::new(),
citations: vec![],
message: Some(api::message::Message::UserQuery(
api::message::UserQuery {
query: query_text.to_string(),
..Default::default()
},
)),
};
let action = ClientAction {
action: Some(api::client_action::Action::AddMessagesToTask(
api::client_action::AddMessagesToTask {
task_id: task_id.to_string(),
messages: vec![message],
},
)),
};
ResponseEvent {
r#type: Some(api::response_event::Type::ClientActions(
api::response_event::ClientActions {
actions: vec![action],
},
)),
}
}
pub(super) fn build_stream_init(request_id: &str, conversation_id: &str) -> ResponseEvent {
ResponseEvent {
r#type: Some(api::response_event::Type::Init(
@@ -389,6 +554,7 @@ pub(super) fn build_stream_finished(
output_tokens: i32,
cache_read_input_tokens: i32,
cache_write_input_tokens: i32,
model_id: &str,
) -> ResponseEvent {
let total_tokens =
(input_tokens + output_tokens + cache_read_input_tokens + cache_write_input_tokens) as u32;
@@ -417,12 +583,21 @@ pub(super) fn build_stream_finished(
output_tokens as u32,
cache_read_input_tokens as u32,
cache_write_input_tokens as u32,
model_id,
),
}];
let max_context_tokens = context_window_for_model(model_id);
let context_usage = if max_context_tokens > 0 {
(input_tokens as f32 + cache_read_input_tokens as f32 + cache_write_input_tokens as f32)
/ max_context_tokens as f32
} else {
0.0
};
#[allow(deprecated)]
let conversation_usage_metadata = Some(stream_finished::ConversationUsageMetadata {
context_window_usage: 0.0,
context_window_usage: context_usage,
summarized: false,
credits_spent: 0.0,
token_usage: vec![],
@@ -444,20 +619,48 @@ pub(super) fn build_stream_finished(
}
}
/// Estimates cost in cents based on Anthropic Claude Bedrock pricing.
/// Uses Sonnet-tier pricing as a conservative default since we don't
/// know the exact model at this layer.
/// Pricing (per 1M tokens): input $3, output $15, cache_read $0.30, cache_write $3.75
/// Estimates cost in cents based on Bedrock model pricing.
/// Pricing varies by model (per 1M tokens):
/// Opus 4.6/4.7: input $15, output $75, cache_read $1.50, cache_write $18.75
/// Sonnet 4/4.6: input $3, output $15, cache_read $0.30, cache_write $3.75
/// Haiku 4.5: input $0.80, output $4, cache_read $0.08, cache_write $1.00
/// Nova Pro: input $0.80, output $3.20
/// Nova Lite: input $0.06, output $0.24
/// Nova Micro: input $0.035, output $0.14
/// DeepSeek R1: input $1.35, output $5.40
fn estimate_cost_cents(
input_tokens: u32,
output_tokens: u32,
cache_read_tokens: u32,
cache_write_tokens: u32,
model_id: &str,
) -> f32 {
let input_cost = input_tokens as f64 * 0.3 / 100_000.0;
let output_cost = output_tokens as f64 * 1.5 / 100_000.0;
let cache_read_cost = cache_read_tokens as f64 * 0.03 / 100_000.0;
let cache_write_cost = cache_write_tokens as f64 * 0.375 / 100_000.0;
let lower = model_id.to_lowercase();
// (input_per_1m, output_per_1m, cache_read_per_1m, cache_write_per_1m) in dollars
let (input_rate, output_rate, cache_read_rate, cache_write_rate) =
if lower.contains("opus") {
(15.0, 75.0, 1.50, 18.75)
} else if lower.contains("haiku") {
(0.80, 4.0, 0.08, 1.0)
} else if lower.contains("nova-pro") {
(0.80, 3.20, 0.0, 0.0)
} else if lower.contains("nova-lite") {
(0.06, 0.24, 0.0, 0.0)
} else if lower.contains("nova-micro") {
(0.035, 0.14, 0.0, 0.0)
} else if lower.contains("deepseek") {
(1.35, 5.40, 0.0, 0.0)
} else {
// Default to Sonnet pricing
(3.0, 15.0, 0.30, 3.75)
};
// Convert from dollars per 1M tokens to cents per token
let input_cost = input_tokens as f64 * input_rate * 100.0 / 1_000_000.0;
let output_cost = output_tokens as f64 * output_rate * 100.0 / 1_000_000.0;
let cache_read_cost = cache_read_tokens as f64 * cache_read_rate * 100.0 / 1_000_000.0;
let cache_write_cost = cache_write_tokens as f64 * cache_write_rate * 100.0 / 1_000_000.0;
(input_cost + output_cost + cache_read_cost + cache_write_cost) as f32
}
@@ -653,6 +856,191 @@ fn build_tool_call_message(
},
))
}
"search_codebase" => {
let query = input
.get("query")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let codebase_path = input
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Some(api::message::tool_call::Tool::SearchCodebase(
api::message::tool_call::SearchCodebase {
query,
path_filters: vec![],
codebase_path,
},
))
}
"write_to_long_running_shell_command" => {
let text_input = input
.get("input")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Some(api::message::tool_call::Tool::WriteToLongRunningShellCommand(
api::message::tool_call::WriteToLongRunningShellCommand {
input: text_input.into_bytes(),
mode: None,
command_id: String::new(),
},
))
}
"read_shell_command_output" => {
Some(api::message::tool_call::Tool::ReadShellCommandOutput(
api::message::tool_call::ReadShellCommandOutput {
command_id: String::new(),
delay: None,
},
))
}
"read_mcp_resource" => {
let server_id = input
.get("server_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let uri = input
.get("uri")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Some(api::message::tool_call::Tool::ReadMcpResource(
api::message::tool_call::ReadMcpResource {
uri,
server_id,
},
))
}
"read_documents" => {
let documents = input
.get("document_ids")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|d| d.as_str())
.map(|id| api::message::tool_call::read_documents::Document {
document_id: id.to_string(),
line_ranges: vec![],
})
.collect()
})
.unwrap_or_default();
Some(api::message::tool_call::Tool::ReadDocuments(
api::message::tool_call::ReadDocuments { documents },
))
}
"create_documents" => {
let new_documents = input
.get("documents")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|d| {
Some(api::message::tool_call::create_documents::NewDocument {
title: d.get("title")?.as_str()?.to_string(),
content: d.get("content")?.as_str()?.to_string(),
})
})
.collect()
})
.unwrap_or_default();
Some(api::message::tool_call::Tool::CreateDocuments(
api::message::tool_call::CreateDocuments { new_documents },
))
}
"edit_documents" => {
let diffs = input
.get("diffs")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|d| {
Some(api::message::tool_call::edit_documents::DocumentDiff {
document_id: d.get("document_id")?.as_str()?.to_string(),
search: d.get("search").and_then(|v| v.as_str()).unwrap_or("").to_string(),
replace: d.get("replace").and_then(|v| v.as_str()).unwrap_or("").to_string(),
})
})
.collect()
})
.unwrap_or_default();
Some(api::message::tool_call::Tool::EditDocuments(
api::message::tool_call::EditDocuments { diffs },
))
}
"start_agent" => {
let _name = input.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
let prompt = input.get("prompt").and_then(|v| v.as_str()).unwrap_or("").to_string();
Some(api::message::tool_call::Tool::Subagent(
api::message::tool_call::Subagent {
task_id: String::new(),
payload: prompt,
metadata: None,
},
))
}
"send_message_to_agent" => {
let agent_id = input.get("agent_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
let message = input.get("message").and_then(|v| v.as_str()).unwrap_or("").to_string();
Some(api::message::tool_call::Tool::SendMessageToAgent(
api::SendMessageToAgent {
addresses: vec![agent_id],
subject: String::new(),
message,
},
))
}
"ask_user_question" => {
let question_text = input.get("question").and_then(|v| v.as_str()).unwrap_or("").to_string();
let options: Vec<api::ask_user_question::Option> = input
.get("options")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|o| o.as_str())
.map(|label| api::ask_user_question::Option { label: label.to_string() })
.collect()
})
.unwrap_or_default();
let question = api::ask_user_question::Question {
question_id: Uuid::new_v4().to_string(),
question: question_text,
question_type: Some(api::ask_user_question::question::QuestionType::MultipleChoice(
api::ask_user_question::MultipleChoice {
options,
is_multiselect: false,
supports_other: true,
recommended_option_index: 0,
},
)),
};
Some(api::message::tool_call::Tool::AskUserQuestion(
api::AskUserQuestion {
questions: vec![question],
},
))
}
"read_skill" => {
let skill = input.get("skill").and_then(|v| v.as_str()).unwrap_or("").to_string();
Some(api::message::tool_call::Tool::ReadSkill(
api::message::tool_call::ReadSkill {
name: skill.clone(),
skill_reference: Some(
api::message::tool_call::read_skill::SkillReference::SkillPath(skill),
),
},
))
}
"fetch_conversation" => {
let conversation_id = input.get("conversation_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
Some(api::message::tool_call::Tool::FetchConversation(
api::message::tool_call::FetchConversation { conversation_id },
))
}
"suggest_next_prompt" => {
let prompt = input
.get("prompt")
@@ -675,8 +1063,25 @@ fn build_tool_call_message(
},
))
}
name if name.starts_with("mcp__") => {
// MCP tool call: parse server and tool name from "mcp__{server}__{tool}"
let parts: Vec<&str> = name.splitn(3, "__").collect();
let (server_name, mcp_tool_name) = if parts.len() == 3 {
(parts[1].to_string(), parts[2].to_string())
} else {
(String::new(), name.strip_prefix("mcp__").unwrap_or(name).to_string())
};
let args = json_to_prost_struct(&input);
Some(api::message::tool_call::Tool::CallMcpTool(
api::message::tool_call::CallMcpTool {
name: mcp_tool_name,
args: Some(args),
server_id: server_name,
},
))
}
_ => {
log::warn!("[bedrock] Unknown tool name: {tool_name}, emitting as text");
log::error!("[bedrock] build_tool_call_message called with unknown tool: {tool_name}");
None
}
};
@@ -695,6 +1100,9 @@ fn build_tool_call_message(
})),
}
} else {
// Fallback: emit as agent output text so the stream doesn't break,
// but this should not happen in normal operation.
log::error!("[bedrock] Emitting unknown tool as text (should have been caught earlier): {tool_name}");
api::Message {
id: Uuid::new_v4().to_string(),
task_id: task_id.to_string(),
@@ -705,8 +1113,8 @@ fn build_tool_call_message(
message: Some(api::message::Message::AgentOutput(
api::message::AgentOutput {
text: format!(
"[Tool call: {} ({})]\nInput: {}",
tool_name, tool_use_id, tool_input_json
"Error: Model attempted to use unknown tool '{}'. This tool does not exist.",
tool_name
),
},
)),
@@ -730,3 +1138,29 @@ fn build_tool_call_message(
)),
}
}
/// Built-in tools that Galaxy knows how to execute directly.
const KNOWN_TOOLS: &[&str] = &[
"run_shell_command",
"read_files",
"apply_file_diffs",
"grep",
"file_glob",
"search_codebase",
"write_to_long_running_shell_command",
"read_shell_command_output",
"read_mcp_resource",
"read_documents",
"create_documents",
"edit_documents",
"start_agent",
"send_message_to_agent",
"ask_user_question",
"suggest_next_prompt",
"read_skill",
"fetch_conversation",
];
fn is_known_tool(name: &str) -> bool {
KNOWN_TOOLS.contains(&name) || name.starts_with("mcp__")
}
@@ -1,6 +1,6 @@
use warp_multi_agent_api::{self as api, response_event::stream_finished};
use super::stream::*;
use super::response_translator::*;
#[test]
fn test_build_stream_init_has_valid_ids() {
@@ -19,7 +19,7 @@ fn test_build_stream_init_has_valid_ids() {
#[test]
fn test_build_stream_finished_done_reason() {
let reason = stream_finished::Reason::Done(stream_finished::Done {});
let event = build_stream_finished(reason, 100, 50, 20, 10);
let event = build_stream_finished(reason, 100, 50, 20, 10, "anthropic.claude-sonnet-4-6");
match event.r#type {
Some(api::response_event::Type::Finished(finished)) => {
@@ -53,7 +53,7 @@ fn test_build_stream_finished_done_reason() {
#[test]
fn test_build_stream_finished_max_token_limit() {
let reason = stream_finished::Reason::MaxTokenLimit(stream_finished::ReachedMaxTokenLimit {});
let event = build_stream_finished(reason, 200, 100, 0, 0);
let event = build_stream_finished(reason, 200, 100, 0, 0, "anthropic.claude-sonnet-4-6");
match event.r#type {
Some(api::response_event::Type::Finished(finished)) => {
@@ -69,7 +69,7 @@ fn test_build_stream_finished_max_token_limit() {
#[test]
fn test_build_stream_finished_other_reason() {
let reason = stream_finished::Reason::Other(stream_finished::Other {});
let event = build_stream_finished(reason, 0, 0, 0, 0);
let event = build_stream_finished(reason, 0, 0, 0, 0, "anthropic.claude-sonnet-4-6");
match event.r#type {
Some(api::response_event::Type::Finished(finished)) => {
@@ -84,7 +84,7 @@ fn test_build_stream_finished_other_reason() {
#[test]
fn test_build_create_task_event() {
let event = super::stream::build_create_task("task-abc-123");
let event = super::response_translator::build_create_task("task-abc-123");
match event.r#type {
Some(api::response_event::Type::ClientActions(actions)) => {
@@ -106,7 +106,7 @@ fn test_build_create_task_event() {
#[test]
fn test_build_create_task_has_no_parent() {
let event = super::stream::build_create_task("root-task-id");
let event = super::response_translator::build_create_task("root-task-id");
if let Some(api::response_event::Type::ClientActions(actions)) = event.r#type {
if let Some(api::client_action::Action::CreateTask(create)) = &actions.actions[0].action {
@@ -118,3 +118,68 @@ fn test_build_create_task_has_no_parent() {
}
}
}
#[test]
fn test_context_window_for_model_1m_marker() {
assert_eq!(context_window_for_model("anthropic.claude-opus-4-6[1m]"), 1_000_000);
assert_eq!(context_window_for_model("us.anthropic.claude-opus-4-6[1M]"), 1_000_000);
assert_eq!(context_window_for_model("anthropic.claude-sonnet-4-6[1m]"), 1_000_000);
}
#[test]
fn test_context_window_for_model_standard_claude() {
assert_eq!(context_window_for_model("anthropic.claude-opus-4-6"), 200_000);
assert_eq!(context_window_for_model("us.anthropic.claude-sonnet-4-6"), 200_000);
assert_eq!(context_window_for_model("anthropic.claude-haiku-4-5-20251001-v1:0"), 200_000);
}
#[test]
fn test_context_window_for_model_nova() {
assert_eq!(context_window_for_model("amazon.nova-pro-v1:0"), 300_000);
assert_eq!(context_window_for_model("amazon.nova-lite-v1:0"), 300_000);
assert_eq!(context_window_for_model("amazon.nova-micro-v1:0"), 300_000);
}
#[test]
fn test_context_window_for_model_deepseek() {
assert_eq!(context_window_for_model("deepseek.r1-v1:0"), 128_000);
}
#[test]
fn test_cost_varies_by_model() {
let reason = stream_finished::Reason::Done(stream_finished::Done {});
let opus_event = build_stream_finished(reason.clone(), 1000, 1000, 0, 0, "anthropic.claude-opus-4-6");
let reason = stream_finished::Reason::Done(stream_finished::Done {});
let sonnet_event = build_stream_finished(reason.clone(), 1000, 1000, 0, 0, "anthropic.claude-sonnet-4-6");
let reason = stream_finished::Reason::Done(stream_finished::Done {});
let haiku_event = build_stream_finished(reason, 1000, 1000, 0, 0, "anthropic.claude-haiku-4-5-20251001-v1:0");
let opus_cost = match opus_event.r#type {
Some(api::response_event::Type::Finished(f)) => f.token_usage[0].cost_in_cents,
_ => panic!("Expected Finished"),
};
let sonnet_cost = match sonnet_event.r#type {
Some(api::response_event::Type::Finished(f)) => f.token_usage[0].cost_in_cents,
_ => panic!("Expected Finished"),
};
let haiku_cost = match haiku_event.r#type {
Some(api::response_event::Type::Finished(f)) => f.token_usage[0].cost_in_cents,
_ => panic!("Expected Finished"),
};
assert!(opus_cost > sonnet_cost, "Opus should cost more than Sonnet");
assert!(sonnet_cost > haiku_cost, "Sonnet should cost more than Haiku");
assert!(haiku_cost > 0.0, "All costs should be positive for non-zero tokens");
}
#[test]
fn test_cost_zero_for_zero_tokens() {
let reason = stream_finished::Reason::Done(stream_finished::Done {});
let event = build_stream_finished(reason, 0, 0, 0, 0, "anthropic.claude-sonnet-4-6");
let cost = match event.r#type {
Some(api::response_event::Type::Finished(f)) => f.token_usage[0].cost_in_cents,
_ => panic!("Expected Finished"),
};
assert_eq!(cost, 0.0);
}
-218
View File
@@ -1,218 +0,0 @@
const CAPABILITIES_DOC: &str = r#"# Galaxy — System Capabilities
You are Galaxy, an AI coding assistant embedded in a terminal application with direct filesystem and shell access.
## What You Can Do
- Execute any shell command the user could run
- Read, create, and edit files anywhere the user has access
- Search codebases using grep and glob patterns
- Work with git repositories
- Install packages, run builds, execute tests
- Debug errors by reading logs and source code
## Permission Model
- **Supervised mode**: Destructive/risky commands require user approval
- **Autonomous mode**: All actions auto-execute except denylist violations
- Commands are classified as read_only or risky by you — be accurate
- Set is_read_only=true for: ls, cat, grep, find, git status, git log, echo, pwd, which, env, printenv
- Set is_risky=true for: rm -rf, git push --force, format/wipe commands, sudo with destructive args
## Tool Execution
- Shell commands run in the user's actual terminal PTY
- Commands have a 2-second initial timeout; if still running, a terminal snapshot is returned
- File edits use fuzzy search/replace — the search string must be unique enough to match exactly one location
- All file paths should be absolute (based on working directory from environment)
## Best Practices
- Read a file before editing it
- Use grep/file_glob to understand project structure before making changes
- For multi-file changes, explain your plan first
- Prefer small, incremental edits over large rewrites
- Always verify changes compile/pass tests when possible"#;
const RUN_SHELL_COMMAND_DOC: &str = r#"# run_shell_command
Execute a shell command in the user's terminal.
## Parameters
- `command` (string, required): The shell command to execute
- `is_read_only` (boolean, optional): Set true if command only reads data (ls, cat, grep, git status)
- `is_risky` (boolean, optional): Set true if command is destructive or irreversible
## Behavior
- Runs in the user's actual shell (bash/zsh/fish) with their environment
- 2-second initial wait for output
- If command finishes: returns full output + exit code
- If still running after timeout: returns terminal snapshot (visible content)
- Long-running commands can be monitored via subsequent read_shell_command_output calls
## Guidelines
- Always set is_read_only=true for read operations (this enables auto-execution)
- Set is_risky=true for: rm with -rf, git push --force, destructive database operations
- Combine related commands with && for efficiency
- Use | head -50 or | tail -20 for potentially large outputs
- Quote paths with spaces
- Prefer absolute paths
## Examples
- Read-only: `{"command": "ls -la /path/to/dir", "is_read_only": true}`
- Risky: `{"command": "rm -rf ./build/", "is_risky": true}`
- Normal: `{"command": "cargo build 2>&1"}`"#;
const READ_FILES_DOC: &str = r#"# read_files
Read the contents of one or more files.
## Parameters
- `files` (array of strings, required): Absolute file paths to read
## Behavior
- Returns file contents with path headers
- 1MB cap per file
- Binary files are detected and skipped
- Images are resized and described
- Non-existent files return an error message
## Guidelines
- Always read a file before editing it (to understand context)
- Use absolute paths (relative to the working directory shown in environment)
- Batch multiple files in one call for efficiency
- For large files, consider using grep first to find relevant sections
## Examples
- Single file: `{"files": ["/home/user/project/src/main.rs"]}`
- Multiple: `{"files": ["/home/user/project/Cargo.toml", "/home/user/project/src/lib.rs"]}`"#;
const APPLY_FILE_DIFFS_DOC: &str = r#"# apply_file_diffs
Apply search/replace edits to files. Creates files if they don't exist (with empty search string).
## Parameters
- `diffs` (array, required): Array of diff objects, each with:
- `file_path` (string): Absolute path to the file
- `search` (string): Exact text to find (must match uniquely)
- `replace` (string): Text to replace it with
## Behavior
- Uses fuzzy matching to locate the search string in the file
- The search string must match exactly ONE location in the file
- If search is empty and file doesn't exist, creates the file with replace content
- Returns the updated file content and a unified diff
- User sees a diff view and can approve/reject
## Guidelines
- Include enough context in search to ensure uniqueness (3-5 surrounding lines)
- Don't include line numbers in search/replace text
- For multiple edits in one file, apply them in one call with multiple diffs
- Preserve existing indentation style (tabs vs spaces)
- Read the file first to get the exact text to search for
- For new files, use search="" and put full content in replace
## Examples
- Edit: `{"diffs": [{"file_path": "/path/file.rs", "search": "fn old_name()", "replace": "fn new_name()"}]}`
- Create: `{"diffs": [{"file_path": "/path/new.rs", "search": "", "replace": "fn main() {\n println!(\"hello\");\n}"}]}`
- Multi-edit: `{"diffs": [{"file_path": "/path/file.rs", "search": "use old;", "replace": "use new;"}, {"file_path": "/path/file.rs", "search": "old::call()", "replace": "new::call()"}]}`"#;
const GREP_DOC: &str = r#"# grep
Search for patterns in files using regex.
## Parameters
- `queries` (array of strings, required): Regex patterns to search for
- `path` (string, optional): Directory to search in (defaults to working directory)
## Behavior
- In git repos: uses git grep (respects .gitignore)
- Outside git: uses ripgrep
- 10-second timeout
- Returns file paths and matching line numbers (NOT content)
- Use read_files afterward to see the actual matching content
## Guidelines
- Use simple patterns for speed (literal strings when possible)
- Scope searches with path parameter to avoid scanning huge directories
- Follow up with read_files to see context around matches
- Multiple queries are searched independently (OR logic)
- Regex syntax: standard ERE (extended regex)
## Examples
- Simple: `{"queries": ["fn main"]}`
- Regex: `{"queries": ["impl.*Display"]}`
- Scoped: `{"queries": ["TODO", "FIXME"], "path": "/home/user/project/src"}`"#;
const FILE_GLOB_DOC: &str = r#"# file_glob
Find files matching glob patterns.
## Parameters
- `patterns` (array of strings, required): Glob patterns to match
## Behavior
- In git repos: uses git ls-files (respects .gitignore)
- Outside git: uses find
- 10-second timeout
- Returns absolute file paths of matching files
- Searches from working directory by default
## Guidelines
- Use to discover project structure before making changes
- Common patterns: "**/*.rs", "src/**/*.ts", "**/Cargo.toml"
- Combine with read_files to inspect discovered files
- Use specific subdirectory patterns to narrow results
## Examples
- All Rust files: `{"patterns": ["**/*.rs"]}`
- Config files: `{"patterns": ["**/Cargo.toml", "**/package.json"]}`
- Specific dir: `{"patterns": ["src/ai/**/*.rs"]}`"#;
const GET_TOOL_DOCUMENTATION_DOC: &str = r#"# get_tool_documentation
Get detailed usage documentation for any available tool.
## Parameters
- `tool_name` (string, required): Name of the tool, or 'capabilities' for system overview
## Available documentation
- `capabilities` — Full system overview, permissions, best practices
- `run_shell_command` — Shell execution details and guidelines
- `read_files` — File reading behavior and limits
- `apply_file_diffs` — File editing with search/replace
- `grep` — Pattern searching in files
- `file_glob` — File discovery with glob patterns
- `get_tool_documentation` — This documentation
## When to use
Call this tool when you need detailed guidance on how to use a specific tool effectively, especially for complex operations like file editing or understanding the permission model."#;
const SUGGEST_NEXT_PROMPT_DOC: &str = r#"# suggest_next_prompt
Suggest a follow-up action the user might want after completing the current task.
## When to Use
- After completing a task successfully
- When there's a natural next step (e.g., "run tests" after writing code, "commit changes" after editing files)
- Only call this ONCE at the end of your response, alongside your final text output
## Parameters
- `prompt`: The full prompt text that will be sent to the agent if the user clicks the suggestion
- `label`: A short display label (under 40 characters) shown as a clickable chip
## Guidelines
- Keep labels concise and action-oriented (e.g., "Run tests", "Commit changes", "Deploy")
- The prompt should be specific enough to be useful without further clarification
- Only suggest actions that are relevant to what was just accomplished
- Do NOT suggest prompts when the conversation is exploratory or the user is asking questions"#;
pub fn get_tool_documentation(tool_name: &str) -> Option<String> {
match tool_name {
"capabilities" => Some(CAPABILITIES_DOC.to_string()),
"run_shell_command" => Some(RUN_SHELL_COMMAND_DOC.to_string()),
"read_files" => Some(READ_FILES_DOC.to_string()),
"apply_file_diffs" => Some(APPLY_FILE_DIFFS_DOC.to_string()),
"grep" => Some(GREP_DOC.to_string()),
"file_glob" => Some(FILE_GLOB_DOC.to_string()),
"get_tool_documentation" => Some(GET_TOOL_DOCUMENTATION_DOC.to_string()),
"suggest_next_prompt" => Some(SUGGEST_NEXT_PROMPT_DOC.to_string()),
_ => None,
}
}
+141
View File
@@ -0,0 +1,141 @@
use std::sync::{Arc, Mutex};
use warp_multi_agent_api as api;
use crate::ai::agent::api::ResponseStream;
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;
pub struct TranslatorRequest {
pub config: BedrockClientConfig,
pub model_id: String,
pub root_task_id: Option<String>,
pub bedrock_message_history: Vec<ConversationMessage>,
pub bedrock_messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
}
pub async fn execute(
params: TranslatorRequest,
request: &mut api::Request,
) -> Result<ResponseStream, 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);
let mut model_id = params.model_id;
if model_id.is_empty() || model_id == "auto" {
model_id = "us.anthropic.claude-opus-4-6".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 mut messages = params.bedrock_message_history;
if !new_input_messages.is_empty() {
log::info!(
"[bedrock] Appending {} new input messages to history of {}",
new_input_messages.len(),
messages.len()
);
messages.extend(new_input_messages);
}
request_translator::sanitize_messages_for_bedrock(&mut messages);
let system_prompt = request_translator::extract_system_prompt(request);
let tools = request_translator::extract_tools(request);
log::info!(
"[bedrock] Sending {} messages, system_prompt={}, tools={}",
messages.len(),
system_prompt.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 = request_translator::extract_user_query_text(request);
let stream = bedrock
.converse_stream(
&model_id,
&task_id,
needs_create_task,
messages.clone(),
system_prompt,
tools,
64000,
None,
cross_region_inference,
user_query_text,
diagnostic_logger,
params.bedrock_messages_sent.clone(),
)
.await?;
if let Ok(mut sent) = params.bedrock_messages_sent.lock() {
*sent = messages;
}
Ok(stream)
}
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::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(", "))
}
}
}
+45 -35
View File
@@ -18,7 +18,6 @@ use crate::ai::blocklist::block::view_impl::common::{
use crate::ai::blocklist::inline_action::aws_bedrock_credentials_error::AwsBedrockCredentialsErrorView;
use crate::ai::blocklist::inline_action::create_or_edit_document::CreateOrEditDocumentAction;
use crate::ai::blocklist::secret_redaction::SecretRedactionState;
use crate::ai::blocklist::view_util::format_credits;
use crate::ai::skills::SkillOpenOrigin;
use crate::ai::skills::{
icon_override_for_skill_name, render_skill_button, skill_path_from_file_path,
@@ -3195,13 +3194,8 @@ fn render_usage_button(props: Props, app: &AppContext) -> Box<dyn Element> {
return Empty::new().finish();
};
// If this conversation has no usage metadata (e.g. a forked conversation from
// mid-way through a prior conversation where the server did not send
// ConversationUsageMetadata), avoid rendering the usage button entirely.
let has_any_usage = conversation.credits_spent() > 0.0
|| conversation.credits_spent_for_last_block().is_some()
|| !conversation.token_usage().is_empty()
|| conversation.tool_usage_metadata().total_tool_calls() > 0;
let has_any_usage = conversation.total_tokens() > 0
|| conversation.total_cost_cents() > 0.0;
if !has_any_usage {
return Empty::new().finish();
}
@@ -3215,29 +3209,37 @@ fn render_usage_button(props: Props, app: &AppContext) -> Box<dyn Element> {
Icon::ChevronRight
};
let total_credits_spent = conversation.credits_spent();
let mut credit_usage_text = format_credits(total_credits_spent);
if let Some(credits_spent_for_last_block) = conversation.credits_spent_for_last_block() {
// Only show the credits spent for the last block if it is different from the total credits spent
// and we spent a non-zero amount of credits for the last block.
// Avoid showing the credits spent for the last block if the request failed, as we refund user
// credits in that case (so no credits were in fact spent).
if credits_spent_for_last_block > 0.0
&& total_credits_spent != credits_spent_for_last_block
&& props.model.status(app).error().is_none()
{
// If the first part of the decimal is 0, we just display the whole number.
if credits_spent_for_last_block.fract() < 0.1 {
credit_usage_text = format!(
"{credit_usage_text} (+{})",
credits_spent_for_last_block.trunc() as i32
);
} else {
credit_usage_text =
format!("{credit_usage_text} (+{credits_spent_for_last_block:.1})");
}
}
}
let context_usage = conversation.context_window_usage();
let total_input = conversation.total_input_tokens();
let cache_read = conversation.total_cache_read_tokens();
let cache_write = conversation.total_cache_write_tokens();
let cache_miss = conversation.cache_miss_tokens();
let cost_cents = conversation.total_cost_cents();
let max_context: u32 = if context_usage > 0.0 {
(total_input as f32 / context_usage).round() as u32
} else {
200_000
};
let context_pct = context_usage * 100.0;
let cache_total = cache_read + cache_write + cache_miss;
let cache_hit_pct = if cache_total > 0 {
(cache_read as f64 / cache_total as f64) * 100.0
} else {
0.0
};
let usage_text = format!(
"Context: {:.1}% ({} / {}) | Cache: {:.1}% (R: {}, W: {}, M: {}) | Cost: ${:.2}",
context_pct,
format_token_count(total_input),
format_token_count(max_context),
cache_hit_pct,
format_token_count(cache_read),
format_token_count(cache_write),
format_token_count(cache_miss),
cost_cents / 100.0,
);
let icon_size = icon_size(app);
let button_row = Flex::row()
@@ -3246,7 +3248,7 @@ fn render_usage_button(props: Props, app: &AppContext) -> Box<dyn Element> {
.with_child(
Container::new(
Text::new_inline(
credit_usage_text,
usage_text,
appearance.ui_font_family(),
appearance.monospace_font_size(),
)
@@ -3265,7 +3267,6 @@ fn render_usage_button(props: Props, app: &AppContext) -> Box<dyn Element> {
)
.with_child(
Container::new(
// Expansion icon
ConstrainedBox::new(
expansion_icon
.to_galaxyui_icon(
@@ -3299,10 +3300,9 @@ fn render_usage_button(props: Props, app: &AppContext) -> Box<dyn Element> {
.with_background(background)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)));
// Show tooltip on hover or while clicked
let mut stack = Stack::new().with_child(content.finish());
let tooltip = ui_builder
.tool_tip("Show credit usage details".to_string())
.tool_tip("Show usage details".to_string())
.build()
.finish();
stack.add_positioned_overlay_child(
@@ -3328,6 +3328,16 @@ fn render_usage_button(props: Props, app: &AppContext) -> Box<dyn Element> {
.finish()
}
fn format_token_count(tokens: u32) -> String {
if tokens >= 1_000_000 {
format!("{:.1}M", tokens as f64 / 1_000_000.0)
} else if tokens >= 1_000 {
format!("{:.1}k", tokens as f64 / 1_000.0)
} else {
format!("{tokens}")
}
}
pub fn action_icon<V: View>(
action_id: &AIAgentActionId,
action_model: &ModelHandle<BlocklistAIActionModel>,
@@ -18,7 +18,6 @@ use crate::{
},
network::NetworkStatus,
report_error, send_telemetry_from_ctx,
server::server_api::ServerApiProvider,
settings::ai::AISettings,
};
use settings::Setting;
@@ -100,7 +99,6 @@ impl ResponseStream {
access_key_id: settings.bedrock_access_key_id.value().clone(),
secret_access_key: settings.bedrock_secret_access_key.value().clone(),
cross_region_inference: *settings.bedrock_cross_region_inference.value(),
fallback_to_warp: *settings.bedrock_fallback_to_warp.value(),
}.with_external_fallbacks())
}
@@ -110,7 +108,6 @@ impl ResponseStream {
can_attempt_resume_on_error: bool,
ctx: &mut ModelContext<Self>,
) -> Self {
let server_api = ServerApiProvider::as_ref(ctx).get();
let (cancellation_tx, cancellation_rx) = oneshot::channel();
let start_time = Local::now();
@@ -119,13 +116,7 @@ impl ResponseStream {
let params_clone = params.clone();
let _ = ctx.spawn(
async move {
generate_multi_agent_output(
server_api,
bedrock_config,
params_clone,
cancellation_rx,
)
.await
generate_multi_agent_output(bedrock_config, params_clone, cancellation_rx).await
},
move |me, stream, ctx| {
me.handle_response_stream_result(request_id, stream, ctx);
@@ -194,11 +185,9 @@ impl ResponseStream {
self.current_request_id = Some(request_id);
let params = self.params.clone();
let bedrock_config = Self::bedrock_config_if_applicable(params.model.as_str(), ctx);
let server_api = ServerApiProvider::as_ref(ctx).get();
let _ = ctx.spawn(
async move {
generate_multi_agent_output(server_api, bedrock_config, params, cancellation_rx)
.await
generate_multi_agent_output(bedrock_config, params, cancellation_rx).await
},
move |me, stream, ctx| {
me.handle_response_stream_result(request_id, stream, ctx);
+1 -1
View File
@@ -67,7 +67,7 @@ pub(crate) use view_util::{
NEW_AGENT_PANE_LABEL,
};
pub(crate) use view_util::{format_credits, format_token_count};
pub(crate) use view_util::{format_cost_cents, format_credits, format_token_count};
pub use crate::ai::blocklist::block::{secret_redaction, AIBlockResponseRating, TextLocation};
pub use block::keyboard_navigable_buttons;
@@ -1,5 +1,5 @@
use crate::ai::blocklist::usage::render_context_window_usage_icon;
use crate::ai::blocklist::view_util::{format_cost_cents, format_credits, format_token_count};
use crate::ai::blocklist::view_util::{format_cost_cents, format_token_count};
use crate::appearance::Appearance;
use crate::persistence::model::{
token_usage_category_display_name, ModelTokenUsage, FULL_TERMINAL_USE_CATEGORY,
@@ -26,10 +26,6 @@ pub enum DisplayMode {
}
pub struct ConversationUsageInfo {
pub credits_spent: f32,
// Credits spent over the last block, where the block comprises
// all agent outputs since the most recent user input.
pub credits_spent_for_last_block: Option<f32>,
pub tool_calls: i32,
pub models: Vec<ModelTokenUsage>,
pub context_window_usage: f32,
@@ -144,28 +140,10 @@ impl ConversationUsageView {
));
values.push(render_section_header("".to_string(), appearance));
if self.display_mode == DisplayMode::Footer
&& self.usage_info.credits_spent_for_last_block.is_some()
{
let last_block_credits = self.usage_info.credits_spent_for_last_block.unwrap();
labels.push(render_label_text(
"Credits spent (last response)",
appearance,
));
if self.usage_info.estimated_cost_cents > 0.0 {
labels.push(render_label_text("Estimated cost", appearance));
values.push(render_value_text(
format_credits(last_block_credits),
appearance,
));
labels.push(render_label_text("Credits spent (total)", appearance));
values.push(render_value_text(
format_credits(self.usage_info.credits_spent),
appearance,
));
} else {
labels.push(render_label_text("Credits spent", appearance));
values.push(render_value_text(
format_credits(self.usage_info.credits_spent),
format_cost_cents(self.usage_info.estimated_cost_cents),
appearance,
));
}
@@ -307,14 +285,6 @@ impl ConversationUsageView {
appearance,
));
}
if self.usage_info.estimated_cost_cents > 0.0 {
labels.push(render_label_text("Estimated cost", appearance));
values.push(render_value_text(
format_cost_cents(self.usage_info.estimated_cost_cents),
appearance,
));
}
}
labels.push(render_label_text("Context window used", appearance));
+1
View File
@@ -16,6 +16,7 @@ use galaxy_core::ui::appearance::Appearance;
use serde::{Deserialize, Serialize};
pub mod manager;
pub mod predefined_rules;
pub mod view;
pub use manager::AIFactManager;
pub use view::{AIFactView, AIFactViewEvent};
+53
View File
@@ -0,0 +1,53 @@
pub struct PredefinedRule {
pub name: &'static str,
pub content: &'static str,
}
pub const SYSTEM_DEFINED_RULE_PREFIX: &str = "System Defined Rule";
pub const PREDEFINED_RULES: &[PredefinedRule] = &[
PredefinedRule {
name: "System Defined Rule #1",
content: "Prioritize correctness, completeness, and reliability over speed.",
},
PredefinedRule {
name: "System Defined Rule #2",
content: "Never guess. If uncertain, explicitly say so and verify before finalizing.",
},
PredefinedRule {
name: "System Defined Rule #3",
content: "Ground non-trivial claims in evidence (repo files, command output, tests, official documentation).",
},
PredefinedRule {
name: "System Defined Rule #4",
content: "If confidence is not high, or if a claim depends on external/current behavior, perform web verification before answering; prioritize official docs and cross-check with at least one additional reliable source.",
},
PredefinedRule {
name: "System Defined Rule #5",
content: "Clearly separate facts, assumptions, and hypotheses.",
},
PredefinedRule {
name: "System Defined Rule #6",
content: "Ask clarifying questions when ambiguity could change the solution or implementation.",
},
PredefinedRule {
name: "System Defined Rule #7",
content: "For code changes, run relevant validations when available (tests, lint, typecheck, build) and report what was run, what passed/failed, and what was not run.",
},
PredefinedRule {
name: "System Defined Rule #8",
content: "If validation cannot be run, state that explicitly and describe residual risk and recommended manual checks.",
},
PredefinedRule {
name: "System Defined Rule #9",
content: "Prefer \"I don't know yet\" over plausible speculation.",
},
PredefinedRule {
name: "System Defined Rule #10",
content: "If the user's idea is wrong, incomplete, risky, or non-optimal, say so directly and respectfully; explain why it may fail and provide a better alternative that still achieves the user's goal.",
},
PredefinedRule {
name: "System Defined Rule #11",
content: "Do not hide uncertainty, and do not avoid technical disagreement when correctness is at stake.",
},
];
+102
View File
@@ -47,6 +47,7 @@ use std::fmt::Debug;
use std::path::PathBuf;
use super::{is_edit_allowed, is_syncing, style, AIFact, CloudAIFact, CloudAIFactModel};
use crate::ai::facts::predefined_rules::{PREDEFINED_RULES, SYSTEM_DEFINED_RULE_PREFIX};
use crate::ai::facts::AIMemory;
pub const HEADER_TEXT: &str = "Rules";
@@ -80,6 +81,7 @@ pub enum RuleViewEvent {
#[derive(Debug, Clone)]
pub enum RuleViewAction {
AddRule,
AddPredefinedRules,
InitializeProject,
Edit(SyncId),
OpenSettings,
@@ -151,6 +153,7 @@ pub struct RuleView {
search_editor: ViewHandle<EditorView>,
search_bar: ViewHandle<SearchBar>,
add_button: ViewHandle<ActionButton>,
add_predefined_rules_button: ViewHandle<ActionButton>,
initialize_button: ViewHandle<ActionButton>,
disabled_banner_highlight_index: HighlightedHyperlink,
current_scope: RuleScope,
@@ -265,12 +268,44 @@ impl RuleView {
.on_click(|ctx| ctx.dispatch_typed_action(RuleViewAction::AddRule))
});
let add_predefined_rules_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Add Predefined Rules", NakedTheme)
.with_icon(Icon::Plus)
.on_click(|ctx| ctx.dispatch_typed_action(RuleViewAction::AddPredefinedRules))
});
let initialize_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Initialize Project", NakedTheme)
.with_icon(Icon::Plus)
.on_click(|ctx| ctx.dispatch_typed_action(RuleViewAction::InitializeProject))
});
// Seed predefined rules on first launch if no global rules exist
if ai_rules.is_empty() && !AISettings::as_ref(ctx).has_seeded_predefined_rules() {
if let Some(owner) = owner {
let update_manager = UpdateManager::handle(ctx);
update_manager.update(ctx, |update_manager, ctx| {
for rule in PREDEFINED_RULES {
let ai_fact = AIFact::Memory(AIMemory {
is_autogenerated: false,
name: Some(rule.name.to_string()),
content: rule.content.to_string(),
suggested_logging_id: None,
});
update_manager.create_ai_fact(
ai_fact,
ClientId::default(),
owner,
ctx,
);
}
});
}
AISettings::handle(ctx).update(ctx, |settings, ctx| {
settings.mark_predefined_rules_seeded(ctx);
});
}
Self {
owner,
global_rules: ai_rules,
@@ -278,6 +313,7 @@ impl RuleView {
search_editor,
search_bar,
add_button,
add_predefined_rules_button,
initialize_button,
disabled_banner_highlight_index: Default::default(),
current_scope: RuleScope::Global,
@@ -418,6 +454,60 @@ impl RuleView {
});
}
pub fn add_predefined_rules(&mut self, ctx: &mut ViewContext<Self>) {
let Some(owner) = self.owner else {
return;
};
// Build a map of existing system-defined rules by name for update detection
let existing_system_rules: std::collections::HashMap<String, (SyncId, Option<Revision>)> =
self.global_rules
.iter()
.filter_map(|row| {
let AIFact::Memory(AIMemory { ref name, .. }) = row.fact.model().string_model;
let name = name.as_deref().unwrap_or_default();
if name.starts_with(SYSTEM_DEFINED_RULE_PREFIX) {
Some((
name.to_string(),
(row.fact.sync_id(), row.fact.metadata().revision.clone()),
))
} else {
None
}
})
.collect();
let update_manager = UpdateManager::handle(ctx);
update_manager.update(ctx, |update_manager, ctx| {
for rule in PREDEFINED_RULES {
let ai_fact = AIFact::Memory(AIMemory {
is_autogenerated: false,
name: Some(rule.name.to_string()),
content: rule.content.to_string(),
suggested_logging_id: None,
});
if let Some((sync_id, revision)) =
existing_system_rules.get(rule.name)
{
update_manager.update_ai_fact(
ai_fact,
*sync_id,
revision.clone(),
ctx,
);
} else {
update_manager.create_ai_fact(
ai_fact,
ClientId::default(),
owner,
ctx,
);
}
}
});
}
fn render_header(&self, appearance: &Appearance) -> Box<dyn Element> {
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
@@ -617,11 +707,20 @@ impl RuleView {
.finish()
}
fn render_add_predefined_rules_button(&self) -> Box<dyn Element> {
Container::new(ChildView::new(&self.add_predefined_rules_button).finish())
.with_margin_left(style::SECTION_MARGIN)
.finish()
}
fn render_search_bar_row(&self, filtered_rules: &[RuleRow]) -> Box<dyn Element> {
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Expanded::new(1., ChildView::new(&self.search_bar).finish()).finish());
if self.current_scope == RuleScope::Global {
row.add_child(self.render_add_predefined_rules_button());
}
if !filtered_rules.is_empty() {
row.add_child(self.render_add_button());
}
@@ -945,6 +1044,9 @@ impl TypedActionView for RuleView {
RuleViewAction::AddRule => {
ctx.emit(RuleViewEvent::AddRule);
}
RuleViewAction::AddPredefinedRules => {
self.add_predefined_rules(ctx);
}
RuleViewAction::Edit(sync_id) => {
ctx.emit(RuleViewEvent::Edit(*sync_id));
}
-1
View File
@@ -604,7 +604,6 @@ impl LLMPreferences {
access_key_id: settings.bedrock_access_key_id.value().clone(),
secret_access_key: settings.bedrock_secret_access_key.value().clone(),
cross_region_inference: *settings.bedrock_cross_region_inference.value(),
fallback_to_warp: *settings.bedrock_fallback_to_warp.value(),
}.with_external_fallbacks();
ctx.spawn(
-1
View File
@@ -16,7 +16,6 @@ pub(crate) mod attachment_utils;
#[cfg(not(target_family = "wasm"))]
pub mod aws_credentials;
#[cfg(not(target_family = "wasm"))]
#[allow(dead_code)]
pub mod bedrock;
pub(crate) mod block_context;
pub(crate) mod blocklist;
+15
View File
@@ -1547,11 +1547,26 @@ impl PaneGroup {
}
});
let raw_cwd = terminal_snapshot.cwd.clone();
let startup_directory = terminal_snapshot
.cwd
.map(PathBuf::from)
.filter(|path| path.is_dir());
log::info!(
"[session-restore] pane=terminal raw_cwd={raw_cwd:?} \
resolved_startup_dir={startup_directory:?} \
conversations_to_restore={} \
active_conversation_id={:?} \
has_shell_launch_data={} \
has_block_list={} is_active={}",
terminal_snapshot.conversation_ids_to_restore.len(),
terminal_snapshot.active_conversation_id,
terminal_snapshot.shell_launch_data.is_some(),
block_list.is_some(),
terminal_snapshot.is_active,
);
// Filter conversation IDs to only include those that have task messages
// and are not entirely passive (ignored suggestions).
// This prevents showing the "Previous session" banner when there's nothing to restore
+27 -4
View File
@@ -418,15 +418,22 @@ impl PaneContent for TerminalPane {
if ambient_model.is_ambient_agent() {
let task_id = ambient_model.task_id();
log::info!(
"[session-save] pane=viewer/ambient task_id={task_id:?}"
);
return LeafContents::AmbientAgent(AmbientAgentPaneSnapshot {
uuid: self.uuid.clone(),
task_id,
});
}
let cwd = view.pwd_if_local(app);
log::info!(
"[session-save] pane=viewer cwd={cwd:?} is_active={is_active}"
);
LeafContents::Terminal(TerminalPaneSnapshot {
uuid: self.uuid.clone(),
cwd: view.pwd_if_local(app),
cwd,
is_active,
is_read_only: false,
shell_launch_data: None,
@@ -441,14 +448,21 @@ impl PaneContent for TerminalPane {
// can be restored via the ambient agent task if one exists.
let task_id = view.model.lock().ambient_agent_task_id();
if task_id.is_some() {
log::info!(
"[session-save] pane=transcript/ambient task_id={task_id:?}"
);
LeafContents::AmbientAgent(AmbientAgentPaneSnapshot {
uuid: self.uuid.clone(),
task_id,
})
} else {
let cwd = view.pwd_if_local(app);
log::info!(
"[session-save] pane=transcript cwd={cwd:?} is_active={is_active}"
);
LeafContents::Terminal(TerminalPaneSnapshot {
uuid: self.uuid.clone(),
cwd: view.pwd_if_local(app),
cwd,
is_active,
is_read_only: false,
shell_launch_data: None,
@@ -468,7 +482,7 @@ impl PaneContent for TerminalPane {
.sync_id();
// Collect all conversation IDs for this terminal view
let conversation_ids_to_restore = BlocklistAIHistoryModel::as_ref(app)
let conversation_ids_to_restore: Vec<_> = BlocklistAIHistoryModel::as_ref(app)
.all_live_conversations_for_terminal_view(self.terminal_view(app).id())
.map(|conversation| conversation.id())
.collect();
@@ -487,9 +501,18 @@ impl PaneContent for TerminalPane {
.active_conversation_id()
});
let cwd = view.pwd_if_local(app);
log::info!(
"[session-save] pane=terminal cwd={cwd:?} is_active={is_active} \
conversations={} active_conversation={active_conversation_id:?} \
has_shell_launch_data={} has_input_config=true",
conversation_ids_to_restore.len(),
view.shell_launch_data_if_local(app).is_some(),
);
LeafContents::Terminal(TerminalPaneSnapshot {
uuid: self.uuid.clone(),
cwd: view.pwd_if_local(app),
cwd,
is_active,
is_read_only: view.model.lock().is_read_only(),
shell_launch_data: view.shell_launch_data_if_local(app),
+76 -2
View File
@@ -141,7 +141,8 @@ const COMMANDS_COUNT_LIMIT: i64 = 10000;
use galaxy_server_client::persistence::{upsert_cloud_object, CloudObjectId};
const WARP_SQLITE_FILE_NAME: &str = "warp.sqlite";
const WARP_SQLITE_FILE_NAME: &str = "galaxy.sqlite";
const LEGACY_SQLITE_FILE_NAME: &str = "warp.sqlite";
/// When delete a cloud object, this callback is used to delete the cloud
/// object. It takes the id of the cloud object to delete as a parameter.
@@ -162,7 +163,14 @@ pub fn initialize(ctx: &mut AppContext) -> (Option<PersistedData>, Option<Writer
Ok(mut conn) => {
let user_uid = AuthStateProvider::as_ref(ctx).get().user_id();
let app_state = match read_sqlite_data(&mut conn, user_uid) {
Ok(app_state) => Some(app_state),
Ok(app_state) => {
log::info!(
"[session-restore] read_sqlite_data: windows={} conversations={}",
app_state.app_state.windows.len(),
app_state.multi_agent_conversations.len(),
);
Some(app_state)
}
Err(err) => {
send_telemetry_from_app_ctx!(
TelemetryEvent::DatabaseReadError(err.to_string()),
@@ -335,6 +343,11 @@ pub(super) fn init_db() -> Result<SqliteConnection> {
// First, make sure the parent directory of the file exists, otherwise
// we'll get an error if the file doesn't already exist.
let db_path = database_file_path();
log::info!(
"[session-restore] init_db: target={} exists={}",
db_path.display(),
db_path.exists()
);
// If we fail to create the necessary directories, log a warning and
// continue; we'll return a sqlite error if it actually fails to initialize
// a database connection.
@@ -348,6 +361,55 @@ pub(super) fn init_db() -> Result<SqliteConnection> {
);
}
// Migrate from legacy "warp.sqlite" filename to "galaxy.sqlite" (same directory).
if !db_path.exists() {
let legacy_same_dir = db_path
.parent()
.expect("database file path should be absolute")
.join(LEGACY_SQLITE_FILE_NAME);
if legacy_same_dir.exists() {
match std::fs::rename(&legacy_same_dir, &db_path) {
Ok(_) => {
log::info!("Migrated legacy warp.sqlite to galaxy.sqlite");
let old_wal = legacy_same_dir.with_extension("sqlite-wal");
let old_shm = legacy_same_dir.with_extension("sqlite-shm");
let new_wal = db_path.with_extension("sqlite-wal");
let new_shm = db_path.with_extension("sqlite-shm");
let _ = std::fs::rename(&old_wal, &new_wal);
let _ = std::fs::rename(&old_shm, &new_shm);
}
Err(err) => {
log::warn!("Failed to migrate legacy warp.sqlite: {err:#}");
}
}
}
}
// Migrate from legacy "warp.sqlite" in state_dir (handles users who never
// got the in-place rename above because they were on the old app ID path).
if !db_path.exists() {
let legacy_state_dir = galaxy_core::paths::state_dir().join(LEGACY_SQLITE_FILE_NAME);
if legacy_state_dir.exists() {
match std::fs::rename(&legacy_state_dir, &db_path) {
Ok(_) => {
log::info!(
"Migrated legacy warp.sqlite from state_dir to {}",
db_path.display()
);
let old_wal = legacy_state_dir.with_extension("sqlite-wal");
let old_shm = legacy_state_dir.with_extension("sqlite-shm");
let new_wal = db_path.with_extension("sqlite-wal");
let new_shm = db_path.with_extension("sqlite-shm");
let _ = std::fs::rename(&old_wal, &new_wal);
let _ = std::fs::rename(&old_shm, &new_shm);
}
Err(err) => {
log::warn!("Failed to migrate legacy warp.sqlite from state_dir: {err:#}");
}
}
}
}
// Migrate old SQLite files into the secure application container.
let old_db_path = galaxy_core::paths::state_dir().join(WARP_SQLITE_FILE_NAME);
if old_db_path != db_path && old_db_path.exists() && !db_path.exists() {
@@ -2409,6 +2471,18 @@ fn read_node(conn: &mut SqliteConnection, node: model::PaneNode) -> Result<PaneN
.active_conversation_id
.and_then(|id_str| AIConversationId::try_from(id_str).ok());
log::info!(
"[session-db-read] terminal_pane: cwd={:?} is_active={} \
conversations={} active_conversation={:?} \
has_shell_launch_data={} has_input_config={}",
terminal_pane.cwd,
terminal_pane.is_active,
conversation_ids_to_restore.len(),
active_conversation_id,
shell_launch_data.is_some(),
input_config.is_some(),
);
LeafContents::Terminal(TerminalPaneSnapshot {
uuid: terminal_pane.uuid,
cwd: terminal_pane.cwd,
+16
View File
@@ -770,7 +770,23 @@ fn open_from_restored(arg: &OpenFromRestoredArg, ctx: &mut AppContext) {
};
// Check whether user has enabled session restoration.
if !*GeneralSettings::as_ref(ctx).restore_session {
log::info!("[session-restore] restore_session setting is DISABLED, skipping");
}
if *GeneralSettings::as_ref(ctx).restore_session {
log::info!(
"[session-restore] restoring {} window(s), active_window_index={:?}",
app_state.windows.len(),
app_state.active_window_index,
);
for (i, w) in app_state.windows.iter().enumerate() {
log::info!(
"[session-restore] window[{i}]: tabs={} active_tab={} quake={}",
w.tabs.len(),
w.active_tab_index,
w.quake_mode,
);
}
let mut active_index = None;
let mut normal_window_count = 0;
for (idx, window) in app_state.windows.iter().enumerate() {
+19 -10
View File
@@ -1110,16 +1110,6 @@ define_settings_group!(AISettings, settings: [
toml_path: "ai.bedrock.cross_region_inference",
description: "Whether to automatically add cross-region inference prefixes to model IDs.",
}
// Whether to fall back to routing through the Warp server when Bedrock credentials fail.
bedrock_fallback_to_warp: BedrockFallbackToWarp {
type: bool,
default: false,
supported_platforms: SupportedPlatforms::DESKTOP,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: false,
toml_path: "ai.bedrock.fallback_to_warp",
description: "Whether to fall back to the Galaxy server when Bedrock credentials are invalid.",
}
// Custom Bedrock model configurations.
bedrock_models: BedrockModels {
type: Vec<BedrockModelConfig>,
@@ -1528,6 +1518,17 @@ define_settings_group!(AISettings, settings: [
toml_path: "agents.warp_agent.other.agent_attribution_enabled",
description: "Whether the Galaxy Agent adds an attribution co-author line to commit messages and pull requests it creates.",
}
// Tracks whether predefined system rules have been seeded on first launch.
// Once set to true, predefined rules will not be auto-created again even if the user
// deletes all their rules.
has_seeded_predefined_rules: HasSeededPredefinedRules {
type: bool,
default: false,
supported_platforms: SupportedPlatforms::ALL,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::No),
private: true,
}
]);
impl AISettings {
@@ -1992,6 +1993,14 @@ impl AISettings {
.plugin_update_chip_dismissed_for_version_map
.set_value(map, ctx));
}
pub fn has_seeded_predefined_rules(&self) -> bool {
*self.has_seeded_predefined_rules
}
pub fn mark_predefined_rules_seeded(&mut self, ctx: &mut ModelContext<Self>) {
report_if_error!(self.has_seeded_predefined_rules.set_value(true, ctx));
}
}
/// Singleton model that caches compiled regexes for the `cli_agent_footer_enabled_commands`
-8
View File
@@ -2081,7 +2081,6 @@ pub enum AISettingsPageAction {
SetBedrockAuthMethod(BedrockAuthMethod),
SetBedrockProfile(String),
ToggleBedrockCrossRegionInference,
ToggleBedrockFallbackToWarp,
ToggleFileBasedMcp,
ToggleIncludeAgentCommandsInHistory,
ToggleAgentAttribution,
@@ -2742,7 +2741,6 @@ impl TypedActionView for AISettingsPageView {
access_key_id: ai_settings.bedrock_access_key_id.value().clone(),
secret_access_key: ai_settings.bedrock_secret_access_key.value().clone(),
cross_region_inference: *ai_settings.bedrock_cross_region_inference.value(),
fallback_to_warp: *ai_settings.bedrock_fallback_to_warp.value(),
}.with_external_fallbacks();
ctx.spawn(
@@ -2781,12 +2779,6 @@ impl TypedActionView for AISettingsPageView {
});
ctx.notify();
}
AISettingsPageAction::ToggleBedrockFallbackToWarp => {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings.bedrock_fallback_to_warp.toggle_and_save_value(ctx));
});
ctx.notify();
}
AISettingsPageAction::ToggleFileBasedMcp => {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings.file_based_mcp_enabled.toggle_and_save_value(ctx));
+1 -1
View File
@@ -808,7 +808,7 @@ fn build_docker_sandbox_command(
let sentinel_value = "57265949261";
builder.env("HISTFILESIZE", sentinel_value);
builder.env("GALAXY_INITIAL_HISTFILESIZE", sentinel_value);
// Intentionally do NOT set `WARP_INITIAL_WORKING_DIR` for sandboxes:
// Intentionally do NOT set `GALAXY_INITIAL_WORKING_DIR` for sandboxes:
// the container's init script cds into the sandbox home dir, not
// the host's startup dir.
+14 -5
View File
@@ -5636,8 +5636,6 @@ impl TerminalView {
let estimated_cost_cents: f32 = token_usage_list.iter().map(|u| u.cost_in_cents).sum();
let conversation_usage_info = ConversationUsageInfo {
credits_spent: conversation.credits_spent(),
credits_spent_for_last_block: conversation.credits_spent_for_last_block(),
tool_calls: tool_usage.total_tool_calls(),
models: conversation.token_usage().to_vec(),
context_window_usage: conversation.context_window_usage(),
@@ -6536,11 +6534,22 @@ impl TerminalView {
.and_then(|data| data.maybe_convert_absolute_path(cwd))
})
})
// Checking if the pwd from the active session actually exists
// and if not (ie. directory was removed) - return None.
.filter(|path| path.is_dir())
// Fall back to the shell's startup directory when no block CWD
// is available (e.g. agent mode sessions or freshly opened tabs).
.or_else(|| {
self.model
.lock()
.session_startup_path()
.filter(|path| path.is_dir())
})
} else {
None
// For non-local sessions (viewers, transcript viewers), still try
// the startup path so we can persist where the pane was opened.
self.model
.lock()
.session_startup_path()
.filter(|path| path.is_dir())
}
}
-3
View File
@@ -248,15 +248,12 @@ impl From<GqlUgcCollectionEnablementSetting> for UgcCollectionEnablementSetting
impl From<&gql_usage::ConversationUsage> for ConversationUsageInfo {
fn from(gql: &gql_usage::ConversationUsage) -> Self {
let persistence::model::ConversationUsageMetadata {
credits_spent,
token_usage: models,
tool_usage_metadata: tool,
context_window_usage,
..
} = (&gql.usage_metadata).into();
ConversationUsageInfo {
credits_spent,
credits_spent_for_last_block: None,
tool_calls: tool.total_tool_calls(),
models,
context_window_usage,