Improve Galaxy permissions, computer-use routing, and agent UX

This commit is contained in:
2026-09-30 09:55:24 -05:00
parent cc064d4050
commit a2a2633199
30 changed files with 623 additions and 180 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ pub(crate) use event_translator::{
pub(crate) use provider_run_coordinator::{
ProviderRunBlock, ProviderRunCoordinator, ProviderRunProfile, ProviderRunProjection,
ProviderToolExecutionRef, ProviderToolLifecycleOutcome, BASE_PROVIDER_PROFILE,
CLI_MONITOR_PROVIDER_PROFILE,
CLI_MONITOR_PROVIDER_PROFILE, COMPUTER_USE_PROVIDER_PROFILE,
};
pub(crate) use rig::{
prepare_provider_run, provider_context_window_tokens, provider_runtime_for_request,
+42 -36
View File
@@ -23,6 +23,7 @@ use crate::ai::model_output_logging;
pub(crate) const BASE_PROVIDER_PROFILE: &str = "base";
pub(crate) const CLI_MONITOR_PROVIDER_PROFILE: &str = "cli-monitor";
pub(crate) const COMPUTER_USE_PROVIDER_PROFILE: &str = "computer-use";
const PROVIDER_MODEL_START_TIMEOUT: Duration = Duration::from_secs(120);
const PROVIDER_MODEL_EVENT_IDLE_TIMEOUT: Duration = Duration::from_secs(90);
const PROVIDER_MODEL_RETRY_DELAYS: [Duration; 3] = [
@@ -752,18 +753,6 @@ impl ProviderRunCoordinator {
return Ok(());
}
buffer.text.push_str(&text);
if !self
.project_or_fail_acknowledged(
ProviderRunProjection::ModelEvent {
work_id: call.work_id.clone(),
event: AgentEvent::TextDelta { text },
},
project,
)
.await?
{
return Ok(());
}
}
AgentEvent::ReasoningDelta { text } => {
if !self
@@ -775,18 +764,6 @@ impl ProviderRunCoordinator {
return Ok(());
}
buffer.reasoning.push_str(&text);
if !self
.project_or_fail_acknowledged(
ProviderRunProjection::ModelEvent {
work_id: call.work_id.clone(),
event: AgentEvent::ReasoningDelta { text },
},
project,
)
.await?
{
return Ok(());
}
}
AgentEvent::ReasoningCompleted { text, signature }
if text.is_empty() && signature.is_none() =>
@@ -806,18 +783,6 @@ impl ProviderRunCoordinator {
}
buffer.reasoning.clone_from(&text);
buffer.reasoning_signature.clone_from(&signature);
if !self
.project_or_fail_acknowledged(
ProviderRunProjection::ModelEvent {
work_id: call.work_id.clone(),
event: AgentEvent::ReasoningCompleted { text, signature },
},
project,
)
.await?
{
return Ok(());
}
}
AgentEvent::Tool {
event: ToolEvent::Proposed { call: tool_call },
@@ -864,6 +829,47 @@ impl ProviderRunCoordinator {
{
return Ok(());
}
// A model can speak before its tool call arrives. Wait until the turn
// finishes so routine tool-turn narration never reaches the conversation.
// Keep it in the canonical turn for provider continuity.
if buffer.tool_calls.is_empty()
&& !matches!(
&reason,
StopReason::Cancelled | StopReason::ContextWindowExceeded
)
{
if !buffer.reasoning.is_empty()
&& !self
.project_or_fail_acknowledged(
ProviderRunProjection::ModelEvent {
work_id: call.work_id.clone(),
event: AgentEvent::ReasoningCompleted {
text: buffer.reasoning.clone(),
signature: buffer.reasoning_signature.clone(),
},
},
project,
)
.await?
{
return Ok(());
}
if !buffer.text.is_empty()
&& !self
.project_or_fail_acknowledged(
ProviderRunProjection::ModelEvent {
work_id: call.work_id.clone(),
event: AgentEvent::TextDelta {
text: buffer.text.clone(),
},
},
project,
)
.await?
{
return Ok(());
}
}
if !self
.project_or_fail_acknowledged(
ProviderRunProjection::ModelTurnFinished {
@@ -301,6 +301,56 @@ fn answer_turn() -> ScriptedTurn {
])
}
#[tokio::test]
async fn tool_turn_narration_stays_in_history_but_out_of_visible_output() {
let runtime = Arc::new(ScriptedRuntime::new(vec![tool_turn()]));
let mut coordinator = coordinator(runtime);
let mut projections = Vec::new();
let (_sender, control) = turn_control();
let block = coordinator
.drive_until_blocked(control, collect_projection(&mut projections))
.await
.unwrap();
assert!(matches!(block, ProviderRunBlock::Tools(_)));
assert!(coordinator
.run()
.transcript()
.iter()
.any(|message| format!("{:?}", message.content).contains("I will inspect it.")));
assert!(!projections.iter().any(|projection| matches!(
projection,
ProviderRunProjection::ModelEvent {
event: AgentEvent::TextDelta { .. }
| AgentEvent::ReasoningDelta { .. }
| AgentEvent::ReasoningCompleted { .. },
..
}
)));
}
#[tokio::test]
async fn completed_answer_is_still_projected() {
let runtime = Arc::new(ScriptedRuntime::new(vec![answer_turn()]));
let mut coordinator = coordinator(runtime);
let mut projections = Vec::new();
let (_sender, control) = turn_control();
coordinator
.drive_until_blocked(control, collect_projection(&mut projections))
.await
.unwrap();
assert!(projections.iter().any(|projection| matches!(
projection,
ProviderRunProjection::ModelEvent {
event: AgentEvent::TextDelta { text },
..
} if text == "The issue is fixed."
)));
}
fn coordinator(runtime: Arc<dyn AgentRuntime>) -> ProviderRunCoordinator {
ProviderRunCoordinator::from_request(
"run-1",
+29
View File
@@ -31,6 +31,7 @@ use crate::settings::OpenAIProviderKind;
pub(crate) struct PreparedProviderRun {
pub(crate) base_profile: ProviderRunProfile,
pub(crate) cli_monitor_profile: Option<ProviderRunProfile>,
pub(crate) computer_use_profile: Option<ProviderRunProfile>,
pub(crate) tool_result_archive: Vec<ConversationMessage>,
pub(crate) messages_sent: Arc<std::sync::Mutex<Vec<ConversationMessage>>>,
pub(crate) persistence_offset: usize,
@@ -123,6 +124,7 @@ impl ProviderActionContext {
pub(crate) async fn prepare_provider_run(
base_provider_config: crate::ai::provider::ProviderConfig,
cli_provider_config: crate::ai::provider::ProviderConfig,
computer_use_provider_config: crate::ai::provider::ProviderConfig,
mut params: RequestParams,
orchestration_models: Vec<OrchestrationModelOption>,
) -> anyhow::Result<PreparedProviderRun> {
@@ -130,6 +132,32 @@ pub(crate) async fn prepare_provider_run(
crate::ai::agent::api::prepare_direct_provider_params(&mut params);
let skill_path_origin = params.session_context.skill_path_origin();
let mut cli_params = params.clone();
let computer_use_model = params.computer_use_model.as_str();
let computer_use_profile = if params.computer_use_enabled
&& !computer_use_model.is_empty()
&& computer_use_model != "computer-use-agent-auto"
&& computer_use_model != params.model.as_str()
&& !matches!(
&computer_use_provider_config,
crate::ai::provider::ProviderConfig::None
) {
let mut computer_params = params.clone();
computer_params.model = params.computer_use_model.clone();
let (runtime, prepared) = prepare_provider_profile(
computer_use_provider_config,
computer_params,
supported_tools.clone(),
supported_cli_agent_tools.clone(),
None,
)
.await?;
Some(
ProviderRunProfile::new(runtime, prepared.request)
.with_max_context_tokens(prepared.max_context_tokens),
)
} else {
None
};
let cli_model_is_placeholder = params.cli_agent_model.as_str().trim().is_empty()
|| params
.cli_agent_model
@@ -210,6 +238,7 @@ pub(crate) async fn prepare_provider_run(
base_profile: ProviderRunProfile::new(base_runtime, request)
.with_max_context_tokens(prepared_context_tokens),
cli_monitor_profile,
computer_use_profile,
tool_result_archive,
messages_sent,
persistence_offset,
+1 -1
View File
@@ -824,7 +824,7 @@ fn build_system_prompt(
"You are Galaxy, a local-first software-engineering and terminal agent. Complete the user's task through inspection, implementation, and proportionate validation. Galaxy owns tool permissions and execution; use only the tools advertised in this request and treat every result as authoritative evidence.\n\n",
);
prompt.push_str(
"## Communication Style\nSpeak naturally, warmly, and directly, like a thoughtful collaborator working alongside the user. Default to short responses and expand only when complexity or the user's request warrants it. Acknowledge a correction once when useful; do not repeatedly agree that the user is right, praise them, or add generic reassurance. For nontrivial work, briefly tell the user what you are checking once before the first tool call of a new user request. Tool results and compaction checkpoints continue the same request: resume the work without another greeting, acknowledgement, or restatement of the plan. Supply the short purpose title requested by each tool schema. Between dependent tool calls, add a concise update only when a result materially changes what you learned or what you will do next; ground it in specifics instead of generic activity narration. Do not narrate every routine read, repeat the plan, or end a turn with only a progress update when useful work can continue. In the final response, lead with the outcome and keep the handoff compact.\n\n",
"## Communication Style\nSpeak naturally, warmly, and directly, like a thoughtful collaborator working alongside the user. Work quietly by default while using tools; tool panes already show the activity. Do not send an introductory acknowledgement, plan, or generic progress message. Send prose during work only for a question needing the user's input, a consequential blocker, or a material finding the user needs to act on now. Tool results and compaction checkpoints continue the same request: resume work without another greeting or restatement. Supply the short purpose title requested by each tool schema. Do not narrate routine reads, repeat the plan, or end a turn with only a progress update when useful work can continue. In the final response, lead with the outcome and keep the handoff compact.\n\n",
);
prompt.push_str(
"## Execution Contract\nBefore another read or search, identify the missing fact it will resolve. Reuse findings already present in the conversation. Once enough evidence exists for the requested change, implement it and validate; additional inspection should resolve a specific uncertainty. Batch independent reads within the tool limits and wait for results before dependent work. Continue until the user's requested outcome is complete and validated. Do not stop at an intermediate analysis, plan, status update, or promise of future work, and do not ask the user to say \"continue\". After each tool result, choose and perform the next necessary step. Stop only when the request is fulfilled or a concrete blocker requires user input; identify that blocker explicitly.\n\n",
+29
View File
@@ -31,6 +31,7 @@ async fn missing_cli_provider_route_falls_back_to_base_provider_profile() {
let prepared = prepare_provider_run(
ProviderConfig::OpenAI(openai_config("base-provider-model")),
ProviderConfig::None,
ProviderConfig::None,
params,
Vec::new(),
)
@@ -58,6 +59,34 @@ async fn missing_cli_provider_route_falls_back_to_base_provider_profile() {
.any(|tool| tool.name == "read_shell_command_output"));
}
#[tokio::test]
async fn selected_computer_use_model_gets_its_own_profile() {
let mut params = RequestParams::new_for_test();
params.model = LLMId::from("base-selection");
params.computer_use_model = LLMId::from("computer-selection");
params.computer_use_enabled = true;
let prepared = prepare_provider_run(
ProviderConfig::OpenAI(openai_config("base-provider-model")),
ProviderConfig::None,
ProviderConfig::OpenAI(openai_config("computer-provider-model")),
params,
Vec::new(),
)
.await
.unwrap();
assert_eq!(
prepared
.computer_use_profile
.expect("selected computer-use model should have a profile")
.request
.model
.as_str(),
"computer-provider-model"
);
}
#[test]
fn subscription_context_budget_uses_catalog_limit_and_clamps_stale_overrides() {
let mut config = openai_config("subscription-model");