anveesa 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cargo.lock CHANGED
@@ -54,7 +54,7 @@ dependencies = [
54
54
 
55
55
  [[package]]
56
56
  name = "anveesa"
57
- version = "0.2.3"
57
+ version = "0.2.4"
58
58
  dependencies = [
59
59
  "anyhow",
60
60
  "base64",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "anveesa"
3
- version = "0.2.3"
3
+ version = "0.2.4"
4
4
  edition = "2024"
5
5
  default-run = "anveesa"
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anveesa",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "A terminal CLI that wraps AI providers (OpenAI-compatible APIs and local CLIs) into a single unified command",
5
5
  "main": "bin/anveesa.js",
6
6
  "bin": {
package/src/lib.rs CHANGED
@@ -302,6 +302,7 @@ async fn render_stream(
302
302
  let mut spinner_active = false;
303
303
  let mut first_token = true;
304
304
  let mut produced = false;
305
+ let mut line_open = false;
305
306
  let mut usage: Option<Usage> = None;
306
307
  let mut plan_tasks: Vec<String> = vec![];
307
308
  let mut plan_done: Vec<bool> = vec![];
@@ -326,13 +327,29 @@ async fn render_stream(
326
327
  first_token = false;
327
328
  }
328
329
  produced = true;
330
+ line_open = true;
329
331
  print!("{text}");
330
332
  let _ = io::stdout().flush();
331
333
  }
332
334
  Some(StreamEvent::Usage(value)) => usage = Some(value),
335
+ Some(StreamEvent::ToolCall { summary }) => {
336
+ clear_spinner(spinner, spinner_active);
337
+ spinner_active = false;
338
+ if line_open {
339
+ println!();
340
+ line_open = false;
341
+ }
342
+ print_tool_call(&summary, spinner);
343
+ first_token = true;
344
+ frame = 0;
345
+ }
333
346
  Some(StreamEvent::Confirm { preview, reply }) => {
334
347
  clear_spinner(spinner, spinner_active);
335
348
  spinner_active = false;
349
+ if line_open {
350
+ println!();
351
+ line_open = false;
352
+ }
336
353
  let decision = tokio::task::block_in_place(|| {
337
354
  show_confirm_preview(&preview, spinner);
338
355
  prompt_confirm_decision(spinner)
@@ -345,6 +362,10 @@ async fn render_stream(
345
362
  Some(StreamEvent::FileOp { verb, path, added, removed, preview, truncated }) => {
346
363
  clear_spinner(spinner, spinner_active);
347
364
  spinner_active = false;
365
+ if line_open {
366
+ println!();
367
+ line_open = false;
368
+ }
348
369
  print_file_op(&verb, &path, added, removed, &preview, truncated, spinner);
349
370
  // Re-arm the spinner for the next API round.
350
371
  first_token = true;
@@ -353,6 +374,10 @@ async fn render_stream(
353
374
  Some(StreamEvent::PlanSet { tasks }) => {
354
375
  clear_spinner(spinner, spinner_active);
355
376
  spinner_active = false;
377
+ if line_open {
378
+ println!();
379
+ line_open = false;
380
+ }
356
381
  plan_done = vec![false; tasks.len()];
357
382
  plan_tasks = tasks;
358
383
  print_plan_list(&plan_tasks, &plan_done, spinner);
@@ -362,6 +387,10 @@ async fn render_stream(
362
387
  Some(StreamEvent::PlanTaskDone { index }) => {
363
388
  clear_spinner(spinner, spinner_active);
364
389
  spinner_active = false;
390
+ if line_open {
391
+ println!();
392
+ line_open = false;
393
+ }
365
394
  if index < plan_done.len() {
366
395
  plan_done[index] = true;
367
396
  }
@@ -398,7 +427,7 @@ async fn render_stream(
398
427
  }
399
428
  }
400
429
 
401
- if produced {
430
+ if produced && line_open {
402
431
  println!();
403
432
  } else {
404
433
  clear_spinner(spinner, spinner_active);
@@ -426,6 +455,14 @@ async fn render_stream(
426
455
  }
427
456
  }
428
457
 
458
+ fn print_tool_call(summary: &str, is_tty: bool) {
459
+ if is_tty {
460
+ eprintln!("\x1b[90m └─ {summary}\x1b[0m");
461
+ } else {
462
+ eprintln!("tool: {summary}");
463
+ }
464
+ }
465
+
429
466
  fn print_file_op(
430
467
  verb: &str,
431
468
  path: &str,
@@ -116,6 +116,8 @@ pub enum StreamEvent {
116
116
  Token(String),
117
117
  /// Final token accounting for the turn.
118
118
  Usage(Usage),
119
+ /// A read-only tool is running. Used to make multi-round inspection visible.
120
+ ToolCall { summary: String },
119
121
  /// A write/run tool needs the user's approval. The renderer shows the
120
122
  /// preview, prompts for a decision, and sends it back through the reply channel.
121
123
  Confirm {
@@ -21,6 +21,7 @@ const MAX_RETRIES: usize = 2;
21
21
  const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
22
22
  /// How many times the model may call the exact same (tool, arguments) pair before we refuse.
23
23
  const MAX_IDENTICAL_CALLS: usize = 3;
24
+ const MAX_TOOL_INTENT_REPROMPTS: usize = 2;
24
25
 
25
26
  pub async fn ask(
26
27
  provider_name: &str,
@@ -58,6 +59,7 @@ pub async fn ask(
58
59
  let mut approval_state = ToolApprovalState::default();
59
60
  let mut full_text = String::new();
60
61
  let mut last_usage: Option<Usage> = None;
62
+ let mut tool_intent_reprompts = 0usize;
61
63
 
62
64
  loop {
63
65
  let mut body = json!({
@@ -94,12 +96,25 @@ pub async fn ask(
94
96
  let mut state = StreamState::default();
95
97
  stream_response(response, &mut state, events).await?;
96
98
 
97
- full_text.push_str(&state.content);
98
99
  if let Some(usage) = state.usage {
99
100
  last_usage = Some(usage);
100
101
  }
101
102
 
102
103
  if state.tool_calls.is_empty() {
104
+ if tools_enabled
105
+ && tool_intent_reprompts < MAX_TOOL_INTENT_REPROMPTS
106
+ && looks_like_unfinished_tool_intent(&state.content)
107
+ {
108
+ tool_intent_reprompts += 1;
109
+ messages.push(json!({
110
+ "role": "assistant",
111
+ "content": state.content,
112
+ }));
113
+ messages.push(tool_intent_reprompt_message());
114
+ continue;
115
+ }
116
+
117
+ full_text.push_str(&state.content);
103
118
  break;
104
119
  }
105
120
 
@@ -196,6 +211,10 @@ async fn dispatch_tool(
196
211
  if !policy.allows_write_tools() {
197
212
  return denied_message("write tools are disabled (pass --yes or run interactively)");
198
213
  }
214
+ } else {
215
+ let _ = events.send(StreamEvent::ToolCall {
216
+ summary: tools::describe_call(&call.name, &call.arguments),
217
+ });
199
218
  }
200
219
 
201
220
  // Snapshot BEFORE the tool runs — needed both for preview and for post-run diff.
@@ -397,6 +416,50 @@ fn tool_limit_message(max_tool_rounds: usize) -> Value {
397
416
  })
398
417
  }
399
418
 
419
+ fn tool_intent_reprompt_message() -> Value {
420
+ json!({
421
+ "role": "system",
422
+ "content": "Your previous message said you would inspect/read/check the workspace, but it did not call any tool or provide a final answer. Do not narrate future tool use. If you need information, call the relevant Anveesa tools now. Otherwise, answer the user directly."
423
+ })
424
+ }
425
+
426
+ fn looks_like_unfinished_tool_intent(text: &str) -> bool {
427
+ let lower = text.trim().to_lowercase();
428
+ if lower.is_empty() || lower.len() > 600 {
429
+ return false;
430
+ }
431
+
432
+ let has_intent = [
433
+ "let me inspect",
434
+ "let me check",
435
+ "let me look",
436
+ "let me read",
437
+ "let me search",
438
+ "let me peek",
439
+ "let me also peek",
440
+ "i'll inspect",
441
+ "i'll check",
442
+ "i'll look",
443
+ "i'll read",
444
+ "i'll search",
445
+ "i will inspect",
446
+ "i will check",
447
+ "i will look",
448
+ "i will read",
449
+ "i will search",
450
+ "i'm going to inspect",
451
+ "i'm going to check",
452
+ "i'm going to look",
453
+ "i'm going to read",
454
+ "i need to inspect",
455
+ "i need to check",
456
+ ]
457
+ .iter()
458
+ .any(|needle| lower.contains(needle));
459
+
460
+ has_intent && (lower.ends_with(':') || lower.ends_with('.') || lower.ends_with("first"))
461
+ }
462
+
400
463
  fn denied_message(reason: &str) -> String {
401
464
  json!({ "ok": false, "error": reason }).to_string()
402
465
  }
@@ -685,15 +748,14 @@ async fn stream_response(
685
748
  // Stream ended normally
686
749
  break;
687
750
  }
688
- Err(_e) => {
751
+ Err(error) => {
689
752
  consecutive_errors += 1;
690
753
  if consecutive_errors >= MAX_CONSECUTIVE_ERRORS {
691
- // Log the error but don't fail the whole request
692
- eprintln!(
693
- "\n[warning: stream interrupted after {} consecutive errors]",
694
- consecutive_errors
754
+ bail!(
755
+ "stream interrupted while reading provider response after {} consecutive errors: {}",
756
+ consecutive_errors,
757
+ error
695
758
  );
696
- break;
697
759
  }
698
760
  // Try to continue reading - transient network hiccups happen
699
761
  continue;
@@ -972,4 +1034,18 @@ mod tests {
972
1034
  .contains("Do not call tools again")
973
1035
  );
974
1036
  }
1037
+
1038
+ #[test]
1039
+ fn detects_unfinished_tool_intent() {
1040
+ assert!(looks_like_unfinished_tool_intent(
1041
+ "Let me inspect the workspace structure more thoroughly."
1042
+ ));
1043
+ assert!(looks_like_unfinished_tool_intent(
1044
+ "Let me also peek at the key files to understand the project:"
1045
+ ));
1046
+ assert!(!looks_like_unfinished_tool_intent(
1047
+ "The project is a Rust CLI with an npm wrapper."
1048
+ ));
1049
+ assert!(!looks_like_unfinished_tool_intent(""));
1050
+ }
975
1051
  }
package/src/tools.rs CHANGED
@@ -24,7 +24,9 @@ const MAX_COMMAND_TIMEOUT_SECS: u64 = 300;
24
24
  pub fn guidance(include_write: bool) -> String {
25
25
  let mut text = String::from(
26
26
  "You can use Anveesa tools to inspect the workspace: list directories, find files by name, \
27
- search text, read capped file snippets, and do a basic public web lookup. Prefer tools over guessing.",
27
+ search text, read capped file snippets, and do a basic public web lookup. Prefer tools over guessing. \
28
+ If you need to inspect, read, list, search, or check something, call the relevant tool immediately; \
29
+ do not end a response by saying you will inspect something later.",
28
30
  );
29
31
  if include_write {
30
32
  text.push_str(
@@ -58,6 +60,19 @@ pub fn describe_call(name: &str, arguments: &str) -> String {
58
60
  let args: Value = serde_json::from_str(arguments).unwrap_or(Value::Null);
59
61
  let field = |key: &str| args.get(key).and_then(Value::as_str).unwrap_or("");
60
62
  match name {
63
+ "list_dir" => format!("list directory {}", field("path").if_empty(".")),
64
+ "find_files" => format!(
65
+ "find files matching `{}` under {}",
66
+ field("query"),
67
+ field("root").if_empty(".")
68
+ ),
69
+ "search_text" => format!(
70
+ "search text `{}` under {}",
71
+ field("query"),
72
+ field("root").if_empty(".")
73
+ ),
74
+ "read_file" => format!("read file {}", field("path")),
75
+ "web_search" => format!("web search `{}`", field("query")),
61
76
  "create_dir" => format!("create directory {}", field("path")),
62
77
  "write_file" => format!("write file {}", field("path")),
63
78
  "edit_file" => format!("edit file {}", field("path")),
@@ -66,6 +81,16 @@ pub fn describe_call(name: &str, arguments: &str) -> String {
66
81
  }
67
82
  }
68
83
 
84
+ trait EmptyStrExt {
85
+ fn if_empty(self, fallback: &'static str) -> Self;
86
+ }
87
+
88
+ impl<'a> EmptyStrExt for &'a str {
89
+ fn if_empty(self, fallback: &'static str) -> Self {
90
+ if self.is_empty() { fallback } else { self }
91
+ }
92
+ }
93
+
69
94
  pub fn definitions(include_write: bool) -> Vec<Value> {
70
95
  let mut definitions = vec![
71
96
  json!({
@@ -846,6 +871,23 @@ mod tests {
846
871
 
847
872
  #[test]
848
873
  fn describes_calls_for_confirmation() {
874
+ assert_eq!(describe_call("list_dir", r#"{}"#), "list directory .");
875
+ assert_eq!(
876
+ describe_call("find_files", r#"{"query":"Cargo","root":"src"}"#),
877
+ "find files matching `Cargo` under src"
878
+ );
879
+ assert_eq!(
880
+ describe_call("search_text", r#"{"query":"TODO"}"#),
881
+ "search text `TODO` under ."
882
+ );
883
+ assert_eq!(
884
+ describe_call("read_file", r#"{"path":"README.md"}"#),
885
+ "read file README.md"
886
+ );
887
+ assert_eq!(
888
+ describe_call("web_search", r#"{"query":"rust termios"}"#),
889
+ "web search `rust termios`"
890
+ );
849
891
  assert_eq!(
850
892
  describe_call("create_dir", r#"{"path":"hello"}"#),
851
893
  "create directory hello"
@@ -863,6 +905,7 @@ mod tests {
863
905
  #[test]
864
906
  fn guidance_mentions_writes_only_when_enabled() {
865
907
  assert!(!guidance(false).contains("write_file"));
908
+ assert!(guidance(false).contains("call the relevant tool immediately"));
866
909
  assert!(guidance(true).contains("create_dir"));
867
910
  assert!(guidance(true).contains("write_file"));
868
911
  }