Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 200 additions & 7 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4783,6 +4783,23 @@ fn handle_prompt_result(
} else {
hard_timeout_fate_suffix = Some(" — requeued for retry (recently active)");
}
} else if matches!(
&result.outcome,
PromptOutcome::Error(acp::AcpError::AgentError { code: -32002, message })
if message.contains("model not found")
) {
// Retrying the same missing model cannot repair its configuration.
tracing::warn!(
channel_id = %batch.channel_id,
events = batch.events.len(),
"dead-lettering batch immediately — model not found"
);
let content = "⚠️ I couldn't process the last request: the configured model \
wasn't found at the provider's endpoint. Open agent settings, select a \
different model from the dropdown, and save your changes. Restart the agent \
to apply the new configuration, then re-send your request."
.to_string();
Comment on lines +4797 to +4801

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Include the restart/apply step before asking the user to resend

After this application error, the agent process and its existing session are returned to the pool (pool.rs:3370–3395, lib.rs:5065–5075). For a running standalone agent, selecting another model in AgentInstanceEditDialog and saving only calls updateManagedAgent; the backend persists the model but restarts immediately only for access-policy changes (agent_models_update.rs:137–181,266–295,453–471). The ordinary auto-restart policy requires three continuous idle minutes and can be disabled (autoRestartPolicy.ts:49–83).

Therefore a user who follows the new instruction, saves another model, and immediately resends still runs the unavailable model and gets the same failure. Please explicitly tell them to save and restart/apply the agent configuration before resending, and adjust the regression test text. This is a correction to the newly added recovery advice, not a request to change the existing restart policy.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Addressed in ed5f54f. The notice now tells users to select another model, save their changes, restart the agent to apply the configuration, and then re-send. Updated the existing notice regression expectation; the restart policy is unchanged. All 30 focused error-outcome tests and the full local just ci gate passed, including 932 buzz-acp library tests, 6,483 desktop tests, and 2,098 mobile tests. Refreshed the PR description and local UI captures with the exact corrected text. Please take another look.

spawn_failure_notice(rest_client, &batch, content);
} else if matches!(&result.outcome, PromptOutcome::Error(e) if is_auth_error(e)) {
// Auth errors are non-retryable: the token won't self-repair
// between retries, so requeueing only wastes attempt slots and
Expand Down Expand Up @@ -11012,10 +11029,192 @@ mod error_outcome_emission_tests {
);
}

#[tokio::test]
async fn model_not_found_posts_recovery_notice_without_retrying() {
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};

let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let rest = relay::RestClient {
http: reqwest::Client::new(),
base_url: format!("http://{}", listener.local_addr().unwrap()),
keys: Keys::generate(),
auth_tag_json: None,
};
let keys = Keys::generate();
let root = nostr::EventId::from_byte_array([0xaa; 32]);
let parent = nostr::EventId::from_byte_array([0xbb; 32]);
let event = EventBuilder::new(Kind::Custom(9), "test")
.tags([
nostr::Tag::parse(["e", &root.to_hex(), "", "root"]).unwrap(),
nostr::Tag::parse(["e", &parent.to_hex(), "", "reply"]).unwrap(),
])
.sign_with_keys(&keys)
.unwrap();
let channel_id = uuid::Uuid::new_v4();
let batch = FlushBatch {
channel_id,
scope: scope::SessionScope::Conversation { channel_id },
events: vec![BatchEvent {
event,
prompt_tag: "test".into(),
received_at: std::time::Instant::now(),
}],
cancelled_events: vec![],
cancel_reason: None,
};

let raw_error = r#"llm model not found: (gpt-6-astra) 404 Not Found: {"error_code":"NOT_FOUND","message":"'gpt-6-astra' does not exist."}"#;
let model_error = AcpError::AgentError {
code: -32002,
message: raw_error.to_string(),
};
let expected_error = model_error.to_string();
let observer = ObserverHandle::in_process();

let agent = dummy_agent(0).await;
let mut pool = AgentPool::from_slots(vec![None]);
let task_id = pool.join_set.spawn(async {}).id();
pool.task_map_mut().insert(
task_id,
crate::pool::TaskMeta {
agent_index: 0,
channel_id: None,
scope: None,
turn_id: "test-turn-id".to_string(),
recoverable_batch: None,
control_tx: None,
steer_tx: None,
successful_steer_deliveries: HashSet::new(),
},
);
let mut queue = EventQueue::new(config::DedupMode::Queue);
let config = test_config();
let mut heartbeat_in_flight = false;
let removed_channels = std::collections::HashSet::new();
let mut crash_history = vec![SlotCircuit {
crash_times: Vec::new(),
open_until: None,
respawn_in_flight: false,
}];
let (respawn_tx, _respawn_rx) = mpsc::channel(8);
let mut respawn_tasks = tokio::task::JoinSet::new();
let result = PromptResult {
agent,
source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }),
turn_id: "test-turn-id".to_string(),
outcome: PromptOutcome::Error(model_error),
batch: Some(batch),
};
handle_prompt_result(
&mut pool,
&mut queue,
&config,
result,
&mut heartbeat_in_flight,
&removed_channels,
&mut crash_history,
&respawn_tx,
&mut respawn_tasks,
Some(observer.clone()),
Some(&rest),
);

// The batch must not be requeued: pending_channels returns 0.
assert_eq!(
queue.pending_channels(),
0,
"model-not-found must stop immediately — batch must not be requeued"
);
assert_eq!(
queue.queued_event_count(channel_id),
0,
"model-not-found must stop immediately — no events should be pending"
);

assert!(
pool.agents_mut()[0].is_some(),
"healthy process remains reusable"
);
assert!(respawn_tasks.is_empty());
let errors: Vec<_> = observer
.snapshot()
.into_iter()
.filter(|event| event.kind == "turn_error")
.collect();
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].payload["code"], -32002);
assert_eq!(errors[0].payload["error"], expected_error);

// Capture the real signed notice sent by handle_prompt_result, without a live relay.
let notice: nostr::Event = tokio::time::timeout(Duration::from_secs(3), async {
let (socket, _) = listener.accept().await.unwrap();
let mut reader = BufReader::new(socket);
let mut line = String::new();
reader.read_line(&mut line).await.unwrap();
assert_eq!(line, "POST /events HTTP/1.1\r\n");
let mut content_length = None;
for _ in 0..64 {
line.clear();
assert_ne!(reader.read_line(&mut line).await.unwrap(), 0);
if line == "\r\n" {
break;
}
if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
content_length = Some(value.trim().parse::<usize>().unwrap());
}
}
let size = content_length.expect("request Content-Length");
assert!(size < 65536);
let mut body = vec![0; size];
reader.read_exact(&mut body).await.unwrap();
reader
.get_mut()
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}")
.await
.unwrap();
serde_json::from_slice(&body).unwrap()
})
.await
.expect("failure notice must be posted on the first failure");
notice.verify().unwrap();
assert_eq!(notice.pubkey, rest.keys.public_key());
assert_eq!(notice.kind, Kind::Custom(9));
assert_eq!(
notice.content,
"⚠️ I couldn't process the last request: the configured model wasn't found at the provider's endpoint. Open agent settings, select a different model from the dropdown, and save your changes. Restart the agent to apply the new configuration, then re-send your request."
);
let tags = serde_json::to_value(&notice.tags).unwrap();
assert!(tags
.as_array()
.unwrap()
.iter()
.any(|tag| tag[0] == "h" && tag[1] == channel_id.to_string()));
let threading = queue::parse_thread_tags(&notice);
assert_eq!(threading.root_event_id, Some(root.to_hex()));
assert_eq!(threading.parent_event_id, Some(parent.to_hex()));
}

/// A non-auth application error (e.g. usage credits) must still follow the
/// standard requeue path so today's behavior is unchanged.
#[tokio::test]
async fn non_auth_application_error_is_requeued() {
assert_application_error_is_requeued(acp::AcpError::AgentError {
code: -32000,
message: "Usage credits required for 1M context".to_string(),
})
.await;
}

#[tokio::test]
async fn non_model_resource_not_found_is_requeued() {
assert_application_error_is_requeued(acp::AcpError::AgentError {
code: -32002,
message: "Resource not found: session no longer exists".to_string(),
})
.await;
}

async fn assert_application_error_is_requeued(error: acp::AcpError) {
let keys = nostr::Keys::generate();
let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "test")
.sign_with_keys(&keys)
Expand All @@ -11033,12 +11232,6 @@ mod error_outcome_emission_tests {
cancel_reason: None,
};

// Usage-credits error — AgentError but NOT an auth error.
let usage_error = acp::AcpError::AgentError {
code: -32000,
message: "Usage credits required for 1M context".to_string(),
};

let agent = dummy_agent(0).await;
let mut pool = AgentPool::from_slots(vec![None]);
let task_id = pool.join_set.spawn(async {}).id();
Expand Down Expand Up @@ -11070,7 +11263,7 @@ mod error_outcome_emission_tests {
agent,
source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }),
turn_id: "test-turn-id".to_string(),
outcome: PromptOutcome::Error(usage_error),
outcome: PromptOutcome::Error(error),
batch: Some(batch),
};
handle_prompt_result(
Expand Down
Loading