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 +1 -1
- package/Cargo.toml +1 -1
- package/README.md +20 -0
- package/package.json +1 -1
- package/src/lib.rs +259 -20
- package/src/provider/mod.rs +2 -0
- package/src/provider/openai_compatible.rs +83 -7
- package/src/tools.rs +44 -1
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
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
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
|
-
//
|
|
165
|
-
|
|
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:
|
|
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
|
-
|
|
1386
|
+
read_clipboard_image().ok()
|
|
1387
|
+
}
|
|
1245
1388
|
|
|
1246
|
-
|
|
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
|
|
1250
|
-
set f to open for access POSIX file \"{
|
|
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
|
-
.
|
|
1442
|
+
.context("failed to read macOS clipboard with osascript")?;
|
|
1264
1443
|
|
|
1265
1444
|
if String::from_utf8_lossy(&out.stdout).trim() != "ok" {
|
|
1266
|
-
|
|
1445
|
+
let _ = fs::remove_file(&tmp);
|
|
1446
|
+
bail!("clipboard does not contain {class_code} image data");
|
|
1267
1447
|
}
|
|
1268
1448
|
|
|
1269
|
-
let bytes =
|
|
1270
|
-
let _ =
|
|
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
|
-
|
|
1453
|
+
bail!("clipboard {class_code} image data is empty");
|
|
1274
1454
|
}
|
|
1275
1455
|
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
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 {
|
package/src/provider/mod.rs
CHANGED
|
@@ -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(
|
|
751
|
+
Err(error) => {
|
|
689
752
|
consecutive_errors += 1;
|
|
690
753
|
if consecutive_errors >= MAX_CONSECUTIVE_ERRORS {
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
}
|