Fix Local Agent Execution And Auth Checks

- Gate server requests on available credentials
- Run local child agents directly without a parent run ID
- Include command IDs in Bedrock context and recognize transfer tools
This commit is contained in:
2026-07-28 10:43:59 -05:00
parent 87e0c83e9e
commit a078287f4b
35 changed files with 1078 additions and 237 deletions
+350 -60
View File
@@ -90,6 +90,12 @@ pub fn extract_new_input_messages(request: &api::Request) -> Vec<ConversationMes
let mut context =
format!("[Running command: {}]\n", running_cmd.command);
if let Some(snapshot) = &running_cmd.snapshot {
if !snapshot.command_id.is_empty() {
context.push_str(&format!(
"[Command ID: {}]\n",
snapshot.command_id
));
}
if !snapshot.output.is_empty() {
context.push_str(&format!(
"[Terminal output:\n{}\n]\n",
@@ -588,17 +594,16 @@ fn extract_input_messages(request: &api::Request) -> Vec<api::Message> {
)
};
let message_user_query =
invoke_skill.user_query.as_ref().map(|input_query| {
api::message::UserQuery {
invoke_skill
.user_query
.as_ref()
.map(|input_query| api::message::UserQuery {
query: input_query.query.clone(),
context: None,
referenced_attachments: input_query
.referenced_attachments
.clone(),
referenced_attachments: input_query.referenced_attachments.clone(),
mode: input_query.mode,
intended_agent: input_query.intended_agent,
}
});
});
results.push(api::Message {
id: uuid::Uuid::new_v4().to_string(),
task_id: task_id.clone(),
@@ -1062,10 +1067,83 @@ fn collect_tool_result_ids(content: &MessageContent, ids: &mut std::collections:
}
}
pub fn extract_system_prompt(request: &api::Request, global_rules: &[(String, String)]) -> Option<String> {
let mut prompt = String::with_capacity(2048);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum AgentMode {
Normal,
Plan,
Orchestrate,
Cli,
}
prompt.push_str("You are Galaxy, an AI coding assistant embedded in a terminal application. You help users with software engineering tasks including writing code, debugging, explaining concepts, and navigating codebases.\n\n");
fn request_agent_mode(request: &api::Request) -> AgentMode {
let Some(api::request::Input {
r#type: Some(api::request::input::Type::UserInputs(user_inputs)),
..
}) = &request.input
else {
return AgentMode::Normal;
};
let mut mode = AgentMode::Normal;
for user_input in &user_inputs.inputs {
match &user_input.input {
Some(api::request::input::user_inputs::user_input::Input::CliAgentUserQuery(_)) => {
return AgentMode::Cli;
}
Some(api::request::input::user_inputs::user_input::Input::UserQuery(query)) => {
match query.mode.as_ref().and_then(|mode| mode.r#type.as_ref()) {
Some(api::user_query_mode::Type::Plan(())) => mode = AgentMode::Plan,
Some(api::user_query_mode::Type::Orchestrate(())) => {
mode = AgentMode::Orchestrate
}
None => {}
}
}
Some(api::request::input::user_inputs::user_input::Input::ToolCallResult(result))
if tool_result_is_cli_command(result) =>
{
return AgentMode::Cli
}
_ => {}
}
}
mode
}
fn tool_result_is_cli_command(result: &api::request::input::ToolCallResult) -> bool {
use api::request::input::tool_call_result::Result;
match &result.result {
Some(Result::RunShellCommand(result)) => matches!(
result.result,
Some(api::run_shell_command_result::Result::LongRunningCommandSnapshot(_))
),
Some(
Result::WriteToLongRunningShellCommand(_)
| Result::ReadShellCommandOutput(_)
| Result::TransferShellCommandControlToUser(_),
) => true,
_ => false,
}
}
pub fn extract_system_prompt(
request: &api::Request,
global_rules: &[(String, String)],
) -> Option<String> {
let mode = request_agent_mode(request);
let tool_names = extract_tools(request)
.into_iter()
.map(|tool| tool.name)
.collect::<Vec<_>>();
let mut prompt = String::with_capacity(4096);
prompt.push_str(
"You are Galaxy, an AI software-engineering and terminal agent embedded in the user's \
terminal. Your job is to complete the user's task, not merely describe how they could \
complete it. You are especially capable at inspecting codebases, editing files, running \
commands, diagnosing failures, and validating changes.\n\n",
);
if let Some(input) = &request.input {
if let Some(context) = &input.context {
@@ -1120,7 +1198,9 @@ pub fn extract_system_prompt(request: &api::Request, global_rules: &[(String, St
// Inject global rules from the local CloudModel (stored as AIFact/AIMemory)
if !global_rules.is_empty() {
prompt.push_str("## Global Rules\n");
prompt.push_str("The following rules have been configured by the user and should be followed:\n\n");
prompt.push_str(
"The following rules have been configured by the user and should be followed:\n\n",
);
for (name, content) in global_rules {
if !name.is_empty() {
prompt.push_str(&format!("### {}\n", name));
@@ -1130,28 +1210,119 @@ pub fn extract_system_prompt(request: &api::Request, global_rules: &[(String, St
}
}
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("## Operating Contract\n");
prompt.push_str(
"- Use ONLY the tools in your tool configuration. Never invent or guess tool names.\n",
"- If the user asks for a change, carry it through inspection, implementation, and \
proportionate validation. Do not stop after proposing a plan unless the request is in \
plan mode or user input is genuinely required.\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("- 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");
prompt.push_str(
"- Inspect relevant files and current state before making claims. Preserve unrelated user \
changes and make the smallest coherent change that solves the problem.\n",
);
prompt.push_str(
"- Prefer non-interactive commands. Disable pagers (`--no-pager`, `PAGER=cat`, or the \
tool-specific equivalent) and avoid commands that wait for an editor or prompt unless \
interaction is intentional.\n",
);
prompt.push_str(
"- Treat tool results as evidence. Check exit codes and output, fix failures when they are \
in scope, and never claim a build, test, command, or edit succeeded without a successful \
result.\n",
);
prompt.push_str(
"- Ask the user only when a missing choice materially changes the result or when an action \
requires authority you do not have. Otherwise make a reasonable, scoped assumption and \
continue.\n",
);
prompt.push_str(
"- Avoid destructive or irreversible commands unless they are clearly requested and their \
target is verified. Never overwrite unrelated work.\n",
);
prompt.push_str("- Keep progress and final responses concise, concrete, and honest.\n\n");
match mode {
AgentMode::Normal => {}
AgentMode::Plan => {
prompt.push_str("## Plan Mode\n");
prompt.push_str(
"Analyze and produce an implementation-ready plan. You may inspect files and run \
read-only commands, but do not edit files, install dependencies, or run commands \
that change external state. Resolve as much uncertainty as possible through \
inspection before presenting the plan.\n\n",
);
}
AgentMode::Orchestrate => {
prompt.push_str("## Orchestration Mode\n");
prompt.push_str(
"Coordinate independent work when delegation materially reduces latency or improves \
coverage. Give each child agent a bounded task and synthesize its result. Do not \
delegate trivial work or work that depends on unfinished local context.\n\n",
);
}
AgentMode::Cli => {
prompt.push_str("## Running Command Monitor\n");
prompt.push_str(
"This turn concerns a running or just-finished shell command. Act as its dedicated \
monitor while still following the user's steering messages. Use the command ID from \
the running-command context or tool result for every read/write operation. If the \
result says the command finished, report its outcome and stop polling. Otherwise, \
poll with `read_shell_command_output`; use a short delay for active progress and \
`wait_until_complete` only when no intervention is expected. Use \
`write_to_long_running_shell_command` only when the process needs input. Never start \
a duplicate command merely to check its state, and never report completion while a \
result says it is still running. If user interaction is the right next step and the \
transfer tool is available, transfer control with a clear reason.\n\n",
);
}
}
prompt.push_str("## Available Tools\n");
if tool_names.is_empty() {
prompt.push_str("No tools are available for this request. Do not invent tool calls.\n\n");
} else {
prompt.push_str(&format!(
"Use only these tools: {}.\n\n",
tool_names.join(", ")
));
}
let has_tool = |name: &str| tool_names.iter().any(|tool_name| tool_name == name);
if [
"read_files",
"apply_file_diffs",
"grep",
"file_glob",
"search_codebase",
]
.into_iter()
.any(has_tool)
{
prompt.push_str(
"- Use filesystem/search tools to understand the codebase and focused diff tools to edit \
it. Use paths rooted in the working directory and absolute paths when a schema requires \
them.\n",
);
}
if has_tool("run_shell_command") {
prompt.push_str(
"- Use `run_shell_command` for builds, tests, package managers, git, and terminal work. \
Set `is_read_only` accurately, set `uses_pager=false`, and set \
`wait_until_complete=false` for commands that may run longer than a few seconds so they \
can be monitored asynchronously.\n",
);
}
if !tool_names.is_empty() {
prompt.push_str(
"- Batch independent reads and searches when the tool schema allows it, but do not hide \
important intermediate failures inside a large opaque script.\n",
);
}
prompt.push_str("- Never invent tool names or parameters.\n");
Some(prompt)
}
pub fn extract_tools(request: &api::Request) -> Vec<ToolDefinition> {
// 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();
@@ -1202,30 +1373,72 @@ pub fn extract_tools(request: &api::Request) -> Vec<ToolDefinition> {
}
}
// 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.
// Filter out start_agent/send_message_to_agent — sub-agents are disabled.
tools.retain(|t| {
t.name != "suggest_next_prompt"
&& t.name != "start_agent"
&& t.name != "send_message_to_agent"
});
// Only advertise tools the client reported for this request. CLI-agent turns
// use their narrower capability list so the command monitor stays focused.
if let Some(supported_tools) = supported_tool_types(request) {
tools.retain(|tool| tool_name_is_supported(&tool.name, &supported_tools));
}
tools
}
fn supported_tool_types(request: &api::Request) -> Option<HashSet<api::ToolType>> {
let settings = request.settings.as_ref()?;
let raw_tools = if request_agent_mode(request) == AgentMode::Cli {
&settings.supported_cli_agent_tools
} else {
&settings.supported_tools
};
Some(
raw_tools
.iter()
.filter_map(|value| api::ToolType::try_from(*value).ok())
.collect(),
)
}
fn tool_name_is_supported(name: &str, supported: &HashSet<api::ToolType>) -> bool {
use api::ToolType;
let has = |tool| supported.contains(&tool);
match name {
"run_shell_command" => has(ToolType::RunShellCommand),
"read_files" => has(ToolType::ReadFiles),
"apply_file_diffs" => has(ToolType::ApplyFileDiffs),
"grep" => has(ToolType::Grep),
"file_glob" => has(ToolType::FileGlob) || has(ToolType::FileGlobV2),
"search_codebase" => has(ToolType::SearchCodebase),
"write_to_long_running_shell_command" => has(ToolType::WriteToLongRunningShellCommand),
"read_shell_command_output" => has(ToolType::ReadShellCommandOutput),
"transfer_shell_command_control_to_user" => {
has(ToolType::TransferShellCommandControlToUser)
}
"read_mcp_resource" => has(ToolType::ReadMcpResource),
name if name.starts_with("mcp__") => has(ToolType::CallMcpTool),
"read_plan" | "read_notebook" => has(ToolType::ReadDocuments),
"create_plan" | "create_notebook" => has(ToolType::CreateDocuments),
"edit_plan" | "edit_notebook" => has(ToolType::EditDocuments),
"start_agent" => has(ToolType::Subagent) || has(ToolType::StartAgentV2),
"ask_user_question" => has(ToolType::AskUserQuestion),
"read_skill" => has(ToolType::ReadSkill),
"fetch_conversation" => has(ToolType::FetchConversation),
_ => false,
}
}
pub fn default_tool_definitions() -> Vec<ToolDefinition> {
vec![
ToolDefinition {
name: "run_shell_command".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(),
description: "Execute a shell command in the user's terminal and return its output. Use for builds, tests, git, package managers, and other shell work. Commands run in the user's actual shell and environment. Set wait_until_complete=false for commands that might run longer than a few seconds; Galaxy will return a command_id that can be monitored with read_shell_command_output.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"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)" }
"is_risky": { "type": "boolean", "description": "True if command is destructive or irreversible (rm -rf, git push --force)" },
"uses_pager": { "type": "boolean", "default": false, "description": "True only when the command intentionally launches a pager. Prefer false." },
"wait_until_complete": { "type": "boolean", "default": false, "description": "Whether to wait for the process to exit. Defaults to false so builds, servers, watchers, and other potentially long-running commands can be monitored asynchronously." }
},
"required": ["command"]
}),
@@ -1291,22 +1504,39 @@ pub fn default_tool_definitions() -> Vec<ToolDefinition> {
},
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(),
description: "Send input to a currently running shell command identified by command_id. Use only when the command is waiting for input, such as an interactive prompt or REPL.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"input": { "type": "string", "description": "Text to send as stdin to the running command" }
"command_id": { "type": "string", "description": "Command ID returned by a long-running command result" },
"input": { "type": "string", "description": "Text to send to the running command" },
"mode": { "type": "string", "enum": ["raw", "line", "block"], "default": "raw", "description": "raw sends exact bytes; line submits one line with Enter; block pastes multiline input" }
},
"required": ["input"]
"required": ["command_id", "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(),
description: "Read output from a previously started long-running shell command identified by command_id. Use wait_seconds for a timed poll, or wait_until_complete=true only when no intervention is expected.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
"properties": {
"command_id": { "type": "string", "description": "Command ID returned by a long-running command result" },
"wait_seconds": { "type": "integer", "minimum": 0, "maximum": 120, "default": 2, "description": "Seconds to wait before returning a fresh snapshot; defaults to 2" },
"wait_until_complete": { "type": "boolean", "description": "Wait until the command exits instead of returning a timed snapshot" }
},
"required": ["command_id"]
}),
},
ToolDefinition {
name: "transfer_shell_command_control_to_user".to_string(),
description: "Transfer control of the current long-running shell command to the user when manual interaction is needed. Explain why control is being transferred.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"reason": { "type": "string", "description": "Concise explanation of what the user needs to do" }
},
"required": ["reason"]
}),
},
ToolDefinition {
@@ -1450,17 +1680,11 @@ fn extract_tool_result_content(result: &api::request::input::ToolCallResult) ->
api::request::input::tool_call_result::Result::RunShellCommand(cmd_result) => {
match &cmd_result.result {
Some(api::run_shell_command_result::Result::CommandFinished(finished)) => {
let content = if finished.output.is_empty() {
format!("Exit code: {}\n(no output)", finished.exit_code)
} else {
format!("Exit code: {}\n{}", finished.exit_code, finished.output)
};
let is_error = finished.exit_code != 0;
(content, is_error)
command_finished_content(finished)
}
Some(api::run_shell_command_result::Result::LongRunningCommandSnapshot(
snapshot,
)) => (snapshot.output.clone(), false),
)) => (long_running_command_content(snapshot), false),
Some(api::run_shell_command_result::Result::PermissionDenied(denied)) => {
let reason = match &denied.reason {
Some(api::permission_denied::Reason::DenylistedCommand(())) => {
@@ -1643,24 +1867,58 @@ fn extract_tool_result_content(result: &api::request::input::ToolCallResult) ->
api::write_to_long_running_shell_command_result::Result::LongRunningCommandSnapshot(
snapshot,
),
) => (snapshot.output.clone(), false),
) => (long_running_command_content(snapshot), false),
Some(
api::write_to_long_running_shell_command_result::Result::CommandFinished(
finished,
),
) => {
let content = format!(
"Exit code: {}\n{}",
finished.exit_code, finished.output
);
let is_error = finished.exit_code != 0;
(content, is_error)
}
) => command_finished_content(finished),
Some(api::write_to_long_running_shell_command_result::Result::Error(_)) => {
("Error: shell command not found.".to_string(), true)
}
None => ("Write to shell command completed.".to_string(), false),
},
api::request::input::tool_call_result::Result::ReadShellCommandOutput(read_result) => {
match &read_result.result {
Some(
api::read_shell_command_output_result::Result::LongRunningCommandSnapshot(
snapshot,
),
) => (long_running_command_content(snapshot), false),
Some(api::read_shell_command_output_result::Result::CommandFinished(
finished,
)) => command_finished_content(finished),
Some(api::read_shell_command_output_result::Result::Error(_)) => {
("Error: shell command not found.".to_string(), true)
}
None => ("Read shell command output completed.".to_string(), false),
}
}
api::request::input::tool_call_result::Result::TransferShellCommandControlToUser(
transfer_result,
) => match &transfer_result.result {
Some(
api::transfer_shell_command_control_to_user_result::Result::LongRunningCommandSnapshot(
snapshot,
),
) => {
let mut content = long_running_command_content(snapshot);
content.push_str(
"\nControl has been transferred to the user. Do not write to the command \
until control is returned.",
);
(content, false)
}
Some(
api::transfer_shell_command_control_to_user_result::Result::CommandFinished(
finished,
),
) => command_finished_content(finished),
Some(api::transfer_shell_command_control_to_user_result::Result::Error(_)) => {
("Error: shell command not found.".to_string(), true)
}
None => ("Shell command control transferred to the user.".to_string(), false),
},
api::request::input::tool_call_result::Result::StartAgent(start_agent_result) => {
match &start_agent_result.result {
Some(api::start_agent_result::Result::Success(success)) => {
@@ -1788,6 +2046,38 @@ fn extract_tool_result_content(result: &api::request::input::ToolCallResult) ->
}
}
fn command_finished_content(finished: &api::ShellCommandFinished) -> (String, bool) {
let mut content = String::new();
if !finished.command_id.is_empty() {
content.push_str(&format!("Command ID: {}\n", finished.command_id));
}
content.push_str(&format!(
"Command finished with exit code {}.",
finished.exit_code
));
if finished.output.is_empty() {
content.push_str("\n(no output)");
} else {
content.push_str(&format!("\nOutput:\n{}", finished.output));
}
(content, finished.exit_code != 0)
}
fn long_running_command_content(snapshot: &api::LongRunningShellCommandSnapshot) -> String {
let output = if snapshot.output.is_empty() {
"(no output yet)"
} else {
&snapshot.output
};
format!(
"Command is still running.\nCommand ID: {}\nCurrent terminal output:\n{}\n\
Continue monitoring with `read_shell_command_output` using command_id `{}`. \
Use `write_to_long_running_shell_command` with the same command_id only if input is \
required. Do not report the command as complete while it is still running.",
snapshot.command_id, output, snapshot.command_id
)
}
#[cfg(test)]
pub fn extract_messages_from_request(request: &api::Request) -> Vec<ConversationMessage> {
let mut messages = Vec::new();
+169 -1
View File
@@ -1,6 +1,9 @@
use serde_json::json;
use warp_multi_agent_api as api;
use super::sanitize_messages_for_bedrock;
use super::{
extract_new_input_messages, extract_system_prompt, extract_tools, sanitize_messages_for_bedrock,
};
use crate::ai::bedrock::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole};
#[test]
@@ -45,3 +48,168 @@ fn test_sanitize_messages_prepends_synthetic_tool_result_before_existing_user_te
ContentPart::Text(text) if text == &existing_user_text
));
}
#[test]
fn advertised_tools_follow_client_capabilities_and_include_local_subagents() {
let request = api::Request {
settings: Some(api::request::Settings {
supported_tools: vec![
api::ToolType::RunShellCommand.into(),
api::ToolType::ReadFiles.into(),
api::ToolType::Subagent.into(),
],
..Default::default()
}),
..Default::default()
};
let names = extract_tools(&request)
.into_iter()
.map(|tool| tool.name)
.collect::<Vec<_>>();
assert_eq!(
names,
vec!["run_shell_command", "read_files", "start_agent"]
);
}
#[test]
fn plan_mode_prompt_prohibits_mutation() {
let request = api::Request {
input: Some(api::request::Input {
r#type: Some(api::request::input::Type::UserInputs(
api::request::input::UserInputs {
inputs: vec![api::request::input::user_inputs::UserInput {
input: Some(
api::request::input::user_inputs::user_input::Input::UserQuery(
api::request::input::UserQuery {
query: "plan the change".to_string(),
mode: Some(api::UserQueryMode {
r#type: Some(api::user_query_mode::Type::Plan(())),
}),
..Default::default()
},
),
),
}],
},
)),
..Default::default()
}),
..Default::default()
};
let prompt = extract_system_prompt(&request, &[]).unwrap();
assert!(prompt.contains("## Plan Mode"));
assert!(prompt.contains("do not edit files"));
}
#[test]
fn running_command_turn_gets_monitor_prompt_and_cli_tools() {
let request = api::Request {
input: Some(api::request::Input {
r#type: Some(api::request::input::Type::UserInputs(
api::request::input::UserInputs {
inputs: vec![api::request::input::user_inputs::UserInput {
input: Some(
api::request::input::user_inputs::user_input::Input::CliAgentUserQuery(
api::request::input::CliAgentUserQuery {
user_query: Some(api::request::input::UserQuery {
query: "monitor this".to_string(),
..Default::default()
}),
running_command: Some(api::RunningShellCommand {
command: "cargo test".to_string(),
snapshot: Some(api::LongRunningShellCommandSnapshot {
command_id: "block-123".to_string(),
..Default::default()
}),
}),
..Default::default()
},
),
),
}],
},
)),
..Default::default()
}),
settings: Some(api::request::Settings {
supported_tools: vec![api::ToolType::RunShellCommand.into()],
supported_cli_agent_tools: vec![
api::ToolType::WriteToLongRunningShellCommand.into(),
api::ToolType::ReadShellCommandOutput.into(),
api::ToolType::TransferShellCommandControlToUser.into(),
],
..Default::default()
}),
..Default::default()
};
let names = extract_tools(&request)
.into_iter()
.map(|tool| tool.name)
.collect::<Vec<_>>();
assert_eq!(
names,
vec![
"write_to_long_running_shell_command",
"read_shell_command_output",
"transfer_shell_command_control_to_user",
]
);
let prompt = extract_system_prompt(&request, &[]).unwrap();
assert!(prompt.contains("## Running Command Monitor"));
assert!(prompt.contains("command ID"));
assert!(prompt.contains("read_shell_command_output"));
assert!(!prompt.contains("- Use `run_shell_command`"));
}
#[test]
fn long_running_tool_result_preserves_command_id() {
let request = api::Request {
input: Some(api::request::Input {
r#type: Some(api::request::input::Type::UserInputs(
api::request::input::UserInputs {
inputs: vec![api::request::input::user_inputs::UserInput {
input: Some(
api::request::input::user_inputs::user_input::Input::ToolCallResult(
api::request::input::ToolCallResult {
tool_call_id: "tool-1".to_string(),
result: Some(
api::request::input::tool_call_result::Result::RunShellCommand(
api::RunShellCommandResult {
command: "cargo test".to_string(),
result: Some(
api::run_shell_command_result::Result::LongRunningCommandSnapshot(
api::LongRunningShellCommandSnapshot {
command_id: "block-456".to_string(),
output: "running 42 tests".to_string(),
..Default::default()
},
),
),
..Default::default()
},
),
),
},
),
),
}],
},
)),
..Default::default()
}),
..Default::default()
};
let messages = extract_new_input_messages(&request);
let MessageContent::ToolResult { content, .. } = &messages[0].content else {
panic!("expected tool result");
};
assert!(content.contains("Command ID: block-456"));
assert!(content.contains("running 42 tests"));
assert!(content.contains("read_shell_command_output"));
}
+94 -17
View File
@@ -273,7 +273,7 @@ pub fn bedrock_stream_to_response_events(
current_tool_name, current_tool_use_id
);
let error_text = 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.",
"Error: '{}' is not a valid tool for this request. Use one of the tools in the current tool configuration; do not invent tool names.",
current_tool_name
);
let input_json: serde_json::Value = serde_json::from_str(&current_tool_input_json)
@@ -846,15 +846,36 @@ pub fn build_tool_call_message(
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let is_read_only = input
.get("is_read_only")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let uses_pager = input
.get("uses_pager")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_risky = input
.get("is_risky")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let wait_until_complete = input
.get("wait_until_complete")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let wait_until_complete_value = Some(
api::message::tool_call::run_shell_command::WaitUntilCompleteValue::WaitUntilComplete(
wait_until_complete,
),
);
Some(api::message::tool_call::Tool::RunShellCommand(
api::message::tool_call::RunShellCommand {
command,
is_read_only: false,
uses_pager: true,
is_read_only,
uses_pager,
citations: vec![],
is_risky: false,
is_risky,
risk_category: 0,
wait_until_complete_value: None,
wait_until_complete_value,
},
))
}
@@ -944,12 +965,14 @@ pub fn build_tool_call_message(
.collect()
})
.unwrap_or_default();
let path = input
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
#[allow(deprecated)]
Some(api::message::tool_call::Tool::FileGlob(
api::message::tool_call::FileGlob {
patterns,
path: String::new(),
},
api::message::tool_call::FileGlob { patterns, path },
))
}
"search_codebase" => {
@@ -972,27 +995,80 @@ pub fn build_tool_call_message(
))
}
"write_to_long_running_shell_command" => {
use api::message::tool_call::write_to_long_running_shell_command::mode::Mode;
let text_input = input
.get("input")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let command_id = input
.get("command_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let mode = input.get("mode").and_then(|v| v.as_str()).map(|mode| {
api::message::tool_call::write_to_long_running_shell_command::Mode {
mode: Some(match mode {
"line" => Mode::Line(()),
"block" => Mode::Block(()),
_ => Mode::Raw(()),
}),
}
});
Some(
api::message::tool_call::Tool::WriteToLongRunningShellCommand(
api::message::tool_call::WriteToLongRunningShellCommand {
input: text_input.into_bytes(),
mode: None,
command_id: String::new(),
mode,
command_id,
},
),
)
}
"read_shell_command_output" => Some(api::message::tool_call::Tool::ReadShellCommandOutput(
api::message::tool_call::ReadShellCommandOutput {
command_id: String::new(),
delay: None,
},
)),
"read_shell_command_output" => {
let command_id = input
.get("command_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let delay = if input
.get("wait_until_complete")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
Some(api::message::tool_call::read_shell_command_output::Delay::OnCompletion(()))
} else {
let seconds = input
.get("wait_seconds")
.and_then(|v| v.as_u64())
.unwrap_or(2)
.min(120);
Some(
api::message::tool_call::read_shell_command_output::Delay::Duration(
prost_types::Duration {
seconds: seconds as i64,
nanos: 0,
},
),
)
};
Some(api::message::tool_call::Tool::ReadShellCommandOutput(
api::message::tool_call::ReadShellCommandOutput { command_id, delay },
))
}
"transfer_shell_command_control_to_user" => {
let reason = input
.get("reason")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Some(
api::message::tool_call::Tool::TransferShellCommandControlToUser(
api::message::tool_call::TransferShellCommandControlToUser { reason },
),
)
}
"read_mcp_resource" => {
let server_id = input
.get("server_id")
@@ -1294,6 +1370,7 @@ const KNOWN_TOOLS: &[&str] = &[
"search_codebase",
"write_to_long_running_shell_command",
"read_shell_command_output",
"transfer_shell_command_control_to_user",
"read_mcp_resource",
"read_plan",
"create_plan",
@@ -3,6 +3,21 @@ use warp_multi_agent_api::{self as api};
use super::response_translator::*;
fn tool_from_event(event: api::ResponseEvent) -> api::message::tool_call::Tool {
let Some(api::response_event::Type::ClientActions(actions)) = event.r#type else {
panic!("expected client actions");
};
let Some(api::client_action::Action::AddMessagesToTask(add_messages)) =
&actions.actions[0].action
else {
panic!("expected AddMessagesToTask");
};
let Some(api::message::Message::ToolCall(tool_call)) = &add_messages.messages[0].message else {
panic!("expected tool call message");
};
tool_call.tool.clone().expect("expected concrete tool")
}
#[test]
fn test_build_stream_init_has_valid_ids() {
let event = build_stream_init("req-123", "conv-456");
@@ -128,6 +143,76 @@ fn test_build_create_task_has_no_parent() {
}
}
#[test]
fn shell_tool_call_defaults_to_async_and_preserves_controls() {
let tool = tool_from_event(build_tool_call_message(
"task-1",
"tool-1",
"run_shell_command",
r#"{
"command": "cargo test",
"is_read_only": true,
"is_risky": true,
"uses_pager": false
}"#,
));
let api::message::tool_call::Tool::RunShellCommand(command) = tool else {
panic!("expected run_shell_command");
};
assert!(command.is_read_only);
assert!(command.is_risky);
assert!(!command.uses_pager);
assert!(matches!(
command.wait_until_complete_value,
Some(
api::message::tool_call::run_shell_command::WaitUntilCompleteValue::WaitUntilComplete(
false
)
)
));
}
#[test]
fn long_running_tool_calls_preserve_command_id_and_delay() {
let write_tool = tool_from_event(build_tool_call_message(
"task-1",
"tool-1",
"write_to_long_running_shell_command",
r#"{"command_id":"block-123","input":"yes","mode":"line"}"#,
));
let api::message::tool_call::Tool::WriteToLongRunningShellCommand(write) = write_tool else {
panic!("expected write_to_long_running_shell_command");
};
assert_eq!(write.command_id, "block-123");
assert!(matches!(
write.mode.and_then(|mode| mode.mode),
Some(api::message::tool_call::write_to_long_running_shell_command::mode::Mode::Line(()))
));
let read_tool = tool_from_event(build_tool_call_message(
"task-1",
"tool-2",
"read_shell_command_output",
r#"{"command_id":"block-123","wait_seconds":7}"#,
));
let api::message::tool_call::Tool::ReadShellCommandOutput(read) = read_tool else {
panic!("expected read_shell_command_output");
};
assert_eq!(read.command_id, "block-123");
assert!(matches!(
read.delay,
Some(
api::message::tool_call::read_shell_command_output::Delay::Duration(
prost_types::Duration {
seconds: 7,
nanos: 0
}
)
)
));
}
#[test]
fn test_context_window_for_model_1m_marker() {
assert_eq!(
@@ -299,6 +299,10 @@ impl RunAgentsExecutor {
Ok(StartAgentOutcome::Started { agent_id }),
_,
)) => RunAgentsAgentOutcomeKind::Launched { agent_id },
futures::future::Either::Left((
Ok(StartAgentOutcome::Completed { agent_id, .. }),
_,
)) => RunAgentsAgentOutcomeKind::Launched { agent_id },
futures::future::Either::Left((
Ok(StartAgentOutcome::Error(error)),
_,
@@ -7,7 +7,7 @@ use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity};
use shell_words::split as split_shell_words;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
use crate::ai::agent::{
AIAgentAction, AIAgentActionResultType, AIAgentActionType, LifecycleEventType,
StartAgentExecutionMode, StartAgentResult,
@@ -22,6 +22,11 @@ pub enum StartAgentOutcome {
Started {
agent_id: String,
},
/// A direct-provider child completed and returned its output inline.
Completed {
agent_id: String,
output: String,
},
/// An error occurred while starting the agent.
Error(String),
}
@@ -114,6 +119,10 @@ struct PendingStartAgent {
/// Set once the child conversation is synchronously created.
child_conversation_id: Option<AIConversationId>,
sender: async_channel::Sender<StartAgentOutcome>,
/// Direct Bedrock/OpenAI parents do not have a server run id or an
/// orchestration event stream. Keep the tool call open until their local
/// child finishes, then return the child's output inline.
wait_for_completion: bool,
}
pub struct StartAgentExecutor {
@@ -194,6 +203,35 @@ impl StartAgentExecutor {
}
}
fn complete_pending_as_completed(
&mut self,
request_id: StartAgentRequestId,
child_conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
let Some(conversation) =
BlocklistAIHistoryModel::as_ref(ctx).conversation(&child_conversation_id)
else {
return;
};
let agent_id = conversation
.orchestration_agent_id()
.or_else(|| {
conversation
.server_conversation_token()
.map(|token| token.as_str().to_string())
})
.unwrap_or_else(|| child_conversation_id.to_string());
let output = extract_child_output(conversation);
let Some(pending) = self.pending.remove(&request_id) else {
return;
};
let _ = pending
.sender
.try_send(StartAgentOutcome::Completed { agent_id, output });
}
fn complete_pending_as_error(
&mut self,
request_id: StartAgentRequestId,
@@ -238,6 +276,14 @@ impl StartAgentExecutor {
self.complete_pending_as_error(request_id, child_conversation_id, error_msg, ctx);
return;
}
let wait_for_completion = self
.pending
.get(&request_id)
.is_some_and(|pending| pending.wait_for_completion);
if wait_for_completion && matches!(conversation.status(), ConversationStatus::Success) {
self.complete_pending_as_completed(request_id, child_conversation_id, ctx);
return;
}
if conversation.orchestration_agent_id().is_some() {
self.complete_pending_as_started(request_id, child_conversation_id, ctx);
}
@@ -256,7 +302,7 @@ impl StartAgentExecutor {
let Some(request_id) = self.find_pending_by_child(conversation_id) else {
return;
};
self.complete_pending_as_started(request_id, *conversation_id, ctx);
self.maybe_complete_pending_for_child_state(request_id, *conversation_id, ctx);
}
BlocklistAIHistoryEvent::UpdatedConversationStatus {
conversation_id, ..
@@ -264,17 +310,7 @@ impl StartAgentExecutor {
let Some(request_id) = self.find_pending_by_child(conversation_id) else {
return;
};
let history = BlocklistAIHistoryModel::as_ref(ctx);
let Some(conversation) = history.conversation(conversation_id) else {
return;
};
let error_msg = start_agent_error_message_for_status(
conversation.status(),
conversation.status_error_message().as_deref(),
);
if let Some(error_msg) = error_msg {
self.complete_pending_as_error(request_id, *conversation_id, error_msg, ctx);
}
self.maybe_complete_pending_for_child_state(request_id, *conversation_id, ctx);
}
BlocklistAIHistoryEvent::NewConversationRequestComplete {
request_id,
@@ -346,36 +382,19 @@ impl StartAgentExecutor {
harness_type: None,
model_id,
} => {
// Oz local children resolve their parent's run id from the
// parent conversation. This mirrors the third-party-harness
// and remote-child branches below; the child task row is
// created eagerly at dispatch (see
// `launch_local_no_harness_child`) using this value as the
// `parent_run_id` on `CreateAgentTask`. Bail out if the
// parent has no `run_id` yet — the eager-create path has no
// late-binding fallback (the pre-change lazy path would have
// linked via `Request.metadata.parent_agent_id` later), so
// proceeding would mint an orphan child with no server-side
// parent linkage.
// Server-backed parents launch an Oz child and return its run
// id immediately. Direct Bedrock/OpenAI parents have no run
// id; terminal_pane creates a local hidden child instead and
// this executor waits for its final output.
let parent_run_id = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&parent_conversation_id)
.and_then(|conversation| conversation.run_id());
let Some(parent_run_id) = parent_run_id else {
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
StartAgentResult::Error {
error:
"Local Oz child agents require the parent run_id to be available."
.to_string(),
version,
},
));
};
(
StartAgentExecutionMode::Local {
harness_type: None,
model_id,
},
Some(parent_run_id),
parent_run_id,
)
}
StartAgentExecutionMode::Local {
@@ -485,7 +504,7 @@ impl StartAgentExecutor {
// In local mode (no parent_run_id), block until the child finishes
// so the parent model receives the child's output as the tool result.
let _wait_for_completion = parent_run_id.is_none();
let wait_for_completion = parent_run_id.is_none();
let (sender, receiver) = async_channel::bounded(1);
let request_id = self.next_request_id();
@@ -495,6 +514,7 @@ impl StartAgentExecutor {
parent_conversation_id,
child_conversation_id: None,
sender,
wait_for_completion,
},
);
@@ -518,6 +538,12 @@ impl StartAgentExecutor {
version,
})
}
Ok(StartAgentOutcome::Completed { agent_id, output }) => {
AIAgentActionResultType::StartAgent(StartAgentResult::Success {
agent_id: format!("{agent_id}\n\nAgent output:\n{output}"),
version,
})
}
Ok(StartAgentOutcome::Error(error)) => {
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
}
@@ -551,6 +577,7 @@ impl StartAgentExecutor {
parent_conversation_id,
child_conversation_id: None,
sender,
wait_for_completion: parent_run_id.is_none(),
},
);
ctx.emit(StartAgentExecutorEvent::CreateAgent(Box::new(
@@ -576,6 +603,22 @@ impl StartAgentExecutor {
}
}
/// Extracts the text output from every completed exchange in a local child.
fn extract_child_output(conversation: &AIConversation) -> String {
let output_parts = conversation
.all_exchanges()
.into_iter()
.filter_map(|exchange| exchange.output_status.output())
.map(|output| output.get().format_for_copy(None))
.filter(|text| !text.is_empty())
.collect::<Vec<_>>();
if output_parts.is_empty() {
"Agent completed but produced no text output.".to_string()
} else {
output_parts.join("\n\n")
}
}
/// Whether a child that failed before launch should have its hidden pane and
/// conversation cleaned up. Only terminal launch failures qualify; recoverable
/// `Blocked` startup states (e.g. awaiting GitHub auth) and non-terminal
@@ -17,9 +17,8 @@ use crate::test_util::settings::initialize_history_persistence_for_tests;
const FIRST_REQUEST_ID: StartAgentRequestId = StartAgentRequestId::from_raw_for_test(0);
/// Stable placeholder run_id assigned to the parent conversation in tests
/// that dispatch an Oz local child. The Oz `Local` arm of
/// `StartAgentExecutor::execute` bails out synchronously if the parent has
/// no `run_id`, so every `local_with_defaults` test needs to assign one.
/// that exercise the server-backed Oz child path. Tests without this id
/// exercise the direct-provider local child path instead.
const PARENT_RUN_ID: &str = "00000000-0000-0000-0000-000000000001";
fn build_start_agent_action(
@@ -375,6 +374,89 @@ fn execute_resolves_success_when_request_linkage_happens_after_child_already_sta
});
}
#[test]
fn execute_waits_for_direct_provider_child_and_returns_its_output() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let action = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
let execution = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id: parent_conversation_id,
};
let result: AnyActionExecution = executor.execute(input, ctx).into();
result
});
let AnyActionExecution::Async {
execute_future,
on_complete,
} = execution
else {
panic!("expected async execution");
};
let child_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_child_conversation(
terminal_view_id,
"Agent 1".to_string(),
parent_conversation_id,
None,
ctx,
)
});
history_model.update(&mut app, |model, ctx| {
model.record_new_conversation_request_complete(
FIRST_REQUEST_ID,
child_conversation_id,
ctx,
);
});
executor.read(&app, |executor, _| {
let pending = executor
.pending
.get(&FIRST_REQUEST_ID)
.expect("direct child should remain pending until completion");
assert!(pending.wait_for_completion);
});
history_model.update(&mut app, |history_model, ctx| {
history_model.update_conversation_status(
terminal_view_id,
child_conversation_id,
ConversationStatus::Success,
ctx,
);
});
let async_result = execute_future.await;
let result = app.update(|ctx| on_complete(async_result, ctx));
assert!(matches!(
result,
AIAgentActionResultType::StartAgent(StartAgentResult::Success {
agent_id,
version,
}) if agent_id.contains(&child_conversation_id.to_string())
&& agent_id.contains("Agent output:")
&& agent_id.contains("Agent completed but produced no text output.")
&& version == StartAgentVersion::V1
));
executor.read(&app, |executor, _| {
assert!(executor.pending.is_empty());
});
});
}
#[test]
fn execute_returns_detailed_error_when_child_startup_fails_before_initialization() {
App::test((), |mut app| async move {
@@ -1514,9 +1514,7 @@ impl View for RequestedCommandView {
let has_citations_footer =
!self.derived_from_citations.is_empty() && !self.block_model.status(app).is_streaming();
let header_element = self.render_header(
!should_render_editor
&& !should_render_mcp_content
&& !has_citations_footer,
!should_render_editor && !should_render_mcp_content && !has_citations_footer,
app,
);
@@ -1640,13 +1638,11 @@ impl View for RequestedCommandView {
&& !is_input_pinned_to_top);
let container = Container::new(content.finish())
.with_margin_left(
if action_status.is_some_and(|status| status.is_blocked()) {
CONTENT_HORIZONTAL_PADDING
} else {
CONTENT_HORIZONTAL_PADDING + icon_size(app) + 16.
},
)
.with_margin_left(if action_status.is_some_and(|status| status.is_blocked()) {
CONTENT_HORIZONTAL_PADDING
} else {
CONTENT_HORIZONTAL_PADDING + icon_size(app) + 16.
})
.with_margin_right(CONTENT_HORIZONTAL_PADDING)
.with_margin_bottom(if should_remove_bottom_margin {
0.
+4 -1
View File
@@ -75,7 +75,10 @@ impl ConnectedSelfHostedWorkersModel {
}
pub fn refresh(&mut self, ctx: &mut ModelContext<Self>) {
if !AuthStateProvider::as_ref(ctx).get().is_logged_in() {
if !AuthStateProvider::as_ref(ctx)
.get()
.has_server_credentials()
{
self.clear_workers(ctx);
return;
}
+1 -1
View File
@@ -13,4 +13,4 @@
mod prompt;
mod reviewer;
pub use reviewer::{CrosscheckReviewer, CrosscheckReviewerEvent, ReviewOutcome};
pub use reviewer::{CrosscheckReviewer, CrosscheckReviewerEvent, ReviewOutcome};
+1 -1
View File
@@ -81,4 +81,4 @@ mod tests {
assert!(!is_approved("1. Fix the loop\n2. Rename variable"));
assert!(!is_approved(""));
}
}
}
+1 -4
View File
@@ -260,10 +260,7 @@ impl CrosscheckReviewer {
});
// Use a non-streaming request by calling the same endpoint but with stream: false
let url = format!(
"{}/chat/completions",
config.base_url.trim_end_matches('/')
);
let url = format!("{}/chat/completions", config.base_url.trim_end_matches('/'));
let http = reqwest::Client::new();
let mut request_builder = http.post(&url).json(&request_body);
+8 -2
View File
@@ -199,7 +199,10 @@ impl HarnessAvailabilityModel {
return;
};
if !AuthStateProvider::as_ref(ctx).get().is_logged_in() {
if !AuthStateProvider::as_ref(ctx)
.get()
.has_server_credentials()
{
return;
}
@@ -340,7 +343,10 @@ impl HarnessAvailabilityModel {
pub fn refresh(&self, ctx: &mut ModelContext<Self>) {
// The endpoint queries `user`, which requires auth.
if !AuthStateProvider::as_ref(ctx).get().is_logged_in() {
if !AuthStateProvider::as_ref(ctx)
.get()
.has_server_credentials()
{
return;
}
+1 -1
View File
@@ -22,7 +22,6 @@ pub mod bedrock;
pub(crate) mod bedrock_credentials;
pub(crate) mod block_context;
pub(crate) mod blocklist;
pub(crate) mod crosscheck;
#[cfg(any(feature = "local_fs", not(target_family = "wasm")))]
pub(crate) mod codebase_auto_indexing;
pub mod control_code_parser;
@@ -31,6 +30,7 @@ pub(crate) mod conversation_navigation;
pub(crate) mod conversation_rename;
pub(crate) mod conversation_status_ui;
pub(crate) mod conversation_utils;
pub(crate) mod crosscheck;
pub(crate) mod custom_model_router_editor;
pub(crate) mod custom_model_routers;
pub(crate) mod document;
+1
View File
@@ -572,6 +572,7 @@ const KNOWN_TOOLS: &[&str] = &[
"search_codebase",
"write_to_long_running_shell_command",
"read_shell_command_output",
"transfer_shell_command_control_to_user",
"read_mcp_resource",
"read_plan",
"create_plan",
+4 -1
View File
@@ -247,7 +247,10 @@ impl AIRequestUsageModel {
/// Spawns a task to refresh the latest AI request usage and bonus grants, fetching from the server.
pub fn refresh_request_usage_async(&mut self, ctx: &mut ModelContext<Self>) {
if !AuthStateProvider::as_ref(ctx).get().is_logged_in() {
if !AuthStateProvider::as_ref(ctx)
.get()
.has_server_credentials()
{
return;
}
+3 -1
View File
@@ -130,7 +130,9 @@ impl Requests {
};
if cached_request_limit_info.is_none()
&& AuthStateProvider::as_ref(ctx).get().is_logged_in()
&& AuthStateProvider::as_ref(ctx)
.get()
.has_server_credentials()
{
let ai_client = requests.ai_client.clone();
let _ = ctx.spawn(
+4 -1
View File
@@ -7483,7 +7483,10 @@ impl EditorView {
fn focused_in_active_window(&self, ctx: &AppContext) -> bool {
let manager = self.windowing_state_handle.as_ref(ctx);
let active = manager.state().active_window.or_else(|| manager.active_window());
let active = manager
.state()
.active_window
.or_else(|| manager.active_window());
Some(self.window_id) == active && self.focused
}
-1
View File
@@ -336,7 +336,6 @@ pub trait Experiment<T: Experiment<T>>: FromStr {
// the work above and do any one-time accounting.
if let Some(group) = assigned_group.as_ref() {
GROUP_ASSIGNMENTS.insert(Self::name(), group.variant());
}
assigned_group
+1 -2
View File
@@ -8066,8 +8066,7 @@ impl View for PaneGroup {
}
}
// Render agent-assisted environment modal at tab level when open.
if let Some(_pane_id) = self.pane_with_open_agent_assisted_environment_modal {
}
if let Some(_pane_id) = self.pane_with_open_agent_assisted_environment_modal {}
stack.finish()
}
+1 -3
View File
@@ -214,9 +214,7 @@ impl PaneId {
}
/// Creates a [`PaneId`] from a [`ViewContext<PaneView<SettingsView>>`] (environment management stub)
pub fn from_environment_management_pane_ctx(
ctx: &ViewContext<PaneView<SettingsView>>,
) -> Self {
pub fn from_environment_management_pane_ctx(ctx: &ViewContext<PaneView<SettingsView>>) -> Self {
Self::new_from_ctx(IPaneType::EnvironmentManagement, ctx)
}
+77
View File
@@ -1647,6 +1647,11 @@ fn launch_local_no_harness_child(
model_id: Option<String>,
ctx: &mut ViewContext<PaneGroup>,
) {
if request.parent_run_id.is_none() {
launch_direct_provider_child(group, parent_pane_id, request, model_id, ctx);
return;
}
let ai_client = ServerApiProvider::handle(ctx).as_ref(ctx).get_ai_client();
let request_id = request.id;
let agent_name = normalize_orchestrator_agent_name(&request.name);
@@ -1776,6 +1781,78 @@ fn launch_local_no_harness_child(
);
}
/// Launches a child conversation entirely inside Galaxy for direct Bedrock
/// and OpenAI-compatible providers. These parents have no Warp server run id,
/// so creating an Oz task would either fail or produce an orphan. The
/// StartAgentExecutor links this hidden conversation to the pending tool call
/// and returns its output after the child reaches Success.
#[cfg(not(target_family = "wasm"))]
fn launch_direct_provider_child(
group: &mut PaneGroup,
parent_pane_id: PaneId,
request: StartAgentRequest,
model_id: Option<String>,
ctx: &mut ViewContext<PaneGroup>,
) {
let request_id = request.id;
let request_name = normalize_orchestrator_agent_name(&request.name).unwrap_or_default();
let parent_conversation_id = request.parent_conversation_id;
let prompt = request.prompt;
let Some(HiddenChildAgentConversation {
terminal_view,
terminal_view_id,
conversation_id,
}) = create_hidden_child_agent_conversation(
group,
HiddenChildAgentConversationRequest {
parent_pane_id,
name: request_name.clone(),
parent_conversation_id,
orchestration_harness: None,
env_vars: HashMap::new(),
task_context: None,
is_shared_session_creator: IsSharedSessionCreator::No,
},
ctx,
)
else {
let _ = create_error_child_agent_conversation(
group,
ErrorChildAgentConversationRequest {
parent_pane_id,
name: request_name,
parent_conversation_id,
request_id: Some(request_id),
orchestration_harness: None,
error_message: "Failed to create a hidden pane for the local child agent."
.to_string(),
},
ctx,
);
return;
};
apply_child_model_id_override(terminal_view_id, model_id.as_deref(), ctx);
BlocklistAIHistoryModel::handle(ctx).update(ctx, |model, ctx| {
model.record_new_conversation_request_complete(request_id, conversation_id, ctx);
});
terminal_view.update(ctx, |terminal_view, ctx| {
terminal_view
.ai_controller()
.update(ctx, |controller, ctx| {
controller.send_agent_query_in_conversation(prompt, conversation_id, ctx);
});
terminal_view.enter_agent_view(
None,
Some(conversation_id),
AgentViewEntryOrigin::ChildAgent,
ctx,
);
});
}
/// Asynchronously prepares a local harness launch, then creates the
/// hidden child pane and executes the launch command.
#[cfg(not(target_family = "wasm"))]
+4 -1
View File
@@ -71,7 +71,10 @@ impl ReferralThemeStatus {
referrals_client: Arc<dyn ReferralsClient>,
ctx: &mut ModelContext<Self>,
) {
if !AuthStateProvider::as_ref(ctx).get().is_logged_in() {
if !AuthStateProvider::as_ref(ctx)
.get()
.has_server_credentials()
{
return;
}
+8 -10
View File
@@ -1647,16 +1647,14 @@ pub fn init_actions_from_parent_view<T: Action + Clone>(
context: &ContextPredicate,
builder: fn(SettingsAction) -> T,
) {
let mut toggle_binding_pairs = vec![
ToggleSettingActionPair::new(
"app analytics",
builder(SettingsAction::PrivacyPageToggle(
PrivacyPageAction::ToggleTelemetry,
)),
context,
flags::TELEMETRY_FLAG,
),
];
let mut toggle_binding_pairs = vec![ToggleSettingActionPair::new(
"app analytics",
builder(SettingsAction::PrivacyPageToggle(
PrivacyPageAction::ToggleTelemetry,
)),
context,
flags::TELEMETRY_FLAG,
)];
toggle_binding_pairs.push(ToggleSettingActionPair::new(
"secret redaction",
+31 -29
View File
@@ -7,10 +7,10 @@ use galaxy_graphql::billing::AddonCreditsOption;
use galaxy_graphql::error::BudgetExceededError;
use galaxyui::elements::{
Align, Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, DropShadow, Empty, Expanded, Flex, FormattedTextElement, HighlightedHyperlink,
Hoverable, Icon as WarpUiIcon, MainAxisAlignment, MainAxisSize, MouseStateHandle,
OffsetPositioning, ParentAnchor, ParentElement as _, ParentOffsetBounds, Radius, Shrinkable,
SizeConstraintCondition, SizeConstraintSwitch, Stack, Text,
CrossAxisAlignment, DropShadow, Empty, Expanded, Flex, FormattedTextElement,
HighlightedHyperlink, Hoverable, Icon as WarpUiIcon, MainAxisAlignment, MainAxisSize,
MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement as _, ParentOffsetBounds,
Radius, Shrinkable, SizeConstraintCondition, SizeConstraintSwitch, Stack, Text,
};
use galaxyui::fonts::Weight;
use galaxyui::ui_components::button::ButtonVariant;
@@ -339,33 +339,35 @@ impl BuyCreditsBanner {
};
if discount_percent > 0 {
MenuItemFields::new_with_custom_label(
Arc::new(enclose!((primary_text) move |is_selected, is_hovered, appearance, _| {
let text_color = appearance.theme().main_text_color(
if is_selected || is_hovered {
appearance.theme().accent()
} else {
appearance.theme().surface_1()
}
);
let main_text = Text::new_inline(
primary_text.clone(),
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(text_color.into())
.finish();
Arc::new(
enclose!((primary_text) move |is_selected, is_hovered, appearance, _| {
let text_color = appearance.theme().main_text_color(
if is_selected || is_hovered {
appearance.theme().accent()
} else {
appearance.theme().surface_1()
}
);
let main_text = Text::new_inline(
primary_text.clone(),
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(text_color.into())
.finish();
let discount_badge = Empty::new().finish();
let discount_badge = Empty::new().finish();
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_child(main_text)
.with_child(discount_badge)
.finish()
})),
Some(primary_text)
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_child(main_text)
.with_child(discount_badge)
.finish()
}),
),
Some(primary_text),
)
.with_on_select_action(DropdownAction::select_action_and_close(
Action::SelectDenomination(index),
+26 -24
View File
@@ -165,32 +165,34 @@ impl EnableAutoReloadModalBody {
};
if discount_percent > 0 {
MenuItemFields::new_with_custom_label(
Arc::new(enclose!((primary_text) move |is_selected, is_hovered, appearance, _| {
let text_color = appearance.theme().main_text_color(
if is_selected || is_hovered {
appearance.theme().accent()
} else {
appearance.theme().surface_1()
}
);
let main_text = Text::new_inline(
primary_text.clone(),
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(text_color.into())
.finish();
Arc::new(
enclose!((primary_text) move |is_selected, is_hovered, appearance, _| {
let text_color = appearance.theme().main_text_color(
if is_selected || is_hovered {
appearance.theme().accent()
} else {
appearance.theme().surface_1()
}
);
let main_text = Text::new_inline(
primary_text.clone(),
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(text_color.into())
.finish();
let discount_badge = Empty::new().finish();
let discount_badge = Empty::new().finish();
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_child(main_text)
.with_child(discount_badge)
.finish()
})),
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_child(main_text)
.with_child(discount_badge)
.finish()
}),
),
Some(primary_text),
)
.with_on_select_action(DropdownAction::select_action_and_close(
+3 -8
View File
@@ -1217,9 +1217,7 @@ impl Input {
"ToolResult(id={tool_use_id}, err={is_error}): {truncated}"
)
}
crate::ai::bedrock::convert::MessageContent::MultiPart(
parts,
) => {
crate::ai::bedrock::convert::MessageContent::MultiPart(parts) => {
format!("MultiPart({} parts)", parts.len())
}
};
@@ -1270,16 +1268,13 @@ impl Input {
}
output.push('\n');
output.push_str(&format!(
"=== MESSAGE HISTORY ({msg_count} messages) ===\n"
));
output.push_str(&format!("=== MESSAGE HISTORY ({msg_count} messages) ===\n"));
for line in &messages {
output.push_str(line);
output.push('\n');
}
ctx.clipboard()
.write(ClipboardContent::plain_text(output));
ctx.clipboard().write(ClipboardContent::plain_text(output));
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
+7 -8
View File
@@ -219,14 +219,13 @@ impl PtySpawner {
>,
is_crash_reporting_enabled: bool,
) -> Result<(PtySpawnResult, Box<dyn PtyHandle>)> {
let pty_spawn_info =
invoke_without_crash_reporting(move || {
local_tty::spawn(
options,
#[cfg(windows)]
event_loop_tx,
)
})?;
let pty_spawn_info = invoke_without_crash_reporting(move || {
local_tty::spawn(
options,
#[cfg(windows)]
event_loop_tx,
)
})?;
let direct_pty_handle = Box::new(DirectPtyHandle {
child: pty_spawn_info.child,
});