anveesa 0.2.2 → 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/README.md +5 -3
- package/bin/anveesa.js +5 -5
- package/package.json +4 -5
- package/scripts/install.js +86 -33
- package/src/lib.rs +52 -1
- package/src/provider/mod.rs +2 -0
- package/src/provider/openai_compatible.rs +85 -6
- package/src/tools.rs +44 -1
- package/.github/workflows/release.yml +0 -68
- package/bin/anveesa +0 -0
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/README.md
CHANGED
|
@@ -10,17 +10,19 @@ Anveesa can be published as an npm package via a Node.js wrapper that invokes th
|
|
|
10
10
|
### Install from npm
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
npm install -g anveesa
|
|
13
|
+
npm install -g anveesa
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
### Build and publish
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
|
-
|
|
19
|
+
git tag v$(node -p "require('./package.json').version")
|
|
20
|
+
git push origin main --tags
|
|
20
21
|
npm publish
|
|
21
22
|
```
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
Wait for the GitHub release binary workflow to finish before publishing to npm.
|
|
25
|
+
See `npm-publish.md` for the full release checklist.
|
|
24
26
|
|
|
25
27
|
- `openai-compatible`: HTTP chat completions providers such as OpenRouter and other compatible gateways.
|
|
26
28
|
- `command`: local CLIs such as Codex, Copilot, and Claude Code, where Anveesa spawns a command and passes the prompt.
|
package/bin/anveesa.js
CHANGED
|
@@ -8,14 +8,14 @@ function findBinary() {
|
|
|
8
8
|
const platform = process.platform;
|
|
9
9
|
const ext = platform === 'win32' ? '.exe' : '';
|
|
10
10
|
|
|
11
|
-
//
|
|
12
|
-
const bundled = path.join(__dirname, 'anveesa' + ext);
|
|
13
|
-
if (fs.existsSync(bundled)) return bundled;
|
|
14
|
-
|
|
15
|
-
// Also check for target/release (dev mode)
|
|
11
|
+
// Prefer the latest local release build in a development checkout.
|
|
16
12
|
const devPath = path.join(__dirname, '..', 'target', 'release', 'anveesa' + ext);
|
|
17
13
|
if (fs.existsSync(devPath)) return devPath;
|
|
18
14
|
|
|
15
|
+
// Installed npm packages keep the downloaded binary in the package bin/ directory.
|
|
16
|
+
const bundled = path.join(__dirname, 'anveesa' + ext);
|
|
17
|
+
if (fs.existsSync(bundled)) return bundled;
|
|
18
|
+
|
|
19
19
|
// Check if there's a sibling directory with the binary
|
|
20
20
|
const sibling = path.join(__dirname, 'target', 'release', 'anveesa' + ext);
|
|
21
21
|
if (fs.existsSync(sibling)) return sibling;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anveesa",
|
|
3
|
-
"version": "0.2.
|
|
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": {
|
|
@@ -12,13 +12,12 @@
|
|
|
12
12
|
"prepublishOnly": "npm run build"
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
|
-
"bin/",
|
|
16
|
-
"scripts/",
|
|
15
|
+
"bin/anveesa.js",
|
|
16
|
+
"scripts/install.js",
|
|
17
17
|
"Cargo.toml",
|
|
18
18
|
"Cargo.lock",
|
|
19
19
|
"src/",
|
|
20
|
-
"README.md"
|
|
21
|
-
".github/"
|
|
20
|
+
"README.md"
|
|
22
21
|
],
|
|
23
22
|
"keywords": [
|
|
24
23
|
"ai",
|
package/scripts/install.js
CHANGED
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
const fs = require('fs');
|
|
9
9
|
const path = require('path');
|
|
10
10
|
const https = require('https');
|
|
11
|
-
const {
|
|
11
|
+
const { execFileSync } = require('child_process');
|
|
12
12
|
|
|
13
13
|
const PACKAGE = require(path.join(__dirname, '..', 'package.json'));
|
|
14
14
|
const REPO = 'pandhuwibowo/anveesa-cli';
|
|
15
15
|
const BIN_DIR = path.join(__dirname, '..', 'bin');
|
|
16
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
16
17
|
|
|
17
18
|
function getPlatformInfo() {
|
|
18
19
|
const platform = process.platform;
|
|
@@ -32,6 +33,14 @@ function getPlatformInfo() {
|
|
|
32
33
|
return null;
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
function getExecutableName() {
|
|
37
|
+
return process.platform === 'win32' ? 'anveesa.exe' : 'anveesa';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getBinaryPath() {
|
|
41
|
+
return path.join(BIN_DIR, getExecutableName());
|
|
42
|
+
}
|
|
43
|
+
|
|
35
44
|
function getBinaryUrl() {
|
|
36
45
|
const info = getPlatformInfo();
|
|
37
46
|
if (!info) return null;
|
|
@@ -39,31 +48,47 @@ function getBinaryUrl() {
|
|
|
39
48
|
return `https://github.com/${REPO}/releases/download/v${version}/anveesa-${version}-${info.target}.tar.gz`;
|
|
40
49
|
}
|
|
41
50
|
|
|
42
|
-
function download(url, dest) {
|
|
51
|
+
function download(url, dest, redirects = 0) {
|
|
43
52
|
return new Promise((resolve, reject) => {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
reject(new Error(`HTTP ${res.statusCode}`));
|
|
53
|
+
const request = https.get(url, {
|
|
54
|
+
headers: { 'User-Agent': `anveesa-install/${PACKAGE.version}` },
|
|
55
|
+
}, (res) => {
|
|
56
|
+
if (REDIRECT_STATUSES.has(res.statusCode)) {
|
|
57
|
+
res.resume();
|
|
58
|
+
if (!res.headers.location) {
|
|
59
|
+
reject(new Error(`HTTP ${res.statusCode} without Location header`));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (redirects >= 5) {
|
|
63
|
+
reject(new Error('too many redirects'));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const nextUrl = new URL(res.headers.location, url).toString();
|
|
68
|
+
resolve(download(nextUrl, dest, redirects + 1));
|
|
69
|
+
return;
|
|
62
70
|
}
|
|
71
|
+
|
|
72
|
+
if (res.statusCode !== 200) {
|
|
73
|
+
res.resume();
|
|
74
|
+
reject(new Error(res.statusCode === 404 ? '404' : `HTTP ${res.statusCode}`));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const file = fs.createWriteStream(dest);
|
|
79
|
+
file.on('finish', () => { file.close(resolve); });
|
|
80
|
+
file.on('error', (err) => {
|
|
81
|
+
fs.unlink(dest, () => {});
|
|
82
|
+
reject(err);
|
|
83
|
+
});
|
|
84
|
+
res.pipe(file);
|
|
63
85
|
}).on('error', (err) => {
|
|
64
86
|
fs.unlink(dest, () => {});
|
|
65
87
|
reject(err);
|
|
66
88
|
});
|
|
89
|
+
request.setTimeout(30000, () => {
|
|
90
|
+
request.destroy(new Error('download timed out'));
|
|
91
|
+
});
|
|
67
92
|
});
|
|
68
93
|
}
|
|
69
94
|
|
|
@@ -76,18 +101,23 @@ async function tryDownloadBinary() {
|
|
|
76
101
|
|
|
77
102
|
console.log('⬇ Downloading prebuilt binary for', process.platform, process.arch);
|
|
78
103
|
|
|
104
|
+
const tarPath = path.join(__dirname, 'anveesa-bin.tar.gz');
|
|
79
105
|
try {
|
|
80
|
-
|
|
106
|
+
if (!fs.existsSync(BIN_DIR)) fs.mkdirSync(BIN_DIR, { recursive: true });
|
|
107
|
+
|
|
81
108
|
await download(url, tarPath);
|
|
82
109
|
|
|
83
110
|
// Extract
|
|
84
|
-
|
|
111
|
+
execFileSync('tar', ['xzf', tarPath, '-C', BIN_DIR], { stdio: 'inherit' });
|
|
85
112
|
fs.unlinkSync(tarPath);
|
|
86
113
|
|
|
87
114
|
// Make executable
|
|
88
|
-
const binary =
|
|
115
|
+
const binary = getBinaryPath();
|
|
89
116
|
if (fs.existsSync(binary)) {
|
|
90
|
-
fs.chmodSync(binary, 0o755);
|
|
117
|
+
if (process.platform !== 'win32') fs.chmodSync(binary, 0o755);
|
|
118
|
+
} else {
|
|
119
|
+
console.log('⚠ Downloaded archive did not contain', getExecutableName());
|
|
120
|
+
return false;
|
|
91
121
|
}
|
|
92
122
|
|
|
93
123
|
console.log('✓ Binary downloaded successfully');
|
|
@@ -96,6 +126,7 @@ async function tryDownloadBinary() {
|
|
|
96
126
|
if (e.message === '404') {
|
|
97
127
|
console.log('ℹ No prebuilt binary for this version yet');
|
|
98
128
|
}
|
|
129
|
+
fs.unlink(tarPath, () => {});
|
|
99
130
|
return false;
|
|
100
131
|
}
|
|
101
132
|
}
|
|
@@ -103,29 +134,45 @@ async function tryDownloadBinary() {
|
|
|
103
134
|
function tryBuildFromSource() {
|
|
104
135
|
try {
|
|
105
136
|
console.log('⚙ Building from source (requires Rust)...');
|
|
106
|
-
|
|
137
|
+
execFileSync('cargo', ['build', '--release'], { cwd: path.join(__dirname, '..'), stdio: 'inherit' });
|
|
107
138
|
|
|
108
|
-
const src = path.join(__dirname, '..', 'target', 'release',
|
|
109
|
-
const dest =
|
|
139
|
+
const src = path.join(__dirname, '..', 'target', 'release', getExecutableName());
|
|
140
|
+
const dest = getBinaryPath();
|
|
110
141
|
|
|
111
142
|
if (fs.existsSync(src)) {
|
|
112
143
|
if (!fs.existsSync(BIN_DIR)) fs.mkdirSync(BIN_DIR, { recursive: true });
|
|
113
144
|
fs.copyFileSync(src, dest);
|
|
114
|
-
fs.chmodSync(dest, 0o755);
|
|
145
|
+
if (process.platform !== 'win32') fs.chmodSync(dest, 0o755);
|
|
115
146
|
console.log('✓ Built from source successfully');
|
|
116
147
|
return true;
|
|
117
148
|
}
|
|
118
149
|
} catch (e) {
|
|
119
|
-
|
|
150
|
+
if (e.code === 'ENOENT') {
|
|
151
|
+
console.log('⚠ Build from source failed: cargo was not found');
|
|
152
|
+
} else {
|
|
153
|
+
console.log('⚠ Build from source failed');
|
|
154
|
+
}
|
|
120
155
|
}
|
|
121
156
|
return false;
|
|
122
157
|
}
|
|
123
158
|
|
|
159
|
+
function hasUsableExistingBinary() {
|
|
160
|
+
const existing = getBinaryPath();
|
|
161
|
+
if (!fs.existsSync(existing)) return false;
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
if (process.platform !== 'win32') fs.chmodSync(existing, 0o755);
|
|
165
|
+
execFileSync(existing, ['--help'], { stdio: 'ignore', timeout: 5000 });
|
|
166
|
+
return true;
|
|
167
|
+
} catch (e) {
|
|
168
|
+
console.log('ℹ Existing anveesa binary is not usable on this platform; replacing it');
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
124
173
|
async function install() {
|
|
125
174
|
// Check if binary already exists
|
|
126
|
-
|
|
127
|
-
const existingExe = path.join(BIN_DIR, 'anveesa.exe');
|
|
128
|
-
if (fs.existsSync(existing) || fs.existsSync(existingExe)) {
|
|
175
|
+
if (hasUsableExistingBinary()) {
|
|
129
176
|
console.log('✓ anveesa binary already installed');
|
|
130
177
|
return;
|
|
131
178
|
}
|
|
@@ -139,6 +186,12 @@ async function install() {
|
|
|
139
186
|
console.error('');
|
|
140
187
|
console.error('✗ Could not install anveesa binary.');
|
|
141
188
|
console.error('');
|
|
189
|
+
const url = getBinaryUrl();
|
|
190
|
+
if (url) {
|
|
191
|
+
console.error(`No prebuilt binary was available for anveesa v${PACKAGE.version}:`);
|
|
192
|
+
console.error(` ${url}`);
|
|
193
|
+
console.error('');
|
|
194
|
+
}
|
|
142
195
|
console.error('Install Rust: https://rustup.rs/');
|
|
143
196
|
console.error('Then run: npm install -g anveesa');
|
|
144
197
|
console.error('');
|
|
@@ -147,4 +200,4 @@ async function install() {
|
|
|
147
200
|
process.exit(1);
|
|
148
201
|
}
|
|
149
202
|
|
|
150
|
-
install();
|
|
203
|
+
install();
|
package/src/lib.rs
CHANGED
|
@@ -16,6 +16,7 @@ use clap::{CommandFactory, Parser};
|
|
|
16
16
|
use serde::{Deserialize, Serialize};
|
|
17
17
|
use tokio::sync::mpsc;
|
|
18
18
|
|
|
19
|
+
#[cfg(target_os = "macos")]
|
|
19
20
|
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
|
20
21
|
|
|
21
22
|
use crate::{
|
|
@@ -301,6 +302,7 @@ async fn render_stream(
|
|
|
301
302
|
let mut spinner_active = false;
|
|
302
303
|
let mut first_token = true;
|
|
303
304
|
let mut produced = false;
|
|
305
|
+
let mut line_open = false;
|
|
304
306
|
let mut usage: Option<Usage> = None;
|
|
305
307
|
let mut plan_tasks: Vec<String> = vec![];
|
|
306
308
|
let mut plan_done: Vec<bool> = vec![];
|
|
@@ -325,13 +327,29 @@ async fn render_stream(
|
|
|
325
327
|
first_token = false;
|
|
326
328
|
}
|
|
327
329
|
produced = true;
|
|
330
|
+
line_open = true;
|
|
328
331
|
print!("{text}");
|
|
329
332
|
let _ = io::stdout().flush();
|
|
330
333
|
}
|
|
331
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
|
+
}
|
|
332
346
|
Some(StreamEvent::Confirm { preview, reply }) => {
|
|
333
347
|
clear_spinner(spinner, spinner_active);
|
|
334
348
|
spinner_active = false;
|
|
349
|
+
if line_open {
|
|
350
|
+
println!();
|
|
351
|
+
line_open = false;
|
|
352
|
+
}
|
|
335
353
|
let decision = tokio::task::block_in_place(|| {
|
|
336
354
|
show_confirm_preview(&preview, spinner);
|
|
337
355
|
prompt_confirm_decision(spinner)
|
|
@@ -344,6 +362,10 @@ async fn render_stream(
|
|
|
344
362
|
Some(StreamEvent::FileOp { verb, path, added, removed, preview, truncated }) => {
|
|
345
363
|
clear_spinner(spinner, spinner_active);
|
|
346
364
|
spinner_active = false;
|
|
365
|
+
if line_open {
|
|
366
|
+
println!();
|
|
367
|
+
line_open = false;
|
|
368
|
+
}
|
|
347
369
|
print_file_op(&verb, &path, added, removed, &preview, truncated, spinner);
|
|
348
370
|
// Re-arm the spinner for the next API round.
|
|
349
371
|
first_token = true;
|
|
@@ -352,6 +374,10 @@ async fn render_stream(
|
|
|
352
374
|
Some(StreamEvent::PlanSet { tasks }) => {
|
|
353
375
|
clear_spinner(spinner, spinner_active);
|
|
354
376
|
spinner_active = false;
|
|
377
|
+
if line_open {
|
|
378
|
+
println!();
|
|
379
|
+
line_open = false;
|
|
380
|
+
}
|
|
355
381
|
plan_done = vec![false; tasks.len()];
|
|
356
382
|
plan_tasks = tasks;
|
|
357
383
|
print_plan_list(&plan_tasks, &plan_done, spinner);
|
|
@@ -361,6 +387,10 @@ async fn render_stream(
|
|
|
361
387
|
Some(StreamEvent::PlanTaskDone { index }) => {
|
|
362
388
|
clear_spinner(spinner, spinner_active);
|
|
363
389
|
spinner_active = false;
|
|
390
|
+
if line_open {
|
|
391
|
+
println!();
|
|
392
|
+
line_open = false;
|
|
393
|
+
}
|
|
364
394
|
if index < plan_done.len() {
|
|
365
395
|
plan_done[index] = true;
|
|
366
396
|
}
|
|
@@ -397,7 +427,7 @@ async fn render_stream(
|
|
|
397
427
|
}
|
|
398
428
|
}
|
|
399
429
|
|
|
400
|
-
if produced {
|
|
430
|
+
if produced && line_open {
|
|
401
431
|
println!();
|
|
402
432
|
} else {
|
|
403
433
|
clear_spinner(spinner, spinner_active);
|
|
@@ -425,6 +455,14 @@ async fn render_stream(
|
|
|
425
455
|
}
|
|
426
456
|
}
|
|
427
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
|
+
|
|
428
466
|
fn print_file_op(
|
|
429
467
|
verb: &str,
|
|
430
468
|
path: &str,
|
|
@@ -968,11 +1006,13 @@ impl PromptBuffer {
|
|
|
968
1006
|
}
|
|
969
1007
|
}
|
|
970
1008
|
|
|
1009
|
+
#[cfg(unix)]
|
|
971
1010
|
struct RawPromptMode {
|
|
972
1011
|
fd: i32,
|
|
973
1012
|
saved: libc::termios,
|
|
974
1013
|
}
|
|
975
1014
|
|
|
1015
|
+
#[cfg(unix)]
|
|
976
1016
|
impl RawPromptMode {
|
|
977
1017
|
fn enter() -> Result<Self> {
|
|
978
1018
|
let fd = libc::STDIN_FILENO;
|
|
@@ -1001,6 +1041,7 @@ impl RawPromptMode {
|
|
|
1001
1041
|
}
|
|
1002
1042
|
}
|
|
1003
1043
|
|
|
1044
|
+
#[cfg(unix)]
|
|
1004
1045
|
impl Drop for RawPromptMode {
|
|
1005
1046
|
fn drop(&mut self) {
|
|
1006
1047
|
print!("\x1b[?2004l");
|
|
@@ -1012,6 +1053,16 @@ impl Drop for RawPromptMode {
|
|
|
1012
1053
|
}
|
|
1013
1054
|
}
|
|
1014
1055
|
|
|
1056
|
+
#[cfg(not(unix))]
|
|
1057
|
+
struct RawPromptMode;
|
|
1058
|
+
|
|
1059
|
+
#[cfg(not(unix))]
|
|
1060
|
+
impl RawPromptMode {
|
|
1061
|
+
fn enter() -> Result<Self> {
|
|
1062
|
+
Ok(Self)
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1015
1066
|
fn read_prompt_line(label: &str, width: usize, paste_count: &mut usize) -> Result<PromptRead> {
|
|
1016
1067
|
let _raw_mode = RawPromptMode::enter()?;
|
|
1017
1068
|
let mut input = io::stdin().lock();
|
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
|
}
|
|
@@ -668,7 +731,7 @@ async fn stream_response(
|
|
|
668
731
|
|
|
669
732
|
loop {
|
|
670
733
|
let chunk_result = response.chunk().await;
|
|
671
|
-
|
|
734
|
+
|
|
672
735
|
match chunk_result {
|
|
673
736
|
Ok(Some(chunk)) => {
|
|
674
737
|
consecutive_errors = 0; // Reset error counter on successful read
|
|
@@ -685,12 +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
|
-
|
|
754
|
+
bail!(
|
|
755
|
+
"stream interrupted while reading provider response after {} consecutive errors: {}",
|
|
756
|
+
consecutive_errors,
|
|
757
|
+
error
|
|
758
|
+
);
|
|
694
759
|
}
|
|
695
760
|
// Try to continue reading - transient network hiccups happen
|
|
696
761
|
continue;
|
|
@@ -969,4 +1034,18 @@ mod tests {
|
|
|
969
1034
|
.contains("Do not call tools again")
|
|
970
1035
|
);
|
|
971
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
|
+
}
|
|
972
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
|
}
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
name: Release Binaries
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
tags:
|
|
6
|
-
- 'v*'
|
|
7
|
-
|
|
8
|
-
permissions:
|
|
9
|
-
contents: write
|
|
10
|
-
|
|
11
|
-
jobs:
|
|
12
|
-
build:
|
|
13
|
-
strategy:
|
|
14
|
-
matrix:
|
|
15
|
-
include:
|
|
16
|
-
- os: ubuntu-latest
|
|
17
|
-
target: x86_64-unknown-linux-gnu
|
|
18
|
-
- os: ubuntu-latest
|
|
19
|
-
target: aarch64-unknown-linux-gnu
|
|
20
|
-
- os: macos-latest
|
|
21
|
-
target: x86_64-apple-darwin
|
|
22
|
-
- os: macos-latest
|
|
23
|
-
target: aarch64-apple-darwin
|
|
24
|
-
- os: windows-latest
|
|
25
|
-
target: x86_64-pc-windows-msvc
|
|
26
|
-
|
|
27
|
-
runs-on: ${{ matrix.os }}
|
|
28
|
-
|
|
29
|
-
steps:
|
|
30
|
-
- uses: actions/checkout@v4
|
|
31
|
-
|
|
32
|
-
- name: Install Rust
|
|
33
|
-
uses: dtolnay/rust-action@stable
|
|
34
|
-
with:
|
|
35
|
-
targets: ${{ matrix.target }}
|
|
36
|
-
|
|
37
|
-
- name: Install cross-compilation tools (Linux ARM64)
|
|
38
|
-
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
|
39
|
-
run: |
|
|
40
|
-
sudo apt-get update
|
|
41
|
-
sudo apt-get install -y gcc-aarch64-linux-gnu
|
|
42
|
-
echo 'CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc' >> $GITHUB_ENV
|
|
43
|
-
|
|
44
|
-
- name: Build
|
|
45
|
-
run: cargo build --release --target ${{ matrix.target }}
|
|
46
|
-
|
|
47
|
-
- name: Package binary
|
|
48
|
-
shell: bash
|
|
49
|
-
run: |
|
|
50
|
-
VERSION=${GITHUB_REF_NAME#v}
|
|
51
|
-
BIN_DIR=release-artifacts
|
|
52
|
-
mkdir -p $BIN_DIR
|
|
53
|
-
|
|
54
|
-
if [[ "${{ matrix.os }}" == "windows-latest" ]]; then
|
|
55
|
-
cp target/${{ matrix.target }}/release/anveesa.exe $BIN_DIR/anveesa.exe
|
|
56
|
-
cd $BIN_DIR && tar czf ../anveesa-$VERSION-${{ matrix.target }}.tar.gz anveesa.exe
|
|
57
|
-
else
|
|
58
|
-
cp target/${{ matrix.target }}/release/anveesa $BIN_DIR/anveesa
|
|
59
|
-
chmod +x $BIN_DIR/anveesa
|
|
60
|
-
cd $BIN_DIR && tar czf ../anveesa-$VERSION-${{ matrix.target }}.tar.gz anveesa
|
|
61
|
-
fi
|
|
62
|
-
|
|
63
|
-
- name: Upload to Release
|
|
64
|
-
uses: softprops/action-gh-release@v2
|
|
65
|
-
with:
|
|
66
|
-
files: anveesa-*.tar.gz
|
|
67
|
-
env:
|
|
68
|
-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
package/bin/anveesa
DELETED
|
Binary file
|