@armadra/agent 0.7.4 → 0.7.6
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/CHANGELOG.md +32 -0
- package/CHANGELOG.zh-CN.md +32 -0
- package/dist/agent/session-classifier.d.ts +2 -0
- package/dist/agent/session-classifier.js +24 -3
- package/dist/agent/session-extensions.d.ts +1 -0
- package/dist/agent/session-extensions.js +3 -0
- package/dist/agent/session-subagent.js +9 -2
- package/dist/agent/subagent-background.d.ts +5 -1
- package/dist/agent/subagent-background.js +13 -1
- package/dist/agent/subagent-fork.d.ts +10 -5
- package/dist/agent/subagent-fork.js +22 -6
- package/dist/agent/subagent-registry.d.ts +1 -1
- package/dist/agent/subagent-registry.js +1 -1
- package/dist/agents/catalog.d.ts +1 -0
- package/dist/ai/apis/anthropic-request.js +6 -1
- package/dist/ai/apis/cache-params.js +2 -1
- package/dist/ai/apis/google-request.js +2 -1
- package/dist/ai/apis/max-tokens.d.ts +4 -0
- package/dist/ai/apis/max-tokens.js +12 -1
- package/dist/ai/apis/openai-request.js +3 -1
- package/dist/ai/apis/openai-responses-request.js +2 -1
- package/dist/ai/json-body.d.ts +33 -1
- package/dist/ai/json-body.js +96 -18
- package/dist/ai/providers/enrich.d.ts +6 -2
- package/dist/ai/providers/enrich.js +13 -2
- package/dist/ai/providers/max-tokens-cache.d.ts +22 -0
- package/dist/ai/providers/max-tokens-cache.js +95 -0
- package/dist/ai/providers/registry.js +3 -3
- package/dist/bundle/ama.cjs +982 -442
- package/dist/checkpoints/shadow-git.d.ts +5 -1
- package/dist/checkpoints/shadow-git.js +11 -2
- package/dist/cli/bootstrap.js +13 -5
- package/dist/cli/compose-providers.d.ts +1 -0
- package/dist/cli/compose-providers.js +3 -0
- package/dist/cli/compose-store.d.ts +6 -2
- package/dist/cli/compose-store.js +8 -4
- package/dist/cli/compose.js +2 -2
- package/dist/cli/deps.d.ts +1 -1
- package/dist/config/json-schema.js +1 -0
- package/dist/config/key-docs.js +1 -0
- package/dist/config/schema-w5.js +2 -0
- package/dist/config/settings-registry.js +1 -0
- package/dist/config/types-w5.d.ts +5 -0
- package/dist/i18n/catalog.d.ts +2 -0
- package/dist/i18n/messages/config-keys.d.ts +2 -0
- package/dist/i18n/messages/config-keys.js +2 -0
- package/dist/i18n/messages/config.d.ts +2 -0
- package/dist/i18n/messages/interactive.d.ts +2 -0
- package/dist/i18n/messages/interactive.js +2 -0
- package/dist/i18n/messages/session.js +2 -2
- package/dist/modes/acp/acp-server.js +17 -15
- package/dist/modes/acp/acp-sessions.d.ts +14 -1
- package/dist/modes/acp/acp-sessions.js +44 -1
- package/dist/modes/interactive/agent-ui.js +7 -0
- package/dist/session/manager.d.ts +18 -0
- package/dist/session/manager.js +75 -14
- package/dist/session/offload.d.ts +59 -0
- package/dist/session/offload.js +218 -0
- package/dist/session/store.d.ts +12 -4
- package/dist/session/store.js +18 -8
- package/dist/tools/background-jobs.d.ts +4 -4
- package/dist/tools/background-jobs.js +4 -4
- package/dist/tools/bash.d.ts +2 -1
- package/dist/tools/bash.js +4 -3
- package/dist/tools/read-lines.d.ts +39 -0
- package/dist/tools/read-lines.js +163 -86
- package/dist/tools/read.js +3 -3
- package/dist/tools/task-ctl.js +2 -1
- package/dist/tools/task.d.ts +9 -0
- package/dist/tools/task.js +27 -1
- package/docs/acp.md +6 -3
- package/docs/agents.md +14 -3
- package/docs/en/acp.md +2 -2
- package/docs/en/permissions.md +1 -1
- package/docs/en/providers.md +4 -4
- package/docs/en/sessions.md +2 -1
- package/docs/permissions.md +1 -1
- package/docs/providers.md +9 -5
- package/docs/session-format.md +11 -0
- package/docs/sessions.md +2 -1
- package/package.json +1 -1
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* 这里只拿到 ChildProcess;stdout / stderr 直接写进 `outputPath`(文件描述符交给子进程,ama 退出也不
|
|
6
6
|
* 会因管道断开而 SIGPIPE)。POSIX 自成进程组并登记(进程退出时同步 SIGKILL 残留组)。
|
|
7
7
|
* - `wait(id, timeoutMs)`:等到退出或超时;`stop(id)`:杀整棵树(SIGTERM → 宽限 → SIGKILL);
|
|
8
|
-
* `output(id)
|
|
8
|
+
* `output(id, options?)`:读输出文件尾部(缺省 2000 行 / 50 KB,调用方可传字节上限)。
|
|
9
9
|
* - 生命周期事件经 `onChange` 发出(started / exited / stopped);`takeExited()` 取走尚未通知的已退出
|
|
10
10
|
* 任务(提醒通道 reminders.ts 据此告诉模型「后台命令 jobId 已退出(码 N)」)。
|
|
11
11
|
* - 按会话 id 登记(`jobsForSession`);`disposeSessionJobs` 在会话 dispose 时回收进程树。
|
|
@@ -119,8 +119,8 @@ export class BackgroundJobs {
|
|
|
119
119
|
await entry.done;
|
|
120
120
|
return entry.job;
|
|
121
121
|
}
|
|
122
|
-
/**
|
|
123
|
-
output(id) {
|
|
122
|
+
/** 输出文件尾部(缺省 2000 行 / 50 KB;调用方可传字节上限,#155)。 */
|
|
123
|
+
output(id, options = {}) {
|
|
124
124
|
const entry = this.entries.get(id);
|
|
125
125
|
if (entry === undefined)
|
|
126
126
|
return undefined;
|
|
@@ -132,7 +132,7 @@ export class BackgroundJobs {
|
|
|
132
132
|
catch {
|
|
133
133
|
// 输出文件被删:按空输出
|
|
134
134
|
}
|
|
135
|
-
return { ...truncateTail(text), job: entry.job };
|
|
135
|
+
return { ...truncateTail(text, options), job: entry.job };
|
|
136
136
|
}
|
|
137
137
|
/** 取走已退出、尚未通知的任务(提醒通道用;wait 已看到退出的也算已知)。 */
|
|
138
138
|
takeExited() {
|
package/dist/tools/bash.d.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* - 注入 `AMA_*` 环境变量(shell.ts)。shell 退出后不等仍占着管道的后台进程。
|
|
10
10
|
* - [W5-H2] 后台命令(§8.3 H6,不加新工具):`{ command, background: true }` 立即返回
|
|
11
11
|
* `{ jobId, outputPath, pid }`;`{ job, action: "wait" | "output" | "stop" }` 查询 / 结束
|
|
12
|
-
* (background-jobs.ts,按会话登记,会话 dispose
|
|
12
|
+
* (background-jobs.ts,按会话登记,会话 dispose 时回收进程树;退出经提醒通道通知)。查询输出同样按
|
|
13
|
+
* 会话结果上限尾截断(#155)。
|
|
13
14
|
* - **进程只在 `spawnShell` 里创建**(前台与后台共用)。[S2] `sandbox.bash: auto` 且有能限制写入的
|
|
14
15
|
* OS 沙箱时在这一处包一层(src/sandbox/bash.ts);`sandbox: false` 不包装(权限管线照常审批)。
|
|
15
16
|
* 失败输出像是被沙箱拒绝时末尾追加一行说明。
|
package/dist/tools/bash.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* - 注入 `AMA_*` 环境变量(shell.ts)。shell 退出后不等仍占着管道的后台进程。
|
|
10
10
|
* - [W5-H2] 后台命令(§8.3 H6,不加新工具):`{ command, background: true }` 立即返回
|
|
11
11
|
* `{ jobId, outputPath, pid }`;`{ job, action: "wait" | "output" | "stop" }` 查询 / 结束
|
|
12
|
-
* (background-jobs.ts,按会话登记,会话 dispose
|
|
12
|
+
* (background-jobs.ts,按会话登记,会话 dispose 时回收进程树;退出经提醒通道通知)。查询输出同样按
|
|
13
|
+
* 会话结果上限尾截断(#155)。
|
|
13
14
|
* - **进程只在 `spawnShell` 里创建**(前台与后台共用)。[S2] `sandbox.bash: auto` 且有能限制写入的
|
|
14
15
|
* OS 沙箱时在这一处包一层(src/sandbox/bash.ts);`sandbox: false` 不包装(权限管线照常审批)。
|
|
15
16
|
* 失败输出像是被沙箱拒绝时末尾追加一行说明。
|
|
@@ -304,7 +305,7 @@ async function jobAction(input, ctx, options) {
|
|
|
304
305
|
}
|
|
305
306
|
if (action === "stop") {
|
|
306
307
|
const job = (await jobs.stop(id));
|
|
307
|
-
const out = jobs.output(id);
|
|
308
|
+
const out = jobs.output(id, { maxBytes: toolOutputBytes(ctx.maxResultChars) });
|
|
308
309
|
const text = out?.content ?? "";
|
|
309
310
|
return {
|
|
310
311
|
content: `${text === "" ? "(no output)" : text}\n\n${jobStatusLine(job)}`,
|
|
@@ -317,7 +318,7 @@ async function jobAction(input, ctx, options) {
|
|
|
317
318
|
return fail(`timeoutMs must be between 1 and ${MAX_BASH_TIMEOUT_MS}`);
|
|
318
319
|
await jobs.wait(id, waitMs, ctx.signal);
|
|
319
320
|
}
|
|
320
|
-
const out = jobs.output(id);
|
|
321
|
+
const out = jobs.output(id, { maxBytes: toolOutputBytes(ctx.maxResultChars) });
|
|
321
322
|
if (out === undefined)
|
|
322
323
|
return fail(`Unknown background job ${id}`);
|
|
323
324
|
const job = out.job;
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
* - 按行解码:终止符都是 ASCII,不会落在多字节序列中间,无效字节的替换结果与整段解码相同;
|
|
11
11
|
* - 窗口内单行超过 `maxBytes + LINE_SLACK` 字节时只留开头(这一行必然触发字节截断,
|
|
12
12
|
* `truncateHead` 至多取它开头 `maxBytes` 字节),超长行不会整行进内存。
|
|
13
|
+
*
|
|
14
|
+
* 解析状态机是 `LineScanner`,同步版 `readLineWindow` 与异步版 `readLineWindowAsync` 共用(#171):
|
|
15
|
+
* 异步版每块一次 `await`,大文件扫描期间不独占事件循环;峰值内存相同(仍一块缓冲)。
|
|
13
16
|
*/
|
|
14
17
|
/** 超过这个大小的文本文件走字节窗口;不超过的保留整读路径。 */
|
|
15
18
|
export declare const STREAM_READ_THRESHOLD: number;
|
|
@@ -25,8 +28,44 @@ export interface LineWindow {
|
|
|
25
28
|
}
|
|
26
29
|
/** 文件开头至多 `bytes` 字节(一次 `readSync`,用于二进制嗅探)。 */
|
|
27
30
|
export declare function readHead(abs: string, bytes: number): Buffer;
|
|
31
|
+
/** `readHead` 的异步版(#171)。 */
|
|
32
|
+
export declare function readHeadAsync(abs: string, bytes: number): Promise<Buffer>;
|
|
33
|
+
/**
|
|
34
|
+
* 按块喂入文件字节的行扫描状态机:第 `first` 行(0 起)到第 `stop` 行(不含)之间的行被收集,
|
|
35
|
+
* 收集的行累计 `字节 + 1` 超过 `maxBytes` 后停止收集(含越界的那一行),其余行只计数。
|
|
36
|
+
*/
|
|
37
|
+
export declare class LineScanner {
|
|
38
|
+
private readonly first;
|
|
39
|
+
private readonly stop;
|
|
40
|
+
private readonly maxBytes;
|
|
41
|
+
private readonly lineCap;
|
|
42
|
+
private readonly lines;
|
|
43
|
+
private truncatedBy;
|
|
44
|
+
private collected;
|
|
45
|
+
private index;
|
|
46
|
+
private parts;
|
|
47
|
+
private partBytes;
|
|
48
|
+
private pending;
|
|
49
|
+
private skipLF;
|
|
50
|
+
private bom;
|
|
51
|
+
private firstChunk;
|
|
52
|
+
constructor(first: number, stop: number, maxBytes: number);
|
|
53
|
+
/**
|
|
54
|
+
* 处理 `buf[0, end)`;`eof` 表示这是最后一块。返回调用方下次读取前应保留在 `buf` 开头的字节数
|
|
55
|
+
* (只在首块不足 3 字节、还判断不了 BOM 时非零)。
|
|
56
|
+
*/
|
|
57
|
+
feed(buf: Buffer, end: number, eof: boolean): number;
|
|
58
|
+
/** 文件读完:收尾最后一行并给出结果。 */
|
|
59
|
+
finish(): LineWindow;
|
|
60
|
+
private collecting;
|
|
61
|
+
private take;
|
|
62
|
+
private endLine;
|
|
63
|
+
}
|
|
28
64
|
/**
|
|
29
65
|
* 读取 `abs` 第 `offset` 行(1 起)开始的至多 `limit` 行(`undefined` 不限,`0` 只计数);
|
|
30
66
|
* 收集的行累计 `字节 + 1` 超过 `maxBytes` 后停止收集(含越界的那一行)。`chunkBytes` 只供测试。
|
|
67
|
+
* 同步版,保留给单测与口径比对;`read` 工具用 `readLineWindowAsync`。
|
|
31
68
|
*/
|
|
32
69
|
export declare function readLineWindow(abs: string, offset: number, limit: number | undefined, maxBytes: number, chunkBytes?: number): LineWindow;
|
|
70
|
+
/** `readLineWindow` 的异步版(#171):每块一次 `await`,结果与同步版相同。 */
|
|
71
|
+
export declare function readLineWindowAsync(abs: string, offset: number, limit: number | undefined, maxBytes: number, chunkBytes?: number): Promise<LineWindow>;
|
package/dist/tools/read-lines.js
CHANGED
|
@@ -10,8 +10,12 @@
|
|
|
10
10
|
* - 按行解码:终止符都是 ASCII,不会落在多字节序列中间,无效字节的替换结果与整段解码相同;
|
|
11
11
|
* - 窗口内单行超过 `maxBytes + LINE_SLACK` 字节时只留开头(这一行必然触发字节截断,
|
|
12
12
|
* `truncateHead` 至多取它开头 `maxBytes` 字节),超长行不会整行进内存。
|
|
13
|
+
*
|
|
14
|
+
* 解析状态机是 `LineScanner`,同步版 `readLineWindow` 与异步版 `readLineWindowAsync` 共用(#171):
|
|
15
|
+
* 异步版每块一次 `await`,大文件扫描期间不独占事件循环;峰值内存相同(仍一块缓冲)。
|
|
13
16
|
*/
|
|
14
17
|
import { closeSync, openSync, readSync } from "node:fs";
|
|
18
|
+
import { open } from "node:fs/promises";
|
|
15
19
|
/** 超过这个大小的文本文件走字节窗口;不超过的保留整读路径。 */
|
|
16
20
|
export const STREAM_READ_THRESHOLD = 1024 * 1024;
|
|
17
21
|
const CHUNK_BYTES = 64 * 1024;
|
|
@@ -37,111 +41,184 @@ export function readHead(abs, bytes) {
|
|
|
37
41
|
closeSync(fd);
|
|
38
42
|
}
|
|
39
43
|
}
|
|
44
|
+
/** `readHead` 的异步版(#171)。 */
|
|
45
|
+
export async function readHeadAsync(abs, bytes) {
|
|
46
|
+
const handle = await open(abs, "r");
|
|
47
|
+
try {
|
|
48
|
+
const buf = Buffer.alloc(bytes);
|
|
49
|
+
let got = 0;
|
|
50
|
+
while (got < bytes) {
|
|
51
|
+
const { bytesRead } = await handle.read(buf, got, bytes - got, null);
|
|
52
|
+
if (bytesRead === 0)
|
|
53
|
+
break;
|
|
54
|
+
got += bytesRead;
|
|
55
|
+
}
|
|
56
|
+
return buf.subarray(0, got);
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
await handle.close();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
40
62
|
/**
|
|
41
|
-
*
|
|
42
|
-
* 收集的行累计 `字节 + 1` 超过 `maxBytes`
|
|
63
|
+
* 按块喂入文件字节的行扫描状态机:第 `first` 行(0 起)到第 `stop` 行(不含)之间的行被收集,
|
|
64
|
+
* 收集的行累计 `字节 + 1` 超过 `maxBytes` 后停止收集(含越界的那一行),其余行只计数。
|
|
43
65
|
*/
|
|
44
|
-
export
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
export class LineScanner {
|
|
67
|
+
first;
|
|
68
|
+
stop;
|
|
69
|
+
maxBytes;
|
|
70
|
+
lineCap;
|
|
71
|
+
lines = [];
|
|
72
|
+
truncatedBy;
|
|
73
|
+
collected = 0;
|
|
74
|
+
index = 0; // 已完成的行数
|
|
75
|
+
parts = [];
|
|
76
|
+
partBytes = 0;
|
|
77
|
+
pending = false; // 最后一个终止符之后已有字节
|
|
78
|
+
skipLF = false; // 上一块以 `\r` 结尾:下一块开头的 `\n` 属于同一终止符
|
|
79
|
+
bom = false;
|
|
80
|
+
firstChunk = true;
|
|
81
|
+
constructor(first, stop, maxBytes) {
|
|
82
|
+
this.first = first;
|
|
83
|
+
this.stop = stop;
|
|
84
|
+
this.maxBytes = maxBytes;
|
|
85
|
+
this.lineCap = maxBytes + LINE_SLACK;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* 处理 `buf[0, end)`;`eof` 表示这是最后一块。返回调用方下次读取前应保留在 `buf` 开头的字节数
|
|
89
|
+
* (只在首块不足 3 字节、还判断不了 BOM 时非零)。
|
|
90
|
+
*/
|
|
91
|
+
feed(buf, end, eof) {
|
|
92
|
+
let pos = 0;
|
|
93
|
+
if (this.firstChunk) {
|
|
94
|
+
if (end < 3 && !eof)
|
|
95
|
+
return end;
|
|
96
|
+
this.firstChunk = false;
|
|
97
|
+
if (end >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
|
|
98
|
+
this.bom = true;
|
|
99
|
+
pos = 3;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (this.skipLF && pos < end) {
|
|
103
|
+
if (buf[pos] === LF)
|
|
104
|
+
pos++;
|
|
105
|
+
this.skipLF = false;
|
|
106
|
+
}
|
|
107
|
+
let nextLF = buf.indexOf(LF, pos);
|
|
108
|
+
let nextCR = buf.indexOf(CR, pos);
|
|
109
|
+
while (pos < end) {
|
|
110
|
+
if (nextLF !== -1 && nextLF < pos)
|
|
111
|
+
nextLF = buf.indexOf(LF, pos);
|
|
112
|
+
if (nextCR !== -1 && nextCR < pos)
|
|
113
|
+
nextCR = buf.indexOf(CR, pos);
|
|
114
|
+
const lf = nextLF === -1 || nextLF >= end ? end : nextLF;
|
|
115
|
+
const cr = nextCR === -1 || nextCR >= end ? end : nextCR;
|
|
116
|
+
const at = Math.min(lf, cr);
|
|
117
|
+
if (at > pos) {
|
|
118
|
+
this.pending = true;
|
|
119
|
+
if (this.collecting())
|
|
120
|
+
this.take(buf, pos, at);
|
|
121
|
+
}
|
|
122
|
+
if (at === end)
|
|
123
|
+
break;
|
|
124
|
+
this.endLine();
|
|
125
|
+
pos = at + 1;
|
|
126
|
+
if (at === cr) {
|
|
127
|
+
if (pos < end) {
|
|
128
|
+
if (buf[pos] === LF)
|
|
129
|
+
pos++;
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
this.skipLF = true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
/** 文件读完:收尾最后一行并给出结果。 */
|
|
139
|
+
finish() {
|
|
140
|
+
if (this.pending)
|
|
141
|
+
this.endLine();
|
|
142
|
+
const { lines, truncatedBy, bom } = this;
|
|
143
|
+
return { lines, totalLines: this.index, ...(truncatedBy ? { truncatedBy } : {}), bom };
|
|
144
|
+
}
|
|
145
|
+
collecting() {
|
|
146
|
+
return this.truncatedBy === undefined && this.index >= this.first && this.index < this.stop;
|
|
147
|
+
}
|
|
148
|
+
take(buf, from, to) {
|
|
149
|
+
if (to <= from || this.partBytes >= this.lineCap)
|
|
60
150
|
return;
|
|
61
|
-
const end = Math.min(to, from + lineCap - partBytes);
|
|
62
|
-
parts.push(Buffer.from(buf.subarray(from, end)));
|
|
63
|
-
partBytes += end - from;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
if (collecting()) {
|
|
151
|
+
const end = Math.min(to, from + this.lineCap - this.partBytes);
|
|
152
|
+
this.parts.push(Buffer.from(buf.subarray(from, end)));
|
|
153
|
+
this.partBytes += end - from;
|
|
154
|
+
}
|
|
155
|
+
endLine() {
|
|
156
|
+
if (this.collecting()) {
|
|
157
|
+
const { parts } = this;
|
|
67
158
|
const line = (parts.length === 1 ? parts[0] : Buffer.concat(parts)).toString("utf8");
|
|
68
|
-
lines.push(line);
|
|
69
|
-
collected += Buffer.byteLength(line) + 1;
|
|
70
|
-
if (collected > maxBytes)
|
|
71
|
-
truncatedBy = "bytes";
|
|
159
|
+
this.lines.push(line);
|
|
160
|
+
this.collected += Buffer.byteLength(line) + 1;
|
|
161
|
+
if (this.collected > this.maxBytes)
|
|
162
|
+
this.truncatedBy = "bytes";
|
|
72
163
|
}
|
|
73
|
-
if (partBytes > 0)
|
|
74
|
-
parts = []; // 窗口外的行不分配:40 万行时每行一个空数组就是十几 MB 临时垃圾
|
|
75
|
-
partBytes = 0;
|
|
76
|
-
pending = false;
|
|
77
|
-
index++;
|
|
78
|
-
}
|
|
164
|
+
if (this.partBytes > 0)
|
|
165
|
+
this.parts = []; // 窗口外的行不分配:40 万行时每行一个空数组就是十几 MB 临时垃圾
|
|
166
|
+
this.partBytes = 0;
|
|
167
|
+
this.pending = false;
|
|
168
|
+
this.index++;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function scanner(offset, limit, maxBytes) {
|
|
172
|
+
const first = offset - 1;
|
|
173
|
+
return new LineScanner(first, limit === undefined ? Infinity : first + limit, maxBytes);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* 读取 `abs` 第 `offset` 行(1 起)开始的至多 `limit` 行(`undefined` 不限,`0` 只计数);
|
|
177
|
+
* 收集的行累计 `字节 + 1` 超过 `maxBytes` 后停止收集(含越界的那一行)。`chunkBytes` 只供测试。
|
|
178
|
+
* 同步版,保留给单测与口径比对;`read` 工具用 `readLineWindowAsync`。
|
|
179
|
+
*/
|
|
180
|
+
export function readLineWindow(abs, offset, limit, maxBytes, chunkBytes = CHUNK_BYTES) {
|
|
181
|
+
const scan = scanner(offset, limit, maxBytes);
|
|
79
182
|
const size = Math.max(3, chunkBytes);
|
|
80
183
|
const fd = openSync(abs, "r");
|
|
81
184
|
try {
|
|
82
185
|
const buf = Buffer.alloc(size);
|
|
83
186
|
let carry = 0; // 首块凑满 3 字节判断 BOM
|
|
84
|
-
let firstChunk = true;
|
|
85
187
|
for (;;) {
|
|
86
188
|
const n = readSync(fd, buf, carry, size - carry, null);
|
|
87
189
|
const end = carry + n;
|
|
88
|
-
|
|
89
|
-
if (n === 0 && end === 0)
|
|
190
|
+
if (end === 0)
|
|
90
191
|
break;
|
|
91
|
-
|
|
92
|
-
if (firstChunk) {
|
|
93
|
-
if (end < 3 && n > 0) {
|
|
94
|
-
carry = end;
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
firstChunk = false;
|
|
98
|
-
if (end >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
|
|
99
|
-
bom = true;
|
|
100
|
-
pos = 3;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
if (skipLF && pos < end) {
|
|
104
|
-
if (buf[pos] === LF)
|
|
105
|
-
pos++;
|
|
106
|
-
skipLF = false;
|
|
107
|
-
}
|
|
108
|
-
let nextLF = buf.indexOf(LF, pos);
|
|
109
|
-
let nextCR = buf.indexOf(CR, pos);
|
|
110
|
-
while (pos < end) {
|
|
111
|
-
if (nextLF !== -1 && nextLF < pos)
|
|
112
|
-
nextLF = buf.indexOf(LF, pos);
|
|
113
|
-
if (nextCR !== -1 && nextCR < pos)
|
|
114
|
-
nextCR = buf.indexOf(CR, pos);
|
|
115
|
-
const lf = nextLF === -1 || nextLF >= end ? end : nextLF;
|
|
116
|
-
const cr = nextCR === -1 || nextCR >= end ? end : nextCR;
|
|
117
|
-
const at = Math.min(lf, cr);
|
|
118
|
-
if (at > pos) {
|
|
119
|
-
pending = true;
|
|
120
|
-
if (collecting())
|
|
121
|
-
take(buf, pos, at);
|
|
122
|
-
}
|
|
123
|
-
if (at === end)
|
|
124
|
-
break;
|
|
125
|
-
finish();
|
|
126
|
-
pos = at + 1;
|
|
127
|
-
if (at === cr) {
|
|
128
|
-
if (pos < end) {
|
|
129
|
-
if (buf[pos] === LF)
|
|
130
|
-
pos++;
|
|
131
|
-
}
|
|
132
|
-
else {
|
|
133
|
-
skipLF = true;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
192
|
+
carry = scan.feed(buf, end, n === 0);
|
|
137
193
|
if (n === 0)
|
|
138
194
|
break;
|
|
139
195
|
}
|
|
140
|
-
if (pending)
|
|
141
|
-
finish();
|
|
142
196
|
}
|
|
143
197
|
finally {
|
|
144
198
|
closeSync(fd);
|
|
145
199
|
}
|
|
146
|
-
return
|
|
200
|
+
return scan.finish();
|
|
201
|
+
}
|
|
202
|
+
/** `readLineWindow` 的异步版(#171):每块一次 `await`,结果与同步版相同。 */
|
|
203
|
+
export async function readLineWindowAsync(abs, offset, limit, maxBytes, chunkBytes = CHUNK_BYTES) {
|
|
204
|
+
const scan = scanner(offset, limit, maxBytes);
|
|
205
|
+
const size = Math.max(3, chunkBytes);
|
|
206
|
+
const handle = await open(abs, "r");
|
|
207
|
+
try {
|
|
208
|
+
const buf = Buffer.alloc(size);
|
|
209
|
+
let carry = 0;
|
|
210
|
+
for (;;) {
|
|
211
|
+
const { bytesRead: n } = await handle.read(buf, carry, size - carry, null);
|
|
212
|
+
const end = carry + n;
|
|
213
|
+
if (end === 0)
|
|
214
|
+
break;
|
|
215
|
+
carry = scan.feed(buf, end, n === 0);
|
|
216
|
+
if (n === 0)
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
await handle.close();
|
|
222
|
+
}
|
|
223
|
+
return scan.finish();
|
|
147
224
|
}
|
package/dist/tools/read.js
CHANGED
|
@@ -13,7 +13,7 @@ import { internImage } from "../ai/image-intern.js";
|
|
|
13
13
|
import { displayPath, resolvePath } from "./paths.js";
|
|
14
14
|
import { DEFAULT_MAX_LINES, formatSize, toolOutputBytes, truncateHead } from "./truncate.js";
|
|
15
15
|
import { normalizeToLF, splitBom } from "./edit-fuzzy.js";
|
|
16
|
-
import { STREAM_READ_THRESHOLD,
|
|
16
|
+
import { STREAM_READ_THRESHOLD, readHeadAsync, readLineWindowAsync } from "./read-lines.js";
|
|
17
17
|
import { MAX_IMAGE_FILE_BYTES, fitImage, imageMimeFromPath, imageSize, sniffImageMime, } from "./image-file.js";
|
|
18
18
|
export { imageSize } from "./image-file.js";
|
|
19
19
|
const SNIFF_BYTES = 8000;
|
|
@@ -99,10 +99,10 @@ export async function executeRead(input, ctx, options = {}) {
|
|
|
99
99
|
let selected;
|
|
100
100
|
if (info.size > (options.streamThreshold ?? STREAM_READ_THRESHOLD)) {
|
|
101
101
|
// [M-E] 大文件按字节窗口读:只解码要显示的行,结果与整读逐字节相同(D1)
|
|
102
|
-
if (isBinary(
|
|
102
|
+
if (isBinary(await readHeadAsync(abs, SNIFF_BYTES)))
|
|
103
103
|
return error(binaryMessage(shown));
|
|
104
104
|
const want = Math.min(input.limit ?? Infinity, DEFAULT_MAX_LINES + 1);
|
|
105
|
-
const win =
|
|
105
|
+
const win = await readLineWindowAsync(abs, badOffset ? 1 : offset, badOffset || badLimit ? 0 : want, maxBytes);
|
|
106
106
|
totalLines = win.totalLines;
|
|
107
107
|
selected = win.lines;
|
|
108
108
|
}
|
package/dist/tools/task-ctl.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { capTaskText, formatTokens, waitDetachedText } from "../agents/result.js";
|
|
14
14
|
import { taskControl } from "../agents/task-control.js";
|
|
15
|
+
import { nestedTaskRejection } from "./task.js";
|
|
15
16
|
export const TASK_CTL_TOOL = "task_ctl";
|
|
16
17
|
export const DEFAULT_WAIT_MS = 30_000;
|
|
17
18
|
export const MAX_WAIT_MS = 600_000;
|
|
@@ -66,7 +67,7 @@ export function createTaskCtlTool() {
|
|
|
66
67
|
annotations: { pollable: true },
|
|
67
68
|
async execute(input, ctx) {
|
|
68
69
|
if (ctx.depth >= 1)
|
|
69
|
-
return fail("task_ctl is not available inside a sub-agent.");
|
|
70
|
+
return fail(nestedTaskRejection(ctx, "task_ctl", "task_ctl is not available inside a sub-agent."));
|
|
70
71
|
const control = taskControl(ctx.sessionId);
|
|
71
72
|
if (input.action === "list") {
|
|
72
73
|
const tasks = control?.list() ?? ctx.tasks?.list() ?? [];
|
package/dist/tools/task.d.ts
CHANGED
|
@@ -20,6 +20,15 @@ import type { ModelThinkingLevel } from "../ai/types.js";
|
|
|
20
20
|
import { AgentCatalog } from "../agents/catalog.js";
|
|
21
21
|
import type { SubagentRequest, ToolContext, ToolDefinition } from "./types.js";
|
|
22
22
|
export declare const MAX_TASK_DEPTH = 1;
|
|
23
|
+
/** <task> 指令首个非空行,超长截断(#191:fork 子会话被拒时提醒模型回到子任务)。 */
|
|
24
|
+
export declare function taskLine(prompt: string): string;
|
|
25
|
+
/** fork 子会话的标记条目(custom,不进上下文):`{ taskLine }`。 */
|
|
26
|
+
export declare const FORK_CUSTOM_TYPE = "ama.fork";
|
|
27
|
+
/**
|
|
28
|
+
* 子会话里 task / task_ctl 的拒绝文案(固定英文)。#191:fork 子会话(有 `ama.fork` 条目)说明身份并
|
|
29
|
+
* 附 <task> 首行;其它情况用 `fallback`(fresh 按深度拒绝的原文案)。
|
|
30
|
+
*/
|
|
31
|
+
export declare function nestedTaskRejection(ctx: ToolContext, tool: string, fallback: string): string;
|
|
23
32
|
export declare const DEFAULT_TASK_MAX_TURNS = 30;
|
|
24
33
|
export interface TaskInput {
|
|
25
34
|
prompt: string;
|
package/dist/tools/task.js
CHANGED
|
@@ -19,6 +19,32 @@
|
|
|
19
19
|
import { AgentCatalog } from "../agents/catalog.js";
|
|
20
20
|
import { TASK_NOTIFICATION_RULE } from "../agents/result.js";
|
|
21
21
|
export const MAX_TASK_DEPTH = 1;
|
|
22
|
+
/** fork 子会话首条 custom 条目里 <task> 指令首行摘要的上限(字符)。 */
|
|
23
|
+
const TASK_LINE_MAX = 120;
|
|
24
|
+
/** <task> 指令首个非空行,超长截断(#191:fork 子会话被拒时提醒模型回到子任务)。 */
|
|
25
|
+
export function taskLine(prompt) {
|
|
26
|
+
const line = prompt
|
|
27
|
+
.split("\n")
|
|
28
|
+
.find((text) => text.trim() !== "")
|
|
29
|
+
?.trim() ?? "";
|
|
30
|
+
return line.length > TASK_LINE_MAX ? `${line.slice(0, TASK_LINE_MAX - 1)}…` : line;
|
|
31
|
+
}
|
|
32
|
+
/** fork 子会话的标记条目(custom,不进上下文):`{ taskLine }`。 */
|
|
33
|
+
export const FORK_CUSTOM_TYPE = "ama.fork";
|
|
34
|
+
/**
|
|
35
|
+
* 子会话里 task / task_ctl 的拒绝文案(固定英文)。#191:fork 子会话(有 `ama.fork` 条目)说明身份并
|
|
36
|
+
* 附 <task> 首行;其它情况用 `fallback`(fresh 按深度拒绝的原文案)。
|
|
37
|
+
*/
|
|
38
|
+
export function nestedTaskRejection(ctx, tool, fallback) {
|
|
39
|
+
const mark = ctx.session.lastCustom(FORK_CUSTOM_TYPE);
|
|
40
|
+
if (mark === undefined || mark === null)
|
|
41
|
+
return fallback;
|
|
42
|
+
const line = typeof mark.taskLine === "string" ? mark.taskLine : "";
|
|
43
|
+
return (`${tool} rejected: you are a sub-agent forked from the main conversation and cannot start ` +
|
|
44
|
+
"or manage sub-agents. Do not call task or task_ctl again; complete the sub-task in your " +
|
|
45
|
+
"<task> block yourself with your other tools, then end with your report." +
|
|
46
|
+
(line !== "" ? ` Your task begins: "${line}"` : ""));
|
|
47
|
+
}
|
|
22
48
|
export const DEFAULT_TASK_MAX_TURNS = 30;
|
|
23
49
|
const THINKING = [
|
|
24
50
|
"off",
|
|
@@ -143,7 +169,7 @@ export function createTaskTool(_options = {}) {
|
|
|
143
169
|
promptGuidelines: [TASK_NOTIFICATION_RULE],
|
|
144
170
|
async execute(input, ctx) {
|
|
145
171
|
if (ctx.depth >= MAX_TASK_DEPTH || ctx.spawnSubagent === undefined) {
|
|
146
|
-
return fail("Sub-agents are not available here (nested tasks are not allowed).");
|
|
172
|
+
return fail(nestedTaskRejection(ctx, "task", "Sub-agents are not available here (nested tasks are not allowed)."));
|
|
147
173
|
}
|
|
148
174
|
const request = buildSubagentRequest(input, ctx);
|
|
149
175
|
if (typeof request === "string")
|
package/docs/acp.md
CHANGED
|
@@ -49,10 +49,13 @@ ama --mode acp # 与 -p 互斥;其余参数(--model、-
|
|
|
49
49
|
再应用到权限管线,并再发一条 `current_mode_update`。所以对排队中的会话改模式不会影响正在跑的那个。
|
|
50
50
|
- 后台子 Agent 完成后的通知回合不经这个队列,在会话空闲时自己开始;它在跑时,对同一会话的 `session/prompt` 也等它
|
|
51
51
|
结束再开始(不报 busy),等待期间客户端先收到通知回合的 `session/update`。等待中 `session/cancel` 会中断通知回合,
|
|
52
|
-
这条提示回 `cancelled
|
|
52
|
+
这条提示回 `cancelled`,之后也不会再被发出。这条提示答复里的 `usage` 只算它自己的回合,不含之前的通知回合。
|
|
53
53
|
- 每个回合结束后发 `session_info_update`(`updatedAt`,标题变了才带 `title`)。
|
|
54
|
-
- 关掉一个会话只释放它(跑 SessionEnd Hook),它的转录、图片与子 Agent
|
|
55
|
-
|
|
54
|
+
- 关掉一个会话只释放它(跑 SessionEnd Hook),它的转录、图片与子 Agent 句柄随之可被回收,进程启动时建的那个会话
|
|
55
|
+
也一样。关闭前先让它安静下来:它的排队提示回 `cancelled`、在跑的后台子 Agent 停掉(不再发完成通知)、在跑的回合
|
|
56
|
+
(含通知回合)被中断,已排着的通知也不再开回合;`session/close` 之后该会话不再有 `session/update`。被中断的
|
|
57
|
+
通知回合与 `session/cancel` 一样在会话文件里留下中断标记。stdin 关闭时排队的提示回 `cancelled`,等在跑的提示结束,
|
|
58
|
+
通知回合与后台子 Agent 按上面的方式停下,再依次释放全部会话。
|
|
56
59
|
|
|
57
60
|
### 事件映射
|
|
58
61
|
|
package/docs/agents.md
CHANGED
|
@@ -158,12 +158,15 @@ worktree 里与父 cwd 对应的目录。结束时没有改动(工作区干净
|
|
|
158
158
|
看得到父的计划、提醒与读过的文件内容,但 `read` 记录从空开始(编辑前照旧要先读)。
|
|
159
159
|
- 类型声明了 `tools` / `disallowed-tools`(或参数给了 `tools`)时,工具表不变,不可用的工具在执行层拒绝
|
|
160
160
|
(`Tool "X" is not available in this session.`),并在 `<task>` 里列出。只读类型照旧走只读管线。
|
|
161
|
+
- `<task>` 同时列出按深度拒绝的 `task` / `task_ctl` 并要求不要调用它们(执行层仍是深度拒绝的文案)。实测仍有模型把父消息里
|
|
162
|
+
「调用 task」的指令当成自己的:调一次被拒,多一轮请求,有时随后只回报失败、不做子任务(见 [benchmarks](benchmarks/efficiency-2026-10.md)「F2」)。
|
|
161
163
|
- `isolation: "worktree"` 时子会话的 cwd 是 worktree,`<task>` 里说明上文的相对路径指父目录;cwd 节变化以尾部补丁发送,
|
|
162
164
|
不影响已缓存的前缀。
|
|
163
165
|
|
|
164
166
|
以下情况回落为 fresh(日志记一条 info,结果 `details.context` 标 `fresh`):调用参数或类型指定了与父不同的模型或思考级别;
|
|
165
|
-
父会话还没有发出过请求;父上一次请求的输入超过(窗口 − `compaction.reserveTokens
|
|
166
|
-
照原文件重开,不受影响。只在请求了 fork 时 `details.context`
|
|
167
|
+
父会话还没有发出过请求;父上一次请求的输入超过(窗口 − `compaction.reserveTokens`)× `subagents.forkMaxContextRatio`
|
|
168
|
+
(缺省 0.5)。续聊(`taskId`)与 resume 照原文件重开,不受影响。只在请求了 fork 时 `details.context` 才出现;后台任务的
|
|
169
|
+
`running` 结果也带它,任务排队或 worktree 隔离尚未就绪时该结果不带,`get_tasks` / 完成通知的 `TaskInfo.context` 仍有。
|
|
167
170
|
|
|
168
171
|
何时用 fork:子任务需要你已经读过、讨论过的内容时。fork 的首个请求把父上下文整段带上,按命中价计费;fresh 只发系统提示、
|
|
169
172
|
工具表与任务说明,但你得在 `prompt` 里写清全部背景,子 Agent 往往还要重新读文件。
|
|
@@ -175,7 +178,14 @@ worktree 里与父 cwd 对应的目录。结束时没有改动(工作区干净
|
|
|
175
178
|
| OpenAI 系(经中转,不发路由键) | 两次分别 0% 与 85%,取决于中转把请求路由到哪个上游 | 视中转而定 |
|
|
176
179
|
| Anthropic(1/10,写入 1.25×) | 未实测;前缀相同,命中应与父的下一回合相同 | 需要父上下文时 |
|
|
177
180
|
|
|
178
|
-
|
|
181
|
+
fork 的成本不止首请求:子会话**每一回合**都重读整段父前缀。按命中价折算,8k 父上下文 × 30 回合在 DeepSeek(1/50)上约
|
|
182
|
+
4.8k 全价 token,在 Kimi / Anthropic(1/4–1/10)上约 24k–60k,中转落空时全价。所以 DeepSeek 上子任务只要重读 ≥ 1 份
|
|
183
|
+
已读文件就划算;Kimi / Anthropic 上只在子任务**主要**依赖父上下文时用;经不发路由键的中转不建议。窗口很大的模型上可以调低
|
|
184
|
+
`subagents.forkMaxContextRatio`,免得几十万 token 的父上下文被每回合重读。
|
|
185
|
+
|
|
186
|
+
`explore` 这类只需要定位文件的任务建议保持 fresh;内置类型(包括 `general`)都缺省 fresh:`general` 的典型任务(按指令
|
|
187
|
+
改某处代码)通常不需要父上下文、回合又多,而且长会话里 fork 会按上面的比例静默回落,缺省 fork 会让同一类型在不同时刻拿到
|
|
188
|
+
不同的上下文。需要时在调用参数或类型定义里写 `context: fork`。
|
|
179
189
|
|
|
180
190
|
### 事件与统计
|
|
181
191
|
|
|
@@ -191,6 +201,7 @@ RPC `get_tasks` / `get_agents` 返回任务快照与可用类型(来源、定
|
|
|
191
201
|
| `subagents.maxPending` | 排队上限,缺省 16 |
|
|
192
202
|
| `subagents.retainSessions` | 保留在内存里的已结束子会话数(LRU),缺省 4,0 = 结束即释放;只认用户级 |
|
|
193
203
|
| `subagents.defaultModel` | 子 Agent 缺省模型,不设继承父会话 |
|
|
204
|
+
| `subagents.forkMaxContextRatio` | fork 回落比例(见「fork 模式」),0.05–0.95,缺省 0.5;只认用户级 |
|
|
194
205
|
| `subagents.background` | `auto`(缺省)\| `always` \| `never`,见「前台与后台」;用户 / 项目 / 宿主级都认 |
|
|
195
206
|
| `subagents.autoBackgroundAfterMs` | 前台任务运行超过该毫秒数自动转后台,缺省 0(关闭);用户 / 项目级都认 |
|
|
196
207
|
| `agents.dirs` | 追加的定义目录 |
|
package/docs/en/acp.md
CHANGED
|
@@ -47,9 +47,9 @@ One `ama --mode acp` process can keep several sessions open (Zed's threads share
|
|
|
47
47
|
- **One turn runs at a time**: while another session runs, `session/prompt` goes into a FIFO queue and starts when the previous turn ends; it no longer fails busy. `session/new`, `load`, `resume`, `list`, `set_mode`, `set_config_option` and `close` can be called at any time.
|
|
48
48
|
- When a turn starts, its session becomes the "foreground" session: the host (`HostApi.session.*`), the common fields of hooks and the session seen by tools all switch to it, and "allowed for this session" grants are cleared (switching back means allowing again, as with `/resume` in the TUI).
|
|
49
49
|
- Permission modes are kept per session: `set_mode` on the foreground session applies at once; on another session it is only recorded (a `current_mode_update` is still sent) and applied to the permission pipeline when that session's turn starts, with another `current_mode_update`. So changing the mode of a queued session does not affect the one that is running.
|
|
50
|
-
- The notification turn that follows a finished background subagent does not go through this queue; it starts on its own once the session is idle. While it runs, `session/prompt` on the same session also waits for it to end (no busy error), and the client sees the notification turn's `session/update` first. `session/cancel` while waiting interrupts the notification turn; the prompt answers `cancelled` and is not sent later either.
|
|
50
|
+
- The notification turn that follows a finished background subagent does not go through this queue; it starts on its own once the session is idle. While it runs, `session/prompt` on the same session also waits for it to end (no busy error), and the client sees the notification turn's `session/update` first. `session/cancel` while waiting interrupts the notification turn; the prompt answers `cancelled` and is not sent later either. The `usage` in that prompt's answer covers only its own turn, not the notification turn before it.
|
|
51
51
|
- After every turn a `session_info_update` is sent (`updatedAt`; `title` only when it changed).
|
|
52
|
-
- Closing a session releases only that session (running the SessionEnd hook), and its transcript, images and subagent handles can then be garbage-collected
|
|
52
|
+
- Closing a session releases only that session (running the SessionEnd hook), and its transcript, images and subagent handles can then be garbage-collected, including the session created at process start. The session is quieted first: its queued prompts answer `cancelled`, running background subagents are stopped (no completion notification), the running turn (notification turns included) is interrupted and notifications already queued no longer start a turn; after `session/close` that session sends no more `session/update`. An interrupted notification turn leaves an interruption marker in the session file, as `session/cancel` does. When stdin closes, queued prompts answer `cancelled`, running prompts are allowed to finish, notification turns and background subagents are stopped as above, then all sessions are released in turn.
|
|
53
53
|
|
|
54
54
|
### Event mapping
|
|
55
55
|
|
package/docs/en/permissions.md
CHANGED
|
@@ -172,7 +172,7 @@ It only handles calls neither ① nor ② decided (e.g. `rm old.txt`, `node scri
|
|
|
172
172
|
- **A separate request**: it never enters the session transcript and does not change the main session's messages or prefix (the main session's prompt cache is unaffected), and does not trigger warming; the request purpose is `purpose: "classify"`.
|
|
173
173
|
- **Input**: tool name, arguments (JSON, truncated to 4000 characters), cwd, project root, and a summary of the latest user message (truncated to 600 characters). Arguments and the user message sit inside a `<tool_call_data>` … `</tool_call_data>` data block, with end markers inside the block escaped; the system prompt requires treating everything inside as data and ignoring instructions in it (including things like "ignore previous instructions" or "respond allow"), leaning to ask when such text appears.
|
|
174
174
|
- **Output**: strict JSON `{"decision":"allow"|"ask","reason":"…"}`. A parse failure, a timeout (10 s) or a request error → ask.
|
|
175
|
-
- **Model**: `permission.autoModel` (`provider/model`); when unset (or not found), the small model from the session provider's catalog (the file-level `small`: deepseek → `deepseek-flash`, anthropic → `claude-haiku-4-5`, openai → `gpt-6-luna`, google → `gemini-3.5-flash-lite`, moonshot → `kimi-k2.6` and so on; it must be found and have a key), otherwise the current session model; the chosen model is logged once at debug level.
|
|
175
|
+
- **Model**: `permission.autoModel` (`provider/model`); when unset (or not found), the small model from the session provider's catalog (the file-level `small`: deepseek → `deepseek-flash`, anthropic → `claude-haiku-4-5`, openai → `gpt-6-luna`, google → `gemini-3.5-flash-lite`, moonshot → `kimi-k2.6` and so on; it must be found and have a key), otherwise the current session model; the chosen model is logged once at debug level. For relays and custom providers: when the session model inherits an official catalog entry by id and the provider's model table (config `models[]`, the discovery cache) lists a small model of the same vendor (one that also inherits that catalog's `small` entry by id, e.g. a packy `deepseek-v4-pro` session → `deepseek-v4-flash`), that model is used, otherwise the session model; entries synthesized outside the model table are never used. Setting `permission.autoModel` is still recommended. A cheap, fast model is recommended, e.g. `packy/qwen3.8-flash`. `maxTokens` 256, thinking off.
|
|
176
176
|
- **Caching**: successful decisions are cached within the session by "tool name + normalized arguments" (bash collapses whitespace, others use JSON with sorted keys), so identical calls are classified once; errors and timeouts are not cached.
|
|
177
177
|
- **Cost**: each classification records a `usage` entry with `kind: "permission_classify"`, counted in `/session` cost and RPC stats, never in the context.
|
|
178
178
|
- The classifier can only judge undecided calls as allow or ask; it cannot overturn ①'s deny or ask.
|