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 +1 -1
- package/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/lib.rs +38 -1
- 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/package.json
CHANGED
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,
|
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
|
}
|