mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* fix: ensure LLM calls always end with user message (closes #763) Claude 4.6 models (claude-sonnet-4-6, claude-opus-4-6) no longer support assistant message prefill — any LLM call where the conversation ends on an assistant message is rejected with HTTP 400 "This model does not support assistant message prefill". The same root cause also triggers NEAR AI's "No user query found in messages" 400 error for the routine engine path. Two fixes: 1. src/worker/container.rs — before_llm_call() After poll_and_inject_prompt(), if no user follow-up arrived and handle_text_response() left an assistant message at the end of the conversation, inject a sentinel "Continue." user message before the next LLM call. 2. src/agent/routine_engine.rs — execute_lightweight_with_tools() Before the force_text final completion call, ensure messages end with a user-role message. Tool result messages (Role::Tool) satisfy Anthropic but not NEAR AI; assistant messages satisfy neither. Also updates the worker system prompt to instruct the agent to include the phrase "The job is complete" in its final message, so the agentic loop can detect termination reliably. Tested with claude-sonnet-4-6 and claude-opus-4-6. Workaround: ANTHROPIC_MODEL=claude-sonnet-4-20250514 (still supports prefill). * fix: broaden sentinel guard to any non-user message (per review) Gemini suggested the Role::Assistant check in before_llm_call() is too specific. Changed to !Role::User to match the routine_engine.rs fix and cover tool results too. * fix: address zmanian review — JobDelegate sentinel, shared helper, NearAI complete() flattening - Extract ensure_ends_with_user_message() to src/util.rs with 4 unit tests (empty list, after assistant, after tool result, no-op when already user) - Add sentinel guard to JobDelegate::before_llm_call() in src/worker/job.rs so scheduler jobs (CreateJob / /job path) no longer hit Claude 4.6 / NEAR AI 400s - Replace inline guards in ContainerDelegate and routine_engine.rs with the shared helper — all 3 call sites now use one implementation - Fix complete() in nearai_chat.rs to apply flatten_tool_messages when flatten_tool_messages=true — previously only complete_with_tools() flattened, so force_text paths could still send role:"tool" messages to NEAR AI - Update stale comment in container.rs: "assistant message" → "non-user message" - Add flatten tests in nearai_chat.rs covering the complete() path Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * ci: fix fmt and tar advisory --------- Co-authored-by: Jacob Lasky <[email protected]> Co-authored-by: Claude Sonnet 4.6 <[email protected]> Co-authored-by: Illia Polosukhin <[email protected]> Co-authored-by: firat.sertgoz <[email protected]>
229 lines
7.3 KiB
Rust
229 lines
7.3 KiB
Rust
//! Shared utility functions used across the codebase.
|
|
|
|
use crate::llm::{ChatMessage, Role};
|
|
|
|
/// Find the largest valid UTF-8 char boundary at or before `pos`.
|
|
///
|
|
/// Polyfill for `str::floor_char_boundary` (nightly-only). Use when
|
|
/// truncating strings by byte position to avoid panicking on multi-byte
|
|
/// characters.
|
|
pub fn floor_char_boundary(s: &str, pos: usize) -> usize {
|
|
if pos >= s.len() {
|
|
return s.len();
|
|
}
|
|
let mut i = pos;
|
|
while i > 0 && !s.is_char_boundary(i) {
|
|
i -= 1;
|
|
}
|
|
i
|
|
}
|
|
|
|
/// Ensure the last message in `messages` is a user-role message.
|
|
///
|
|
/// NEAR AI rejects conversations that don't end with a user message;
|
|
/// Claude 4.6 rejects assistant prefill. Call this before any LLM
|
|
/// completion request to satisfy both requirements.
|
|
pub fn ensure_ends_with_user_message(messages: &mut Vec<ChatMessage>) {
|
|
if !matches!(messages.last(), Some(m) if m.role == Role::User) {
|
|
messages.push(ChatMessage::user("Continue."));
|
|
}
|
|
}
|
|
|
|
/// Check if an LLM response explicitly signals that a job/task is complete.
|
|
///
|
|
/// Uses phrase-level matching to avoid false positives from bare words like
|
|
/// "done" or "complete" appearing in non-completion contexts (e.g. "not done yet",
|
|
/// "the download is incomplete").
|
|
pub fn llm_signals_completion(response: &str) -> bool {
|
|
let lower = response.to_lowercase();
|
|
|
|
// Superset of phrases from worker/job.rs and worker/container.rs.
|
|
let positive_phrases = [
|
|
"job is complete",
|
|
"job is done",
|
|
"job is finished",
|
|
"task is complete",
|
|
"task is done",
|
|
"task is finished",
|
|
"work is complete",
|
|
"work is done",
|
|
"work is finished",
|
|
"successfully completed",
|
|
"have completed the job",
|
|
"have completed the task",
|
|
"have finished the job",
|
|
"have finished the task",
|
|
"all steps are complete",
|
|
"all steps are done",
|
|
"i have completed",
|
|
"i've completed",
|
|
"all done",
|
|
"all tasks complete",
|
|
];
|
|
|
|
let negative_phrases = [
|
|
"not complete",
|
|
"not done",
|
|
"not finished",
|
|
"incomplete",
|
|
"unfinished",
|
|
"isn't done",
|
|
"isn't complete",
|
|
"isn't finished",
|
|
"not yet done",
|
|
"not yet complete",
|
|
"not yet finished",
|
|
];
|
|
|
|
let has_negative = negative_phrases.iter().any(|p| lower.contains(p));
|
|
if has_negative {
|
|
return false;
|
|
}
|
|
|
|
positive_phrases.iter().any(|p| lower.contains(p))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::llm::ChatMessage;
|
|
use crate::util::{ensure_ends_with_user_message, floor_char_boundary, llm_signals_completion};
|
|
|
|
// ── floor_char_boundary ──
|
|
|
|
#[test]
|
|
fn floor_char_boundary_at_valid_boundary() {
|
|
assert_eq!(floor_char_boundary("hello", 3), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn floor_char_boundary_mid_multibyte_char() {
|
|
// h = 1 byte, é = 2 bytes, total 3 bytes
|
|
let s = "hé";
|
|
assert_eq!(floor_char_boundary(s, 2), 1); // byte 2 is mid-é, back up to 1
|
|
}
|
|
|
|
#[test]
|
|
fn floor_char_boundary_past_end() {
|
|
assert_eq!(floor_char_boundary("hi", 100), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn floor_char_boundary_at_zero() {
|
|
assert_eq!(floor_char_boundary("hello", 0), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn floor_char_boundary_empty_string() {
|
|
assert_eq!(floor_char_boundary("", 5), 0);
|
|
}
|
|
|
|
// ── ensure_ends_with_user_message ──
|
|
|
|
#[test]
|
|
fn ensure_user_message_injects_when_empty() {
|
|
let mut msgs: Vec<ChatMessage> = vec![];
|
|
ensure_ends_with_user_message(&mut msgs);
|
|
assert_eq!(msgs.len(), 1);
|
|
assert_eq!(msgs[0].role, crate::llm::Role::User);
|
|
}
|
|
|
|
#[test]
|
|
fn ensure_user_message_injects_after_assistant() {
|
|
let mut msgs = vec![ChatMessage::user("hi"), ChatMessage::assistant("hello")];
|
|
ensure_ends_with_user_message(&mut msgs);
|
|
assert_eq!(msgs.len(), 3);
|
|
assert_eq!(msgs[2].role, crate::llm::Role::User);
|
|
}
|
|
|
|
#[test]
|
|
fn ensure_user_message_injects_after_tool_result() {
|
|
let mut msgs = vec![
|
|
ChatMessage::user("run tool"),
|
|
ChatMessage::tool_result("call_1", "my_tool", "result"),
|
|
];
|
|
ensure_ends_with_user_message(&mut msgs);
|
|
assert_eq!(msgs.len(), 3);
|
|
assert_eq!(msgs[2].role, crate::llm::Role::User);
|
|
}
|
|
|
|
#[test]
|
|
fn ensure_user_message_no_op_when_already_user() {
|
|
let mut msgs = vec![ChatMessage::user("hello")];
|
|
ensure_ends_with_user_message(&mut msgs);
|
|
assert_eq!(msgs.len(), 1);
|
|
}
|
|
|
|
// ── llm_signals_completion ──
|
|
|
|
#[test]
|
|
fn signals_completion_positive() {
|
|
assert!(llm_signals_completion("The job is complete."));
|
|
assert!(llm_signals_completion("I have completed the task."));
|
|
assert!(llm_signals_completion("All done, here are the results."));
|
|
assert!(llm_signals_completion("Task is finished successfully."));
|
|
assert!(llm_signals_completion(
|
|
"I have completed the task successfully."
|
|
));
|
|
assert!(llm_signals_completion(
|
|
"All steps are complete and verified."
|
|
));
|
|
assert!(llm_signals_completion(
|
|
"I've done all the work. The work is done."
|
|
));
|
|
assert!(llm_signals_completion(
|
|
"Successfully completed the migration."
|
|
));
|
|
assert!(llm_signals_completion(
|
|
"I have completed the job ahead of schedule."
|
|
));
|
|
assert!(llm_signals_completion("I have finished the task."));
|
|
assert!(llm_signals_completion("All steps are done now."));
|
|
assert!(llm_signals_completion("I've completed everything."));
|
|
assert!(llm_signals_completion("All tasks complete."));
|
|
}
|
|
|
|
#[test]
|
|
fn signals_completion_negative() {
|
|
assert!(!llm_signals_completion("The task is not complete yet."));
|
|
assert!(!llm_signals_completion("This is not done."));
|
|
assert!(!llm_signals_completion("The work is incomplete."));
|
|
assert!(!llm_signals_completion("Build is unfinished."));
|
|
assert!(!llm_signals_completion(
|
|
"The migration is not yet finished."
|
|
));
|
|
assert!(!llm_signals_completion("The job isn't done yet."));
|
|
assert!(!llm_signals_completion("This remains unfinished."));
|
|
}
|
|
|
|
#[test]
|
|
fn signals_completion_no_bare_substrings() {
|
|
assert!(!llm_signals_completion("The download completed."));
|
|
assert!(!llm_signals_completion(
|
|
"Function done_callback was called."
|
|
));
|
|
assert!(!llm_signals_completion("Set is_complete = true"));
|
|
assert!(!llm_signals_completion("Running step 3 of 5"));
|
|
assert!(!llm_signals_completion(
|
|
"I need to complete more work first."
|
|
));
|
|
assert!(!llm_signals_completion(
|
|
"Let me finish the remaining steps."
|
|
));
|
|
assert!(!llm_signals_completion(
|
|
"I'm done analyzing, now let me fix it."
|
|
));
|
|
assert!(!llm_signals_completion(
|
|
"I completed step 1 but step 2 remains."
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn signals_completion_tool_output_injection() {
|
|
assert!(!llm_signals_completion("TASK_COMPLETE"));
|
|
assert!(!llm_signals_completion("JOB_DONE"));
|
|
assert!(!llm_signals_completion(
|
|
"The tool returned: TASK_COMPLETE signal"
|
|
));
|
|
}
|
|
}
|