@adecore/agents 0.31.8 → 0.31.10
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/dist/chat/acp-backend.d.ts +1 -0
- package/dist/chat/acp-backend.js +7 -3
- package/dist/chat/acp-protocol.d.ts +10 -2
- package/dist/chat/acp-protocol.js +81 -18
- package/dist/chat/chat-session.js +11 -0
- package/dist/chat/fixtures/copilot-1.0.101-denied.json +1050 -0
- package/dist/chat/fixtures/copilot-1.0.101-turn.json +1 -0
- package/dist/chat/fixtures/grok-1.0.62-denied.json +630 -0
- package/dist/chat/fixtures/grok-1.0.62-usage.json +475 -0
- package/dist/providers/acp-agent-state.js +14 -1
- package/dist/testing/fake-acp-capture.d.ts +2 -1
- package/dist/testing/fake-acp-capture.js +29 -9
- package/dist/usage/scanner.d.ts +6 -0
- package/dist/usage/scanner.js +101 -74
- package/package.json +3 -3
- package/src/chat/acp-backend.ts +10 -3
- package/src/chat/acp-protocol.ts +90 -21
- package/src/chat/chat-session.ts +11 -0
- package/src/chat/fixtures/copilot-1.0.101-denied.json +1050 -0
- package/src/chat/fixtures/copilot-1.0.101-turn.json +1 -0
- package/src/chat/fixtures/grok-1.0.62-denied.json +630 -0
- package/src/chat/fixtures/grok-1.0.62-usage.json +475 -0
- package/src/providers/acp-agent-state.ts +15 -1
- package/src/testing/fake-acp-capture.ts +34 -11
- package/src/usage/scanner.ts +117 -81
package/dist/usage/scanner.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { mkdir, open, readFile, readdir, stat } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import { Readable } from 'node:stream';
|
|
5
|
+
import { createZstdDecompress } from 'node:zlib';
|
|
5
6
|
import { errorText } from '../error-text.js';
|
|
6
7
|
import { isNotFound, writeAtomic } from '../fs.js';
|
|
7
8
|
import { decodeIndex, encodeIndex } from './index-file.js';
|
|
@@ -12,41 +13,58 @@ import { hostLogMightCarryUsage, parseHostUsageLine } from './readers/host-log.j
|
|
|
12
13
|
import { cloneCodexState, codexMightCarryUsage, createCodexState, parseCodexLine } from './readers/codex.js';
|
|
13
14
|
import { usageRoots } from './roots.js';
|
|
14
15
|
const HASH_CHUNK_BYTES = 65536;
|
|
16
|
+
// How much of a transcript is read at once. Codex writes sessions of more than a gigabyte, which read whole held several times their size.
|
|
17
|
+
const READ_CHUNK_BYTES = 1 << 20;
|
|
15
18
|
const CODEX_PARSER_VERSION = 2;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
*/
|
|
20
|
-
async function readHashed(path, from) {
|
|
19
|
+
const NEWLINE = 0x0a;
|
|
20
|
+
/* Hashes the first `length` bytes of a file into `hash`, a chunk at a time. */
|
|
21
|
+
async function hashPrefix(path, length, hash) {
|
|
21
22
|
const handle = await open(path, 'r');
|
|
22
23
|
try {
|
|
23
|
-
const
|
|
24
|
-
const chunk = Buffer.alloc(Math.min(from, HASH_CHUNK_BYTES));
|
|
24
|
+
const chunk = Buffer.alloc(Math.min(length, HASH_CHUNK_BYTES));
|
|
25
25
|
let offset = 0;
|
|
26
|
-
while (offset <
|
|
27
|
-
const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length,
|
|
26
|
+
while (offset < length) {
|
|
27
|
+
const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, length - offset), offset);
|
|
28
28
|
if (bytesRead === 0) {
|
|
29
29
|
break;
|
|
30
30
|
}
|
|
31
31
|
hash.update(chunk.subarray(0, bytesRead));
|
|
32
32
|
offset += bytesRead;
|
|
33
33
|
}
|
|
34
|
-
|
|
35
|
-
const bytes = Buffer.alloc(Math.max(0, size - from));
|
|
36
|
-
let filled = 0;
|
|
37
|
-
while (filled < bytes.length) {
|
|
38
|
-
const { bytesRead } = await handle.read(bytes, filled, bytes.length - filled, from + filled);
|
|
39
|
-
if (bytesRead === 0) {
|
|
40
|
-
break;
|
|
41
|
-
}
|
|
42
|
-
filled += bytesRead;
|
|
43
|
-
}
|
|
44
|
-
return { bytes: bytes.subarray(0, filled), hash };
|
|
34
|
+
return hash;
|
|
45
35
|
}
|
|
46
36
|
finally {
|
|
47
37
|
await handle.close();
|
|
48
38
|
}
|
|
49
39
|
}
|
|
40
|
+
/*
|
|
41
|
+
* Hands every whole line of a byte stream to `onLine` and hashes it with its newline, holding no more
|
|
42
|
+
* than a chunk and the line it is in. Lines are split on the byte, which never occurs inside a UTF-8 sequence.
|
|
43
|
+
*/
|
|
44
|
+
export async function forEachLine(chunks, hash, onLine) {
|
|
45
|
+
let consumed = 0;
|
|
46
|
+
let pending = [];
|
|
47
|
+
for await (const piece of chunks) {
|
|
48
|
+
const chunk = Buffer.from(piece.buffer, piece.byteOffset, piece.byteLength);
|
|
49
|
+
let start = 0;
|
|
50
|
+
let newline = chunk.indexOf(NEWLINE, start);
|
|
51
|
+
while (newline !== -1) {
|
|
52
|
+
const end = newline + 1;
|
|
53
|
+
const line = pending.length === 0 ? chunk.subarray(start, end) : Buffer.concat([...pending, chunk.subarray(start, end)]);
|
|
54
|
+
pending = [];
|
|
55
|
+
hash.update(line);
|
|
56
|
+
consumed += line.length;
|
|
57
|
+
onLine(line.toString('utf8', 0, line.length - 1));
|
|
58
|
+
start = end;
|
|
59
|
+
newline = chunk.indexOf(NEWLINE, start);
|
|
60
|
+
}
|
|
61
|
+
if (start < chunk.length) {
|
|
62
|
+
// A copy: the stream may hand the same memory out again for its next chunk.
|
|
63
|
+
pending.push(Buffer.from(chunk.subarray(start)));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { consumed, rest: Buffer.concat(pending).toString('utf8') };
|
|
67
|
+
}
|
|
50
68
|
/* Covers every committed byte, since a transcript can be rewritten after an unchanged session header. */
|
|
51
69
|
function headOf(hash) {
|
|
52
70
|
// Older indexes hashed only the first 4 KB; they cannot establish that the rest stayed unchanged.
|
|
@@ -79,54 +97,68 @@ async function listUsageFiles(dir, provider, out = []) {
|
|
|
79
97
|
}
|
|
80
98
|
return out;
|
|
81
99
|
}
|
|
100
|
+
/* The bytes of a file from `from` to its end, a chunk at a time. */
|
|
101
|
+
async function* fileChunks(path, from) {
|
|
102
|
+
const handle = await open(path, 'r');
|
|
103
|
+
try {
|
|
104
|
+
let position = from;
|
|
105
|
+
for (;;) {
|
|
106
|
+
// A buffer per chunk: a decompressor may still hold the one before.
|
|
107
|
+
const chunk = Buffer.alloc(READ_CHUNK_BYTES);
|
|
108
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, position);
|
|
109
|
+
if (bytesRead === 0) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
position += bytesRead;
|
|
113
|
+
yield chunk.subarray(0, bytesRead);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
await handle.close();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/* The bytes of a file from `from` on, decompressed for a `.zst` one; a failure on either side ends the iteration with it. */
|
|
121
|
+
function readBytes(path, from, compressed) {
|
|
122
|
+
if (!compressed) {
|
|
123
|
+
return fileChunks(path, from);
|
|
124
|
+
}
|
|
125
|
+
const source = Readable.from(fileChunks(path, from));
|
|
126
|
+
const decompressed = createZstdDecompress();
|
|
127
|
+
source.on('error', (error) => decompressed.destroy(error));
|
|
128
|
+
return source.pipe(decompressed);
|
|
129
|
+
}
|
|
130
|
+
/* The usage record of one transcript line, when it carries one. */
|
|
131
|
+
function parseLine(raw, provider, codex) {
|
|
132
|
+
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
|
|
133
|
+
if (line === '') {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
if (provider === 'claude') {
|
|
137
|
+
return claudeMightCarryUsage(line) ? parseClaudeLine(line) : null;
|
|
138
|
+
}
|
|
139
|
+
if (provider === 'codex') {
|
|
140
|
+
return codex !== null && codexMightCarryUsage(line) ? parseCodexLine(line, codex) : null;
|
|
141
|
+
}
|
|
142
|
+
// Claude Code and Codex write transcripts of their own, Grok a file read whole; every other provider is a log its host keeps.
|
|
143
|
+
return hostLogMightCarryUsage(line) ? parseHostUsageLine(line, provider) : null;
|
|
144
|
+
}
|
|
82
145
|
/*
|
|
83
146
|
* Reads what a file added since the last scan. Only whole lines count towards the offset; the piece
|
|
84
147
|
* after the last newline is a line still being written, so it is parsed for what it is worth and
|
|
85
148
|
* read again next time. The Codex state is snapshotted before that tail for the same reason.
|
|
86
149
|
*/
|
|
87
|
-
function
|
|
88
|
-
const end = text.lastIndexOf('\n');
|
|
89
|
-
const whole = end === -1 ? '' : text.slice(0, end);
|
|
90
|
-
const tail = text.slice(end + 1);
|
|
91
|
-
const read = (chunk, codex) => {
|
|
92
|
-
const records = [];
|
|
93
|
-
for (const raw of chunk.split('\n')) {
|
|
94
|
-
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
|
|
95
|
-
if (line === '') {
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
if (provider === 'claude') {
|
|
99
|
-
if (claudeMightCarryUsage(line)) {
|
|
100
|
-
const record = parseClaudeLine(line);
|
|
101
|
-
if (record !== null) {
|
|
102
|
-
records.push(record);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
if (provider === 'codex') {
|
|
108
|
-
if (codex !== null && codexMightCarryUsage(line)) {
|
|
109
|
-
const record = parseCodexLine(line, codex);
|
|
110
|
-
if (record !== null) {
|
|
111
|
-
records.push(record);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
// Claude Code and Codex write transcripts of their own, Grok a file read whole; every other provider is a log its host keeps.
|
|
117
|
-
if (hostLogMightCarryUsage(line)) {
|
|
118
|
-
const record = parseHostUsageLine(line, provider);
|
|
119
|
-
if (record !== null) {
|
|
120
|
-
records.push(record);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
return records;
|
|
125
|
-
};
|
|
150
|
+
async function parseLines(chunks, hash, provider, from, state) {
|
|
126
151
|
const codex = provider === 'codex' ? (state ?? createCodexState()) : null;
|
|
127
|
-
const
|
|
152
|
+
const records = [];
|
|
153
|
+
const read = await forEachLine(chunks, hash, (line) => {
|
|
154
|
+
const record = parseLine(line, provider, codex);
|
|
155
|
+
if (record !== null) {
|
|
156
|
+
records.push(record);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
128
159
|
const committed = codex === null ? null : cloneCodexState(codex);
|
|
129
|
-
|
|
160
|
+
const tail = parseLine(read.rest, provider, codex);
|
|
161
|
+
return { records, tail: tail === null ? [] : [tail], offset: from + read.consumed, codex: committed };
|
|
130
162
|
}
|
|
131
163
|
/*
|
|
132
164
|
* The account a record of a root belongs to, as a record keeps it (absent for the default account):
|
|
@@ -275,18 +307,13 @@ export class UsageScanner {
|
|
|
275
307
|
known.provider === provider &&
|
|
276
308
|
info.size > known.size &&
|
|
277
309
|
known.head?.startsWith('sha256:') === true;
|
|
278
|
-
let read = compressed
|
|
279
|
-
? { bytes: zstdDecompressSync(await readFile(path)), hash: createHash('sha256') }
|
|
280
|
-
: await readHashed(path, appended ? known.offset : 0);
|
|
281
310
|
// A file only counts as grown while it starts the way it did; one rewritten larger is read again from the start.
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
const
|
|
288
|
-
read.hash.update(read.bytes.subarray(0, parsed.offset - from));
|
|
289
|
-
const before = grown ? known.records : [];
|
|
311
|
+
const prefix = appended ? await hashPrefix(path, known.offset, createHash('sha256')) : null;
|
|
312
|
+
const grown = prefix !== null && known !== undefined && headOf(prefix) === known.head ? known : null;
|
|
313
|
+
const hash = grown === null || prefix === null ? createHash('sha256') : prefix;
|
|
314
|
+
const from = grown?.offset ?? 0;
|
|
315
|
+
const parsed = await parseLines(readBytes(path, from, compressed), hash, provider, from, grown?.codex ?? null);
|
|
316
|
+
const before = grown?.records ?? [];
|
|
290
317
|
const entry = {
|
|
291
318
|
provider,
|
|
292
319
|
size: info.size,
|
|
@@ -296,7 +323,7 @@ export class UsageScanner {
|
|
|
296
323
|
records: foldByKey([...before, ...withAccount(parsed.records, accountOf)]),
|
|
297
324
|
tail: withAccount(parsed.tail, accountOf),
|
|
298
325
|
codex: parsed.codex,
|
|
299
|
-
head: headOf(
|
|
326
|
+
head: headOf(hash),
|
|
300
327
|
...(provider === 'codex' ? { parserVersion: CODEX_PARSER_VERSION, compressed } : {})
|
|
301
328
|
};
|
|
302
329
|
this.index.set(key, entry);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/agents",
|
|
3
|
-
"version": "0.31.
|
|
3
|
+
"version": "0.31.10",
|
|
4
4
|
"description": "Node-compatible agent chat hosts, providers, accounts, usage and durable task coordination.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -81,8 +81,8 @@
|
|
|
81
81
|
"test": "bun --conditions=source test"
|
|
82
82
|
},
|
|
83
83
|
"dependencies": {
|
|
84
|
-
"@adecore/agent-contracts": "0.31.
|
|
85
|
-
"@adecore/intelligent-ui": "0.31.
|
|
84
|
+
"@adecore/agent-contracts": "0.31.10",
|
|
85
|
+
"@adecore/intelligent-ui": "0.31.10",
|
|
86
86
|
"zod": "^4.6.5"
|
|
87
87
|
},
|
|
88
88
|
"devDependencies": {
|
package/src/chat/acp-backend.ts
CHANGED
|
@@ -63,6 +63,9 @@ export interface AcpAgentSpec {
|
|
|
63
63
|
onContextReport?(): void;
|
|
64
64
|
// The slash commands a session offers (without the slash), each time the agent lists them.
|
|
65
65
|
onCommands?(commands: string[]): void;
|
|
66
|
+
// True takes the session titles the agent sends (`session_info_update`) as the chat's suggested title. Absent leaves
|
|
67
|
+
// naming the chat to the host, since an agent may rename the session after every turn.
|
|
68
|
+
adoptTitles?: boolean;
|
|
66
69
|
// A permission request the host answers itself, in any runtime mode: true allows it once without asking the
|
|
67
70
|
// person, for a call the host already guards. A throw counts as false.
|
|
68
71
|
autoAllow?(request: AcpPermissionRequest): boolean;
|
|
@@ -121,7 +124,7 @@ export class AcpBackend implements ChatBackend {
|
|
|
121
124
|
this.host = host;
|
|
122
125
|
this.spec = spec;
|
|
123
126
|
this.client = spec.client ?? DEFAULT_ACP_CLIENT;
|
|
124
|
-
this.protocol = new AcpProtocol(launch.generation, spec.modes ?? {}, acpStopWords(spec.name));
|
|
127
|
+
this.protocol = new AcpProtocol(launch.generation, spec.modes ?? {}, acpStopWords(spec.name), spec.adoptTitles === true);
|
|
125
128
|
}
|
|
126
129
|
|
|
127
130
|
get running(): boolean {
|
|
@@ -420,7 +423,7 @@ export class AcpBackend implements ChatBackend {
|
|
|
420
423
|
if (this.transport === transport && this.prompt === turn) {
|
|
421
424
|
this.prompt = null;
|
|
422
425
|
this.report(result);
|
|
423
|
-
this.emitAll(this.protocol.finish(turn.
|
|
426
|
+
this.emitAll(this.protocol.finish(result, turn.promptId, turn.cancelled));
|
|
424
427
|
}
|
|
425
428
|
})
|
|
426
429
|
.catch((error: unknown) => {
|
|
@@ -469,7 +472,11 @@ export class AcpBackend implements ChatBackend {
|
|
|
469
472
|
if (!answer) {
|
|
470
473
|
return false;
|
|
471
474
|
}
|
|
472
|
-
|
|
475
|
+
if (!this.respond(requestId, answer.rpcId, answer.result)) {
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
this.emitAll(answer.events);
|
|
479
|
+
return true;
|
|
473
480
|
}
|
|
474
481
|
|
|
475
482
|
/* ACP asks no questions of its own; an agent asks in its message and the next prompt answers. */
|
package/src/chat/acp-protocol.ts
CHANGED
|
@@ -133,6 +133,15 @@ function contextWindowOf(meta: unknown): number | null {
|
|
|
133
133
|
return typeof tokens === 'number' && Number.isSafeInteger(tokens) && tokens > 0 ? tokens : null;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/*
|
|
137
|
+
* The context tokens an agent counts outside the protocol, in `_meta.totalTokens`: Grok CLI 1.0.62 sends no `usage_update`
|
|
138
|
+
* and puts the session's count there, on every `session/update` notification and on the `session/prompt` response.
|
|
139
|
+
*/
|
|
140
|
+
function vendorContextTokens(meta: unknown): number | null {
|
|
141
|
+
const tokens = isRecord(meta) ? meta.totalTokens : undefined;
|
|
142
|
+
return typeof tokens === 'number' && Number.isSafeInteger(tokens) && tokens > 0 ? tokens : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
136
145
|
const AuthMethodSchema = z.looseObject({ id: z.string(), name: z.string() });
|
|
137
146
|
|
|
138
147
|
const InitializeResponseSchema = z.looseObject({
|
|
@@ -296,6 +305,9 @@ function sessionState(options: ConfigOption[], modes: unknown, models: unknown):
|
|
|
296
305
|
const thought = thoughtOption(options);
|
|
297
306
|
const modeState = ModeStateSchema.safeParse(modes);
|
|
298
307
|
const modelState = ModelStateSchema.safeParse(models);
|
|
308
|
+
const entries = modelState.success ? validItems(ModelEntrySchema, modelState.data.availableModels) : [];
|
|
309
|
+
// Grok CLI 1.0.62 offers its models as a config option too, but names their windows only in the older field.
|
|
310
|
+
const windows = new Map(entries.map((model) => [model.modelId, contextWindowOf(model._meta)]));
|
|
299
311
|
return {
|
|
300
312
|
models: modelOption
|
|
301
313
|
? {
|
|
@@ -304,14 +316,14 @@ function sessionState(options: ConfigOption[], modes: unknown, models: unknown):
|
|
|
304
316
|
id: value.value,
|
|
305
317
|
name: value.name,
|
|
306
318
|
description: null,
|
|
307
|
-
contextWindow: contextWindowOf(value._meta)
|
|
319
|
+
contextWindow: contextWindowOf(value._meta) ?? windows.get(value.value) ?? null
|
|
308
320
|
})),
|
|
309
321
|
configId: modelOption.id
|
|
310
322
|
}
|
|
311
323
|
: modelState.success
|
|
312
324
|
? {
|
|
313
325
|
current: modelState.data.currentModelId,
|
|
314
|
-
models:
|
|
326
|
+
models: entries.map((model) => ({
|
|
315
327
|
id: model.modelId,
|
|
316
328
|
name: model.name,
|
|
317
329
|
description: model.description ?? null,
|
|
@@ -452,6 +464,8 @@ interface ToolState {
|
|
|
452
464
|
// What the thread last heard, so an update that changes nothing about the row sends nothing.
|
|
453
465
|
shown: string | null;
|
|
454
466
|
settled: boolean;
|
|
467
|
+
// A person turned the call down, so whatever diff it carries was never written.
|
|
468
|
+
refused: boolean;
|
|
455
469
|
}
|
|
456
470
|
|
|
457
471
|
// `Edit` would have the thread draw a diff of nothing until the agent sends one.
|
|
@@ -467,6 +481,9 @@ const KIND_NAMES: Record<string, string> = {
|
|
|
467
481
|
// A session's title comes from its opening prompts; later ones are not kept.
|
|
468
482
|
const KEPT_PROMPTS = 3;
|
|
469
483
|
|
|
484
|
+
// What Copilot CLI 1.0.101 writes as a message of its own after a call a person turned down; the thread shows the refusal already.
|
|
485
|
+
const REFUSAL_LINE = 'Info: Operation cancelled by user';
|
|
486
|
+
|
|
470
487
|
function foldSpace(text: string): string {
|
|
471
488
|
return text.replace(/\s+/g, ' ').trim();
|
|
472
489
|
}
|
|
@@ -551,7 +568,7 @@ function toolOutput(tool: ToolState): string | null {
|
|
|
551
568
|
return tool.rawOutput;
|
|
552
569
|
}
|
|
553
570
|
if (isRecord(tool.rawOutput)) {
|
|
554
|
-
const text = rawField(tool.rawOutput, 'output', 'stdout', 'content', 'result', 'error');
|
|
571
|
+
const text = rawField(tool.rawOutput, 'output', 'stdout', 'content', 'result', 'error', 'message');
|
|
555
572
|
return text ?? JSON.stringify(tool.rawOutput);
|
|
556
573
|
}
|
|
557
574
|
return null;
|
|
@@ -601,18 +618,28 @@ export class AcpProtocol {
|
|
|
601
618
|
private readonly modes: AcpModeMap;
|
|
602
619
|
private readonly words: AcpStopWords;
|
|
603
620
|
private readonly tools = new Map<string, ToolState>();
|
|
604
|
-
private readonly permissions = new Map<string, { rpcId: number | string; options: Array<z.infer<typeof PermissionOptionSchema>> }>();
|
|
605
|
-
|
|
621
|
+
private readonly permissions = new Map<string, { rpcId: number | string; toolCallId: string; options: Array<z.infer<typeof PermissionOptionSchema>> }>();
|
|
622
|
+
// `held` while the message may still be the refusal line, whose chunks the thread has not heard yet.
|
|
623
|
+
private text: { ref: string; messageId: string | null; text: string; held: boolean } | null = null;
|
|
624
|
+
// Set by a refusal until the next message shows whether it is the refusal line.
|
|
625
|
+
private refusalLine = false;
|
|
606
626
|
private thought: { ref: string; text: string } | null = null;
|
|
607
627
|
private counter = 0;
|
|
608
628
|
private costUsd = 0;
|
|
629
|
+
// Set by the first `usage_update`; from then on a count outside the protocol is never read.
|
|
630
|
+
private standardUsage = false;
|
|
631
|
+
private vendorTokens: number | null = null;
|
|
609
632
|
// The opening prompts of the process, whitespace folded, which an agent may hand back as the session's title.
|
|
610
633
|
private readonly prompts: string[] = [];
|
|
611
634
|
|
|
612
|
-
|
|
635
|
+
private readonly titles: boolean;
|
|
636
|
+
|
|
637
|
+
// `titles` takes the session titles the agent sends as the chat's suggested title.
|
|
638
|
+
constructor(generation: number, modes: AcpModeMap, words: AcpStopWords, titles = false) {
|
|
613
639
|
this.generation = generation;
|
|
614
640
|
this.modes = modes;
|
|
615
641
|
this.words = words;
|
|
642
|
+
this.titles = titles;
|
|
616
643
|
}
|
|
617
644
|
|
|
618
645
|
/* One `session/update` notification; empty for another session, an unknown update or one that does not parse. */
|
|
@@ -621,7 +648,10 @@ export class AcpProtocol {
|
|
|
621
648
|
if (!notification.success || notification.data.sessionId !== sessionId) {
|
|
622
649
|
return [];
|
|
623
650
|
}
|
|
624
|
-
|
|
651
|
+
return [...this.mapUpdate(notification.data.update), ...this.vendorUsage(notification.data._meta)];
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
private mapUpdate(update: z.infer<typeof SessionNotificationSchema>['update']): BackendEvent[] {
|
|
625
655
|
switch (update.sessionUpdate) {
|
|
626
656
|
case 'agent_message_chunk':
|
|
627
657
|
return this.messageChunk(update);
|
|
@@ -651,6 +681,7 @@ export class AcpProtocol {
|
|
|
651
681
|
if (!usage.success) {
|
|
652
682
|
return [];
|
|
653
683
|
}
|
|
684
|
+
this.standardUsage = true;
|
|
654
685
|
const cost = usage.data.cost?.currency === 'USD' ? usage.data.cost.amount : undefined;
|
|
655
686
|
if (cost !== undefined) {
|
|
656
687
|
this.costUsd = cost;
|
|
@@ -658,6 +689,9 @@ export class AcpProtocol {
|
|
|
658
689
|
return [{ type: 'usage', contextTokens: usage.data.used, contextWindow: usage.data.size, ...(cost === undefined ? {} : { costUsd: cost }) }];
|
|
659
690
|
}
|
|
660
691
|
case 'session_info_update': {
|
|
692
|
+
if (!this.titles) {
|
|
693
|
+
return [];
|
|
694
|
+
}
|
|
661
695
|
const title = cleanTitle(update.title);
|
|
662
696
|
return title === null || this.restatesPrompt(title) ? [] : [{ type: 'title', title }];
|
|
663
697
|
}
|
|
@@ -666,6 +700,16 @@ export class AcpProtocol {
|
|
|
666
700
|
}
|
|
667
701
|
}
|
|
668
702
|
|
|
703
|
+
/* The fallback for an agent that sends no `usage_update`: its own count of the context, once each time it changes. */
|
|
704
|
+
private vendorUsage(meta: unknown): BackendEvent[] {
|
|
705
|
+
const tokens = this.standardUsage ? null : vendorContextTokens(meta);
|
|
706
|
+
if (tokens === null || tokens === this.vendorTokens) {
|
|
707
|
+
return [];
|
|
708
|
+
}
|
|
709
|
+
this.vendorTokens = tokens;
|
|
710
|
+
return [{ type: 'usage', contextTokens: tokens }];
|
|
711
|
+
}
|
|
712
|
+
|
|
669
713
|
/* Notes a prompt sent to the agent, so a title that only repeats its start is not taken for a name. */
|
|
670
714
|
prompted(text: string): void {
|
|
671
715
|
if (this.prompts.length < KEPT_PROMPTS) {
|
|
@@ -698,7 +742,7 @@ export class AcpProtocol {
|
|
|
698
742
|
const tool = this.tools.get(request.data.toolCall.toolCallId)!;
|
|
699
743
|
const row = toolRow(tool);
|
|
700
744
|
const options = request.data.options;
|
|
701
|
-
this.permissions.set(requestId, { rpcId, options });
|
|
745
|
+
this.permissions.set(requestId, { rpcId, toolCallId: request.data.toolCall.toolCallId, options });
|
|
702
746
|
events.push({
|
|
703
747
|
type: 'approval.requested',
|
|
704
748
|
requestId,
|
|
@@ -716,9 +760,9 @@ export class AcpProtocol {
|
|
|
716
760
|
/*
|
|
717
761
|
* The answer to a permission request: allow once, reject, or allow always only when the agent offered it.
|
|
718
762
|
* A decision the agent offered no option for falls to the narrower one, and a refusal with no reject option
|
|
719
|
-
* at all is a cancelled outcome.
|
|
763
|
+
* at all is a cancelled outcome. A refusal draws the call's row again without its diff.
|
|
720
764
|
*/
|
|
721
|
-
answer(requestId: string, decision: ApprovalDecision): { rpcId: number | string; result: unknown } | null {
|
|
765
|
+
answer(requestId: string, decision: ApprovalDecision): { rpcId: number | string; result: unknown; events: BackendEvent[] } | null {
|
|
722
766
|
const pending = this.permissions.get(requestId);
|
|
723
767
|
if (!pending) {
|
|
724
768
|
return null;
|
|
@@ -739,7 +783,14 @@ export class AcpProtocol {
|
|
|
739
783
|
: decision === 'allow'
|
|
740
784
|
? byKind('allow_once', 'allow_always')
|
|
741
785
|
: byKind('reject_once', 'reject_always');
|
|
742
|
-
|
|
786
|
+
const result = { outcome: optionId === null ? { outcome: 'cancelled' } : { outcome: 'selected', optionId } };
|
|
787
|
+
const tool = this.tools.get(pending.toolCallId);
|
|
788
|
+
if (decision !== 'deny' || tool === undefined) {
|
|
789
|
+
return { rpcId: pending.rpcId, result, events: [] };
|
|
790
|
+
}
|
|
791
|
+
tool.refused = true;
|
|
792
|
+
this.refusalLine = true;
|
|
793
|
+
return { rpcId: pending.rpcId, result, events: this.toolCall({ toolCallId: pending.toolCallId }, false) };
|
|
743
794
|
}
|
|
744
795
|
|
|
745
796
|
/* Every permission request still open, answered as cancelled, which the protocol asks of a client that cancels a turn. */
|
|
@@ -757,11 +808,17 @@ export class AcpProtocol {
|
|
|
757
808
|
return this.permissions.has(requestId);
|
|
758
809
|
}
|
|
759
810
|
|
|
760
|
-
/*
|
|
761
|
-
|
|
811
|
+
/*
|
|
812
|
+
* The end of a turn, from the `stopReason` of its `session/prompt` response. Only a turn the client cancelled
|
|
813
|
+
* (`cancelled`) ends aborted: Grok 1.0.62 answers `cancelled` by itself when a person turns a call down, and
|
|
814
|
+
* that ends its turn the way any answer does.
|
|
815
|
+
*/
|
|
816
|
+
finish(result: unknown, promptId: string | undefined, cancelled = false): BackendEvent[] {
|
|
762
817
|
const parsed = PromptResponseSchema.safeParse(result);
|
|
763
|
-
const
|
|
764
|
-
const
|
|
818
|
+
const stopReason = parsed.success ? parsed.data.stopReason : 'end_turn';
|
|
819
|
+
const reason = cancelled ? 'cancelled' : stopReason === 'cancelled' ? 'end_turn' : stopReason;
|
|
820
|
+
this.refusalLine = false;
|
|
821
|
+
const events = [...this.closeText(), ...this.closeThought(), ...this.vendorUsage(isRecord(result) ? result._meta : undefined)];
|
|
765
822
|
const base = { ...(promptId === undefined ? {} : { promptId }), costUsd: this.costUsd };
|
|
766
823
|
switch (reason) {
|
|
767
824
|
case 'cancelled':
|
|
@@ -784,6 +841,7 @@ export class AcpProtocol {
|
|
|
784
841
|
|
|
785
842
|
/* A turn that ended without a `stopReason`: the request failed, or was cancelled before the agent said so. */
|
|
786
843
|
abort(promptId: string | undefined, error: string | null): BackendEvent[] {
|
|
844
|
+
this.refusalLine = false;
|
|
787
845
|
const base = { ...(promptId === undefined ? {} : { promptId }), costUsd: this.costUsd };
|
|
788
846
|
return [
|
|
789
847
|
...this.closeText(),
|
|
@@ -803,10 +861,17 @@ export class AcpProtocol {
|
|
|
803
861
|
events.push(...this.closeText());
|
|
804
862
|
}
|
|
805
863
|
if (this.text === null) {
|
|
806
|
-
this.text = { ref: messageId === null ? `message-${++this.counter}` : `message-${messageId}`, messageId, text: '' };
|
|
864
|
+
this.text = { ref: messageId === null ? `message-${++this.counter}` : `message-${messageId}`, messageId, text: '', held: this.refusalLine };
|
|
865
|
+
this.refusalLine = false;
|
|
866
|
+
}
|
|
867
|
+
const text = this.text;
|
|
868
|
+
text.text += chunk.data.content.text;
|
|
869
|
+
if (text.held && !REFUSAL_LINE.startsWith(text.text)) {
|
|
870
|
+
text.held = false;
|
|
871
|
+
events.push({ type: 'text.delta', ref: text.ref, text: text.text });
|
|
872
|
+
} else if (!text.held) {
|
|
873
|
+
events.push({ type: 'text.delta', ref: text.ref, text: chunk.data.content.text });
|
|
807
874
|
}
|
|
808
|
-
this.text.text += chunk.data.content.text;
|
|
809
|
-
events.push({ type: 'text.delta', ref: this.text.ref, text: chunk.data.content.text });
|
|
810
875
|
return events;
|
|
811
876
|
}
|
|
812
877
|
|
|
@@ -827,7 +892,10 @@ export class AcpProtocol {
|
|
|
827
892
|
private closeText(): BackendEvent[] {
|
|
828
893
|
const text = this.text;
|
|
829
894
|
this.text = null;
|
|
830
|
-
|
|
895
|
+
if (text === null || (text.held && text.text === REFUSAL_LINE)) {
|
|
896
|
+
return [];
|
|
897
|
+
}
|
|
898
|
+
return [{ type: 'text.done', ref: text.ref, text: text.text }];
|
|
831
899
|
}
|
|
832
900
|
|
|
833
901
|
private closeThought(): BackendEvent[] {
|
|
@@ -850,7 +918,8 @@ export class AcpProtocol {
|
|
|
850
918
|
diffs: [],
|
|
851
919
|
text: [],
|
|
852
920
|
shown: null,
|
|
853
|
-
settled: false
|
|
921
|
+
settled: false,
|
|
922
|
+
refused: false
|
|
854
923
|
};
|
|
855
924
|
tool.title = call.title ?? tool.title;
|
|
856
925
|
tool.name = call.name ?? tool.name;
|
|
@@ -869,7 +938,7 @@ export class AcpProtocol {
|
|
|
869
938
|
}
|
|
870
939
|
this.tools.set(call.toolCallId, tool);
|
|
871
940
|
const events: BackendEvent[] = [];
|
|
872
|
-
const row = toolRow(tool);
|
|
941
|
+
const row = toolRow(tool.refused ? { ...tool, diffs: [] } : tool);
|
|
873
942
|
const shown = JSON.stringify(row);
|
|
874
943
|
if (shown !== tool.shown) {
|
|
875
944
|
tool.shown = shown;
|
package/src/chat/chat-session.ts
CHANGED
|
@@ -1963,6 +1963,17 @@ export class ChatSession {
|
|
|
1963
1963
|
this.refuseStop(event.taskId, event.message);
|
|
1964
1964
|
return;
|
|
1965
1965
|
}
|
|
1966
|
+
/*
|
|
1967
|
+
* A CLI that counts its tokens but names no window, in a chat that has none yet: the catalog may have learned the
|
|
1968
|
+
* model's window since the chat began, as an ACP agent's catalog does from the first session that reports its models.
|
|
1969
|
+
*/
|
|
1970
|
+
if (event.type === 'usage' && event.contextWindow === undefined && event.contextTokens !== undefined && this.thread.info.usage.contextWindow === null) {
|
|
1971
|
+
const contextWindow = this.options.provider.catalog.contextWindowFor(this.launchedSelection ?? this.thread.info.selection);
|
|
1972
|
+
if (contextWindow !== null) {
|
|
1973
|
+
this.emit(this.projector.project(generation, { ...event, contextWindow }));
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1966
1977
|
/*
|
|
1967
1978
|
* A CLI reports the window it was started on. A pick made since waits for the restart the next
|
|
1968
1979
|
* send does, so its report may not put the old size back under a meter that already reads the new one.
|