anveesa 0.2.3 → 0.2.5

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.5"
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.5"
4
4
  edition = "2024"
5
5
  default-run = "anveesa"
6
6
 
package/README.md CHANGED
@@ -63,6 +63,26 @@ sessions (stored next to the config as `history`). The active conversation is
63
63
  stored next to it as `session.json`. Use the up/down arrows to recall previous
64
64
  prompts.
65
65
 
66
+ To include an image, copy it to the clipboard and run `/attach` before your
67
+ question:
68
+
69
+ ```text
70
+ ❯ /attach
71
+ [image attached for the next message]
72
+ ❯ what is in this screenshot?
73
+ ```
74
+
75
+ You can also attach an image file directly:
76
+
77
+ ```text
78
+ ❯ /attach ./screenshot.png
79
+ ❯ explain this UI
80
+ ```
81
+
82
+ Image input works with OpenAI-compatible providers and models that support
83
+ vision. Terminals do not paste image pixels into the text prompt itself, so use
84
+ `/attach` instead of pressing paste and expecting the image to appear inline.
85
+
66
86
  `ctx:on` means Anveesa sends lightweight terminal context with each request:
67
87
  current directory, parent directory, git root/branch/status when available, and
68
88
  a capped directory listing. This lets the model answer questions like "where are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anveesa",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
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
@@ -12,13 +12,11 @@ use std::{
12
12
  };
13
13
 
14
14
  use anyhow::{Context, Result, bail};
15
+ use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
15
16
  use clap::{CommandFactory, Parser};
16
17
  use serde::{Deserialize, Serialize};
17
18
  use tokio::sync::mpsc;
18
19
 
19
- #[cfg(target_os = "macos")]
20
- use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
21
-
22
20
  use crate::{
23
21
  cli::{AskOptions, Cli, Command, ConfigCommand},
24
22
  config::{
@@ -80,6 +78,7 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
80
78
  .get(&provider_name)
81
79
  .with_context(|| format!("unknown provider '{provider_name}'"))?;
82
80
  let tools_available = matches!(provider, ProviderConfig::OpenAiCompatible(_));
81
+ let images_available = matches!(provider, ProviderConfig::OpenAiCompatible(_));
83
82
  let model = options
84
83
  .model
85
84
  .clone()
@@ -122,6 +121,7 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
122
121
  // Fingerprint of the last clipboard image we attached — prevents re-attaching
123
122
  // the same screenshot on every subsequent turn until the user copies something new.
124
123
  let mut last_image_fp: Option<String> = None;
124
+ let mut pending_image: Option<ImageAttachment> = None;
125
125
  let mut paste_count = 0usize;
126
126
 
127
127
  loop {
@@ -148,6 +148,7 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
148
148
  "/clear" => {
149
149
  history.clear();
150
150
  last_image_fp = None;
151
+ pending_image = None;
151
152
  paste_count = 0;
152
153
  if let Some(path) = &session_path {
153
154
  let _ = fs::remove_file(path);
@@ -157,12 +158,31 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
157
158
  }
158
159
  _ => {}
159
160
  }
161
+ if let Some(path) = parse_attach_command(&prompt) {
162
+ if !images_available {
163
+ eprintln!("error: image attachments require an openai-compatible provider");
164
+ continue;
165
+ }
166
+
167
+ match attach_image(path.as_deref()) {
168
+ Ok(image) => {
169
+ last_image_fp = Some(image_fingerprint(&image));
170
+ pending_image = Some(image);
171
+ eprintln!("\x1b[90m [image attached for the next message]\x1b[0m");
172
+ }
173
+ Err(error) => eprintln!("error: {error:#}"),
174
+ }
175
+ continue;
176
+ }
160
177
  if let Some(path) = &history_path {
161
178
  let _ = append_repl_history(path, prompt.as_str());
162
179
  }
163
180
 
164
- // Check clipboard for a new screenshot. Skip if it's the same image as last turn.
165
- let image = if is_tty {
181
+ // Use an explicitly attached image first. Otherwise, keep the legacy
182
+ // convenience behavior: attach a newly copied clipboard image once.
183
+ let image = if pending_image.is_some() {
184
+ pending_image.take()
185
+ } else if is_tty && images_available {
166
186
  grab_clipboard_image().and_then(|img| {
167
187
  let fp = image_fingerprint(&img);
168
188
  if last_image_fp.as_deref() == Some(&fp) {
@@ -302,13 +322,14 @@ async fn render_stream(
302
322
  let mut spinner_active = false;
303
323
  let mut first_token = true;
304
324
  let mut produced = false;
325
+ let mut line_open = false;
305
326
  let mut usage: Option<Usage> = None;
306
327
  let mut plan_tasks: Vec<String> = vec![];
307
328
  let mut plan_done: Vec<bool> = vec![];
308
329
 
309
330
  static TIPS: &[&str] = &[
310
331
  "Tip: type /clear to reset context",
311
- "Tip: paste a screenshot and ask about it",
332
+ "Tip: copy an image, then type /attach",
312
333
  "Tip: use --yes to auto-approve file edits",
313
334
  "Tip: type /exit to leave the session",
314
335
  ];
@@ -326,13 +347,29 @@ async fn render_stream(
326
347
  first_token = false;
327
348
  }
328
349
  produced = true;
350
+ line_open = true;
329
351
  print!("{text}");
330
352
  let _ = io::stdout().flush();
331
353
  }
332
354
  Some(StreamEvent::Usage(value)) => usage = Some(value),
355
+ Some(StreamEvent::ToolCall { summary }) => {
356
+ clear_spinner(spinner, spinner_active);
357
+ spinner_active = false;
358
+ if line_open {
359
+ println!();
360
+ line_open = false;
361
+ }
362
+ print_tool_call(&summary, spinner);
363
+ first_token = true;
364
+ frame = 0;
365
+ }
333
366
  Some(StreamEvent::Confirm { preview, reply }) => {
334
367
  clear_spinner(spinner, spinner_active);
335
368
  spinner_active = false;
369
+ if line_open {
370
+ println!();
371
+ line_open = false;
372
+ }
336
373
  let decision = tokio::task::block_in_place(|| {
337
374
  show_confirm_preview(&preview, spinner);
338
375
  prompt_confirm_decision(spinner)
@@ -345,6 +382,10 @@ async fn render_stream(
345
382
  Some(StreamEvent::FileOp { verb, path, added, removed, preview, truncated }) => {
346
383
  clear_spinner(spinner, spinner_active);
347
384
  spinner_active = false;
385
+ if line_open {
386
+ println!();
387
+ line_open = false;
388
+ }
348
389
  print_file_op(&verb, &path, added, removed, &preview, truncated, spinner);
349
390
  // Re-arm the spinner for the next API round.
350
391
  first_token = true;
@@ -353,6 +394,10 @@ async fn render_stream(
353
394
  Some(StreamEvent::PlanSet { tasks }) => {
354
395
  clear_spinner(spinner, spinner_active);
355
396
  spinner_active = false;
397
+ if line_open {
398
+ println!();
399
+ line_open = false;
400
+ }
356
401
  plan_done = vec![false; tasks.len()];
357
402
  plan_tasks = tasks;
358
403
  print_plan_list(&plan_tasks, &plan_done, spinner);
@@ -362,6 +407,10 @@ async fn render_stream(
362
407
  Some(StreamEvent::PlanTaskDone { index }) => {
363
408
  clear_spinner(spinner, spinner_active);
364
409
  spinner_active = false;
410
+ if line_open {
411
+ println!();
412
+ line_open = false;
413
+ }
365
414
  if index < plan_done.len() {
366
415
  plan_done[index] = true;
367
416
  }
@@ -398,7 +447,7 @@ async fn render_stream(
398
447
  }
399
448
  }
400
449
 
401
- if produced {
450
+ if produced && line_open {
402
451
  println!();
403
452
  } else {
404
453
  clear_spinner(spinner, spinner_active);
@@ -426,6 +475,14 @@ async fn render_stream(
426
475
  }
427
476
  }
428
477
 
478
+ fn print_tool_call(summary: &str, is_tty: bool) {
479
+ if is_tty {
480
+ eprintln!("\x1b[90m └─ {summary}\x1b[0m");
481
+ } else {
482
+ eprintln!("tool: {summary}");
483
+ }
484
+ }
485
+
429
486
  fn print_file_op(
430
487
  verb: &str,
431
488
  path: &str,
@@ -881,6 +938,7 @@ fn print_session_header(
881
938
  row("", "", " Commands", bg);
882
939
  row(&info, dm, " /clear reset memory", cy);
883
940
  row(&cwd_line, dm, " /exit quit session", cy);
941
+ row("", "", " /attach attach image", cy);
884
942
  row("", "", approve, cy);
885
943
  row("", "", "", "");
886
944
 
@@ -1237,17 +1295,138 @@ fn image_fingerprint(img: &ImageAttachment) -> String {
1237
1295
  format!("{}:{}", img.data.len(), prefix)
1238
1296
  }
1239
1297
 
1298
+ fn parse_attach_command(prompt: &str) -> Option<Option<String>> {
1299
+ for command in ["/attach", "/image", "/img"] {
1300
+ if prompt == command {
1301
+ return Some(None);
1302
+ }
1303
+ if let Some(rest) = prompt.strip_prefix(command)
1304
+ && rest.chars().next().is_some_and(char::is_whitespace)
1305
+ {
1306
+ let path = unquote_path(rest.trim());
1307
+ if !path.is_empty() {
1308
+ return Some(Some(path.to_string()));
1309
+ }
1310
+ return Some(None);
1311
+ }
1312
+ }
1313
+ None
1314
+ }
1315
+
1316
+ fn unquote_path(path: &str) -> &str {
1317
+ let trimmed = path.trim();
1318
+ if trimmed.len() >= 2 {
1319
+ let bytes = trimmed.as_bytes();
1320
+ if (bytes[0] == b'"' && bytes[trimmed.len() - 1] == b'"')
1321
+ || (bytes[0] == b'\'' && bytes[trimmed.len() - 1] == b'\'')
1322
+ {
1323
+ return &trimmed[1..trimmed.len() - 1];
1324
+ }
1325
+ }
1326
+ trimmed
1327
+ }
1328
+
1329
+ fn attach_image(path: Option<&str>) -> Result<ImageAttachment> {
1330
+ match path {
1331
+ Some(path) => load_image_file(Path::new(path)),
1332
+ None => read_clipboard_image().context(
1333
+ "no supported image found in clipboard; copy an image first or use /attach path/to/image.png",
1334
+ ),
1335
+ }
1336
+ }
1337
+
1338
+ fn load_image_file(path: &Path) -> Result<ImageAttachment> {
1339
+ const MAX_IMAGE_BYTES: u64 = 20 * 1024 * 1024;
1340
+
1341
+ let metadata =
1342
+ fs::metadata(path).with_context(|| format!("failed to read {}", path.display()))?;
1343
+ if !metadata.is_file() {
1344
+ bail!("{} is not a file", path.display());
1345
+ }
1346
+ if metadata.len() > MAX_IMAGE_BYTES {
1347
+ bail!(
1348
+ "{} is too large for an image attachment ({} MB max)",
1349
+ path.display(),
1350
+ MAX_IMAGE_BYTES / 1024 / 1024
1351
+ );
1352
+ }
1353
+
1354
+ let mime = image_mime_for_path(path)
1355
+ .with_context(|| format!("unsupported image type for {}", path.display()))?;
1356
+ let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
1357
+ if bytes.is_empty() {
1358
+ bail!("{} is empty", path.display());
1359
+ }
1360
+
1361
+ Ok(ImageAttachment {
1362
+ mime: mime.to_string(),
1363
+ data: BASE64.encode(&bytes),
1364
+ })
1365
+ }
1366
+
1367
+ fn image_mime_for_path(path: &Path) -> Option<&'static str> {
1368
+ match path
1369
+ .extension()
1370
+ .and_then(|ext| ext.to_str())
1371
+ .map(|ext| ext.to_ascii_lowercase())
1372
+ .as_deref()
1373
+ {
1374
+ Some("png") => Some("image/png"),
1375
+ Some("jpg") | Some("jpeg") => Some("image/jpeg"),
1376
+ Some("webp") => Some("image/webp"),
1377
+ Some("gif") => Some("image/gif"),
1378
+ _ => None,
1379
+ }
1380
+ }
1381
+
1240
1382
  /// Try to grab an image from the system clipboard and return it base64-encoded.
1241
1383
  /// Only supported on macOS; returns None on other platforms or when no image is present.
1242
1384
  #[cfg(target_os = "macos")]
1243
1385
  fn grab_clipboard_image() -> Option<ImageAttachment> {
1244
- let tmp = format!("/tmp/anveesa_clip_{}.png", std::process::id());
1386
+ read_clipboard_image().ok()
1387
+ }
1245
1388
 
1246
- // AppleScript: cast clipboard to PNG and write to a temp file.
1389
+ /// Try to grab an image from the system clipboard and return it base64-encoded.
1390
+ #[cfg(target_os = "macos")]
1391
+ fn read_clipboard_image() -> Result<ImageAttachment> {
1392
+ if let Ok(bytes) = read_clipboard_class_bytes("PNGf", "png") {
1393
+ return Ok(ImageAttachment {
1394
+ mime: "image/png".to_string(),
1395
+ data: BASE64.encode(&bytes),
1396
+ });
1397
+ }
1398
+
1399
+ if let Ok(bytes) = read_clipboard_class_bytes("JPEG", "jpg") {
1400
+ return Ok(ImageAttachment {
1401
+ mime: "image/jpeg".to_string(),
1402
+ data: BASE64.encode(&bytes),
1403
+ });
1404
+ }
1405
+
1406
+ if let Ok(tiff) = read_clipboard_class_bytes("TIFF", "tiff") {
1407
+ let png = convert_tiff_to_png(&tiff)?;
1408
+ return Ok(ImageAttachment {
1409
+ mime: "image/png".to_string(),
1410
+ data: BASE64.encode(&png),
1411
+ });
1412
+ }
1413
+
1414
+ bail!("clipboard does not contain PNG, JPEG, or TIFF image data")
1415
+ }
1416
+
1417
+ #[cfg(target_os = "macos")]
1418
+ fn read_clipboard_class_bytes(class_code: &str, extension: &str) -> Result<Vec<u8>> {
1419
+ let tmp = std::env::temp_dir().join(format!(
1420
+ "anveesa_clip_{}_{}.{}",
1421
+ std::process::id(),
1422
+ class_code,
1423
+ extension
1424
+ ));
1425
+ let tmp_display = tmp.display();
1247
1426
  let script = format!(
1248
1427
  "try\n\
1249
- set d to (the clipboard as \u{00AB}class PNGf\u{00BB})\n\
1250
- set f to open for access POSIX file \"{tmp}\" with write permission\n\
1428
+ set d to (the clipboard as \u{00AB}class {class_code}\u{00BB})\n\
1429
+ set f to open for access POSIX file \"{tmp_display}\" with write permission\n\
1251
1430
  write d to f\n\
1252
1431
  close access f\n\
1253
1432
  return \"ok\"\n\
@@ -1260,23 +1439,52 @@ fn grab_clipboard_image() -> Option<ImageAttachment> {
1260
1439
  .arg("-e")
1261
1440
  .arg(&script)
1262
1441
  .output()
1263
- .ok()?;
1442
+ .context("failed to read macOS clipboard with osascript")?;
1264
1443
 
1265
1444
  if String::from_utf8_lossy(&out.stdout).trim() != "ok" {
1266
- return None;
1445
+ let _ = fs::remove_file(&tmp);
1446
+ bail!("clipboard does not contain {class_code} image data");
1267
1447
  }
1268
1448
 
1269
- let bytes = std::fs::read(&tmp).ok()?;
1270
- let _ = std::fs::remove_file(&tmp);
1449
+ let bytes = fs::read(&tmp).with_context(|| format!("failed to read {tmp_display}"))?;
1450
+ let _ = fs::remove_file(&tmp);
1271
1451
 
1272
1452
  if bytes.len() < 8 {
1273
- return None;
1453
+ bail!("clipboard {class_code} image data is empty");
1274
1454
  }
1275
1455
 
1276
- Some(ImageAttachment {
1277
- mime: "image/png".to_string(),
1278
- data: BASE64.encode(&bytes),
1279
- })
1456
+ Ok(bytes)
1457
+ }
1458
+
1459
+ #[cfg(target_os = "macos")]
1460
+ fn convert_tiff_to_png(tiff: &[u8]) -> Result<Vec<u8>> {
1461
+ let base = std::env::temp_dir().join(format!("anveesa_clip_{}", std::process::id()));
1462
+ let tiff_path = base.with_extension("tiff");
1463
+ let png_path = base.with_extension("png");
1464
+ fs::write(&tiff_path, tiff).context("failed to write temporary TIFF clipboard image")?;
1465
+
1466
+ let status = std::process::Command::new("sips")
1467
+ .arg("-s")
1468
+ .arg("format")
1469
+ .arg("png")
1470
+ .arg(&tiff_path)
1471
+ .arg("--out")
1472
+ .arg(&png_path)
1473
+ .status()
1474
+ .context("failed to convert clipboard TIFF to PNG with sips")?;
1475
+
1476
+ let _ = fs::remove_file(&tiff_path);
1477
+ if !status.success() {
1478
+ let _ = fs::remove_file(&png_path);
1479
+ bail!("failed to convert clipboard TIFF image to PNG");
1480
+ }
1481
+
1482
+ let bytes = fs::read(&png_path).context("failed to read converted clipboard PNG")?;
1483
+ let _ = fs::remove_file(&png_path);
1484
+ if bytes.len() < 8 {
1485
+ bail!("converted clipboard PNG is empty");
1486
+ }
1487
+ Ok(bytes)
1280
1488
  }
1281
1489
 
1282
1490
  #[cfg(not(target_os = "macos"))]
@@ -1284,6 +1492,11 @@ fn grab_clipboard_image() -> Option<ImageAttachment> {
1284
1492
  None
1285
1493
  }
1286
1494
 
1495
+ #[cfg(not(target_os = "macos"))]
1496
+ fn read_clipboard_image() -> Result<ImageAttachment> {
1497
+ bail!("clipboard image attachment is only supported on macOS; use /attach path/to/image.png")
1498
+ }
1499
+
1287
1500
  fn repl_history_path() -> Option<PathBuf> {
1288
1501
  let path = config_path().ok()?;
1289
1502
  let dir = path.parent()?;
@@ -1496,6 +1709,32 @@ mod tests {
1496
1709
  );
1497
1710
  }
1498
1711
 
1712
+ #[test]
1713
+ fn parses_attach_commands() {
1714
+ assert_eq!(parse_attach_command("/attach"), Some(None));
1715
+ assert_eq!(
1716
+ parse_attach_command("/attach screenshot.png"),
1717
+ Some(Some("screenshot.png".into()))
1718
+ );
1719
+ assert_eq!(
1720
+ parse_attach_command("/attach \"folder/my image.jpg\""),
1721
+ Some(Some("folder/my image.jpg".into()))
1722
+ );
1723
+ assert_eq!(
1724
+ parse_attach_command("/img '/tmp/capture.webp'"),
1725
+ Some(Some("/tmp/capture.webp".into()))
1726
+ );
1727
+ assert_eq!(parse_attach_command("/attachment nope"), None);
1728
+ }
1729
+
1730
+ #[test]
1731
+ fn detects_image_mime_from_path() {
1732
+ assert_eq!(image_mime_for_path(Path::new("a.png")), Some("image/png"));
1733
+ assert_eq!(image_mime_for_path(Path::new("a.JPEG")), Some("image/jpeg"));
1734
+ assert_eq!(image_mime_for_path(Path::new("a.webp")), Some("image/webp"));
1735
+ assert_eq!(image_mime_for_path(Path::new("a.txt")), None);
1736
+ }
1737
+
1499
1738
  #[test]
1500
1739
  fn interactive_session_matches_same_scope_only() {
1501
1740
  let options = AskOptions {
@@ -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
  }