Skip to content
Merged
Show file tree
Hide file tree
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
24 changes: 24 additions & 0 deletions src/agent/agentic_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,30 @@ pub async fn run_agentic_loop(
// Call LLM
let output = delegate.call_llm(reasoning, reason_ctx, iteration).await?;

match &output.result {
RespondResult::Text(text) => {
tracing::debug!(
iteration,
len = text.len(),
has_suggestions = text.contains("<suggestions>"),
response = %text,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Intentional — full response at DEBUG is needed for debugging suggestion extraction and prompt compliance. The TRACE-level body log in nearai_chat.rs was removed as redundant (the agentic loop log is more useful since it shows the cleaned response).

"LLM text response"
);
}
RespondResult::ToolCalls {
tool_calls,
content,
} => {
let names: Vec<&str> = tool_calls.iter().map(|tc| tc.name.as_str()).collect();
tracing::debug!(
iteration,
tools = ?names,
has_content = content.is_some(),
"LLM tool_calls response"
);
}
}

match output.result {
RespondResult::Text(text) => {
// Tool intent nudge: if the LLM says "let me search..." without
Expand Down
97 changes: 97 additions & 0 deletions src/agent/dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,54 @@ fn strip_internal_tool_call_text(text: &str) -> String {
}
}

/// Extract `<suggestions>["...","..."]</suggestions>` from a response string.
///
/// Returns `(cleaned_text, suggestions)`. The `<suggestions>` block is stripped
/// from the text regardless of whether the JSON inside parses successfully.
/// Only the **last** `<suggestions>` block is used (closest to end of response).
/// Blocks inside markdown code fences are ignored.
pub(crate) fn extract_suggestions(text: &str) -> (String, Vec<String>) {
use regex::Regex;
use std::sync::LazyLock;

static RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?s)<suggestions>\s*(.*?)\s*</suggestions>").expect("valid regex") // safety: constant pattern
});

// Find the position of the last closing code fence to avoid matching inside code blocks
let last_code_fence = text.rfind("```").unwrap_or(0);

// Find all matches, take the last one that's after the last code fence
let mut best_match: Option<regex::Match<'_>> = None;
let mut best_capture: Option<String> = None;
for caps in RE.captures_iter(text) {
if let (Some(full), Some(inner)) = (caps.get(0), caps.get(1))
&& full.start() >= last_code_fence
{
best_match = Some(full);
best_capture = Some(inner.as_str().to_string());
}
}

let Some(full) = best_match else {
return (text.to_string(), Vec::new());
};

let cleaned = format!("{}{}", &text[..full.start()], &text[full.end()..]); // safety: regex match boundaries are valid UTF-8
let cleaned = cleaned.trim().to_string();

// Parse the JSON array
let suggestions = best_capture
.and_then(|json| serde_json::from_str::<Vec<String>>(&json).ok())
.unwrap_or_default()
.into_iter()
.filter(|s| !s.trim().is_empty() && s.len() <= 80)
.take(3)
.collect();

(cleaned, suggestions)
}

#[cfg(test)]
mod tests {
use std::sync::Arc;
Expand Down Expand Up @@ -2197,6 +2245,55 @@ mod tests {
assert_eq!(result, input);
}

#[test]
fn test_extract_suggestions_basic() {
let input = "Here is my answer.\n<suggestions>[\"Check logs\", \"Deploy\"]</suggestions>";
let (text, suggestions) = super::extract_suggestions(input);
assert_eq!(text, "Here is my answer."); // safety: test
assert_eq!(suggestions, vec!["Check logs", "Deploy"]); // safety: test
}

#[test]
fn test_extract_suggestions_no_tag() {
let input = "Just a plain response.";
let (text, suggestions) = super::extract_suggestions(input);
assert_eq!(text, "Just a plain response."); // safety: test
assert!(suggestions.is_empty()); // safety: test
}

#[test]
fn test_extract_suggestions_malformed_json() {
let input = "Answer.\n<suggestions>not json</suggestions>";
let (text, suggestions) = super::extract_suggestions(input);
assert_eq!(text, "Answer."); // safety: test
assert!(suggestions.is_empty()); // safety: test
}

#[test]
fn test_extract_suggestions_inside_code_fence() {
let input = "```\n<suggestions>[\"foo\"]</suggestions>\n```";
let (text, suggestions) = super::extract_suggestions(input);
// The tag is inside a code fence, so it should not be extracted
assert_eq!(text, input); // safety: test
assert!(suggestions.is_empty()); // safety: test
}

#[test]
fn test_extract_suggestions_after_code_fence() {
let input = "```\ncode\n```\nAnswer.\n<suggestions>[\"foo\"]</suggestions>";
let (text, suggestions) = super::extract_suggestions(input);
assert_eq!(text, "```\ncode\n```\nAnswer."); // safety: test
assert_eq!(suggestions, vec!["foo"]); // safety: test
}

#[test]
fn test_extract_suggestions_filters_long() {
let long = "x".repeat(81);
let input = format!("Answer.\n<suggestions>[\"{}\", \"ok\"]</suggestions>", long);
let (_, suggestions) = super::extract_suggestions(&input);
assert_eq!(suggestions, vec!["ok"]); // safety: test
}

#[test]
fn test_tool_error_format_includes_tool_name() {
// Regression test for issue #487: tool errors sent to the LLM should
Expand Down
28 changes: 28 additions & 0 deletions src/agent/thread_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,10 @@ impl Agent {
// Complete, fail, or request approval
match result {
Ok(AgenticLoopResult::Response(response)) => {
// Extract <suggestions> from response text before user sees it
let (response, suggestions) =
crate::agent::dispatcher::extract_suggestions(&response);

// Hook: TransformResponse — allow hooks to modify or reject the final response
let response = {
let event = crate::hooks::HookEvent::ResponseTransform {
Expand Down Expand Up @@ -473,6 +477,18 @@ impl Agent {
)
.await;

// Send suggestions after response (best-effort, rendered by web gateway)
if !suggestions.is_empty() {
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::Suggestions { suggestions },
&message.metadata,
)
.await;
}
Comment on lines 423 to +490

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 6411abe — the approval-resume path now applies extract_suggestions() + send_status(Suggestions) the same as the main response path.


Ok(SubmissionResult::response(response))
}
Ok(AgenticLoopResult::NeedApproval { pending }) => {
Expand Down Expand Up @@ -1334,6 +1350,8 @@ impl Agent {

match result {
Ok(AgenticLoopResult::Response(response)) => {
let (response, suggestions) =
crate::agent::dispatcher::extract_suggestions(&response);
thread.complete_turn(&response);
let (turn_number, tool_calls) = thread
.turns
Expand Down Expand Up @@ -1364,6 +1382,16 @@ impl Agent {
&message.metadata,
)
.await;
if !suggestions.is_empty() {
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::Suggestions { suggestions },
&message.metadata,
)
.await;
}
Ok(SubmissionResult::response(response))
}
Ok(AgenticLoopResult::NeedApproval {
Expand Down
2 changes: 2 additions & 0 deletions src/channels/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@ pub enum StatusUpdate {
/// Optional workspace path where the image was saved.
path: Option<String>,
},
/// Suggested follow-up messages for the user.
Suggestions { suggestions: Vec<String> },
}

impl StatusUpdate {
Expand Down
3 changes: 3 additions & 0 deletions src/channels/repl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,9 @@ impl Channel for ReplChannel {
eprintln!("\x1b[36m [image generated]\x1b[0m");
}
}
StatusUpdate::Suggestions { .. } => {
// Suggestions are only rendered by the web gateway
}
}
Ok(())
}
Expand Down
Loading
Loading