@adecore/agents 0.31.2 → 0.31.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/dist/chat/acp-backend.d.ts +11 -1
- package/dist/chat/acp-backend.js +49 -17
- package/dist/chat/acp-protocol.d.ts +26 -5
- package/dist/chat/acp-protocol.js +53 -5
- package/dist/chat/index.d.ts +2 -2
- package/dist/providers/acp-provider.d.ts +1 -0
- package/dist/providers/acp-provider.js +32 -2
- package/dist/providers/acp-usage-log.d.ts +2 -0
- package/dist/providers/acp-usage-log.js +21 -0
- package/dist/providers/catalog.d.ts +2 -2
- package/dist/providers/catalog.js +6 -3
- package/dist/providers/index.d.ts +1 -0
- package/dist/providers/index.js +1 -0
- package/dist/providers/shell-commands.d.ts +1 -0
- package/dist/providers/shell-commands.js +119 -0
- package/dist/testing/fake-acp.d.ts +2 -0
- package/dist/testing/fake-acp.js +23 -2
- package/dist/usage/aggregate.js +6 -5
- package/dist/usage/index-file.js +25 -17
- package/dist/usage/limits/monitor.js +5 -0
- package/dist/usage/readers/grok.d.ts +3 -0
- package/dist/usage/readers/grok.js +85 -0
- package/dist/usage/record.d.ts +1 -0
- package/dist/usage/record.js +5 -1
- package/dist/usage/roots.js +7 -2
- package/dist/usage/scanner.d.ts +1 -0
- package/dist/usage/scanner.js +39 -5
- package/package.json +3 -3
- package/src/chat/acp-backend.ts +67 -17
- package/src/chat/acp-protocol.ts +95 -7
- package/src/chat/index.ts +14 -2
- package/src/providers/acp-provider.ts +39 -4
- package/src/providers/acp-usage-log.ts +26 -0
- package/src/providers/catalog.ts +7 -4
- package/src/providers/index.ts +1 -0
- package/src/providers/shell-commands.ts +122 -0
- package/src/testing/fake-acp.ts +29 -2
- package/src/usage/aggregate.ts +6 -5
- package/src/usage/index-file.ts +27 -18
- package/src/usage/limits/monitor.ts +5 -0
- package/src/usage/readers/grok.ts +89 -0
- package/src/usage/record.ts +7 -1
- package/src/usage/roots.ts +7 -2
- package/src/usage/scanner.ts +42 -5
package/dist/testing/fake-acp.js
CHANGED
|
@@ -7,6 +7,7 @@ export function fakeAcpWith(options) {
|
|
|
7
7
|
let sessionId = '';
|
|
8
8
|
let mode = modes[0] ?? '';
|
|
9
9
|
let model = 'fake-small';
|
|
10
|
+
let effort = 'low';
|
|
10
11
|
let serverRequestId = 0;
|
|
11
12
|
let callCounter = 0;
|
|
12
13
|
let prompt = null;
|
|
@@ -33,7 +34,7 @@ export function fakeAcpWith(options) {
|
|
|
33
34
|
return;
|
|
34
35
|
}
|
|
35
36
|
update({ sessionUpdate: 'usage_update', used: 1200, size: 200000, cost: { amount: 0.01, currency: 'USD' } });
|
|
36
|
-
respond(prompt.rpcId, { stopReason });
|
|
37
|
+
respond(prompt.rpcId, { stopReason, ...(options.turnUsage === undefined ? {} : { usage: options.turnUsage }) });
|
|
37
38
|
prompt = null;
|
|
38
39
|
};
|
|
39
40
|
const sessionState = () => {
|
|
@@ -41,11 +42,24 @@ export function fakeAcpWith(options) {
|
|
|
41
42
|
{ value: 'fake-small', name: 'Fake small' },
|
|
42
43
|
{ value: 'fake-large', name: 'Fake large' }
|
|
43
44
|
];
|
|
45
|
+
const effortChoices = [
|
|
46
|
+
{ value: 'low', name: 'Low', description: 'Answers quickly' },
|
|
47
|
+
{ value: 'high', name: 'High' },
|
|
48
|
+
...(model === 'fake-large' ? [{ value: 'xhigh', name: 'Extra high' }] : [])
|
|
49
|
+
];
|
|
50
|
+
const effortOption = options.reasoningEffort === undefined
|
|
51
|
+
? []
|
|
52
|
+
: [{ id: 'reasoning_effort', name: 'Reasoning effort', type: 'select', currentValue: effort, options: effortChoices }];
|
|
44
53
|
return {
|
|
45
54
|
...(modes.length === 0 ? {} : { modes: { currentModeId: mode, availableModes: modes.map((id) => ({ id, name: id })) } }),
|
|
46
55
|
...(options.legacyModels
|
|
47
56
|
? { models: { currentModelId: model, availableModels: modelChoices.map((choice) => ({ modelId: choice.value, name: choice.name })) } }
|
|
48
|
-
: {
|
|
57
|
+
: {
|
|
58
|
+
configOptions: [
|
|
59
|
+
{ id: 'model', name: 'Model', type: 'select', category: 'model', currentValue: model, options: modelChoices },
|
|
60
|
+
...effortOption
|
|
61
|
+
]
|
|
62
|
+
})
|
|
49
63
|
};
|
|
50
64
|
};
|
|
51
65
|
const askPermission = (toolCall, onAnswer) => {
|
|
@@ -241,6 +255,13 @@ export function fakeAcpWith(options) {
|
|
|
241
255
|
if (params.configId === 'model') {
|
|
242
256
|
model = String(params.value);
|
|
243
257
|
}
|
|
258
|
+
if (params.configId === 'reasoning_effort') {
|
|
259
|
+
if (options.reasoningEffort === 'reject') {
|
|
260
|
+
refuse(id, -32602, 'Reasoning effort is locked');
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
effort = String(params.value);
|
|
264
|
+
}
|
|
244
265
|
respond(id, { configOptions: sessionState().configOptions ?? [] });
|
|
245
266
|
return;
|
|
246
267
|
case 'session/prompt':
|
package/dist/usage/aggregate.js
CHANGED
|
@@ -47,7 +47,7 @@ function foldBucket(buckets, slot, record, account, price, cost) {
|
|
|
47
47
|
}
|
|
48
48
|
bucket.totals = addTotals(bucket.totals, record.totals);
|
|
49
49
|
bucket.costUsd += cost;
|
|
50
|
-
bucket.priced ||= price !== null;
|
|
50
|
+
bucket.priced ||= price !== null || record.costUsd !== undefined;
|
|
51
51
|
bucket.cacheSavingsUsd += price === null ? 0 : cacheSavingsOf(record.totals, price);
|
|
52
52
|
if (record.sessionId !== '') {
|
|
53
53
|
bucket.sessions.add(record.sessionId);
|
|
@@ -64,14 +64,15 @@ function foldModel(models, record, account, lookup, cost) {
|
|
|
64
64
|
totals: emptyTotals(),
|
|
65
65
|
costUsd: 0,
|
|
66
66
|
priced: false,
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
// A cost the CLI reported is the bill itself, not a price looked up for the model.
|
|
68
|
+
basis: record.costUsd === undefined ? lookup.basis : 'exact',
|
|
69
|
+
pricedAs: record.costUsd === undefined ? lookup.pricedAs : null
|
|
69
70
|
};
|
|
70
71
|
models.set(key, model);
|
|
71
72
|
}
|
|
72
73
|
model.totals = addTotals(model.totals, record.totals);
|
|
73
74
|
model.costUsd += cost;
|
|
74
|
-
model.priced ||= lookup.price !== null;
|
|
75
|
+
model.priced ||= lookup.price !== null || record.costUsd !== undefined;
|
|
75
76
|
}
|
|
76
77
|
function foldProject(project, record, cost) {
|
|
77
78
|
project.totals = addTotals(project.totals, record.totals);
|
|
@@ -111,7 +112,7 @@ export async function aggregate(records, payload, prices, known) {
|
|
|
111
112
|
continue;
|
|
112
113
|
}
|
|
113
114
|
const lookup = prices.look(record.model);
|
|
114
|
-
const cost = lookup.price === null ? 0 : costOf(record.totals, lookup.price);
|
|
115
|
+
const cost = record.costUsd ?? (lookup.price === null ? 0 : costOf(record.totals, lookup.price));
|
|
115
116
|
foldBucket(buckets, slot, record, account, lookup.price, cost);
|
|
116
117
|
foldModel(models, record, account, lookup, cost);
|
|
117
118
|
if (record.sessionId !== '') {
|
package/dist/usage/index-file.js
CHANGED
|
@@ -23,21 +23,27 @@ export function encodeIndex(index) {
|
|
|
23
23
|
const folders = new Interner();
|
|
24
24
|
const accounts = new Interner();
|
|
25
25
|
const files = {};
|
|
26
|
-
const row = (record) =>
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
26
|
+
const row = (record) => {
|
|
27
|
+
const counted = [
|
|
28
|
+
record.timestampMs,
|
|
29
|
+
models.index(record.model),
|
|
30
|
+
sessions.index(record.sessionId),
|
|
31
|
+
folders.index(record.cwd),
|
|
32
|
+
record.totals.calls,
|
|
33
|
+
record.totals.input,
|
|
34
|
+
record.totals.cacheRead,
|
|
35
|
+
record.totals.cacheWrite,
|
|
36
|
+
record.totals.cacheWrite1h,
|
|
37
|
+
record.totals.output,
|
|
38
|
+
record.totals.reasoning,
|
|
39
|
+
record.dedupeKey ?? 0,
|
|
40
|
+
record.account === undefined ? 0 : accounts.index(record.account) + 1
|
|
41
|
+
];
|
|
42
|
+
if (record.costUsd !== undefined) {
|
|
43
|
+
counted.push(record.costUsd);
|
|
44
|
+
}
|
|
45
|
+
return counted;
|
|
46
|
+
};
|
|
41
47
|
for (const [path, file] of index) {
|
|
42
48
|
files[path] = {
|
|
43
49
|
p: file.provider,
|
|
@@ -62,10 +68,11 @@ export function encodeIndex(index) {
|
|
|
62
68
|
}
|
|
63
69
|
function isRow(row, version) {
|
|
64
70
|
return (Array.isArray(row) &&
|
|
65
|
-
|
|
71
|
+
(version === ACCOUNTLESS_VERSION ? row.length === 12 : row.length === 13 || row.length === 14) &&
|
|
66
72
|
row.slice(0, 11).every((value) => typeof value === 'number') &&
|
|
67
73
|
(typeof row[11] === 'string' || row[11] === 0) &&
|
|
68
|
-
(version === ACCOUNTLESS_VERSION || typeof row[12] === 'number')
|
|
74
|
+
(version === ACCOUNTLESS_VERSION || typeof row[12] === 'number') &&
|
|
75
|
+
(row.length < 14 || typeof row[13] === 'number'));
|
|
69
76
|
}
|
|
70
77
|
/*
|
|
71
78
|
* A row that does not read back costs a cold parse of the file it belonged to, never a wrong total,
|
|
@@ -119,6 +126,7 @@ export function decodeIndex(text) {
|
|
|
119
126
|
cwd,
|
|
120
127
|
...(account === undefined ? {} : { account }),
|
|
121
128
|
totals: { calls: row[4], input: row[5], cacheRead: row[6], cacheWrite: row[7], cacheWrite1h: row[8], output: row[9], reasoning: row[10] },
|
|
129
|
+
...(row[13] === undefined ? {} : { costUsd: row[13] }),
|
|
122
130
|
dedupeKey: row[11] === 0 ? null : row[11]
|
|
123
131
|
});
|
|
124
132
|
}
|
|
@@ -10,6 +10,8 @@ const MAX_BACKOFF_MS = 60 * 60_000;
|
|
|
10
10
|
/* An account other than the default one that nobody started a CLI under for this long is only read when a person asks. */
|
|
11
11
|
const RECENT_USE_MS = 24 * 60 * 60_000;
|
|
12
12
|
const PROVIDERS = USAGE_PROVIDERS;
|
|
13
|
+
/* The CLIs the probes know how to ask; any other provider has a plan only through the host's `plans`. */
|
|
14
|
+
const PROBED = ['claude', 'codex'];
|
|
13
15
|
function blank(kind) {
|
|
14
16
|
return {
|
|
15
17
|
kind,
|
|
@@ -106,6 +108,9 @@ export class UsageMonitor {
|
|
|
106
108
|
listed() {
|
|
107
109
|
const accounts = this.accounts?.list() ?? PROVIDERS.map((kind) => ({ id: kind, kind, label: kind, isDefault: true }));
|
|
108
110
|
return PROVIDERS.flatMap((kind) => {
|
|
111
|
+
if (!PROBED.includes(kind) && this.plans[kind] === undefined) {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
109
114
|
const own = accounts.filter((account) => account.kind === kind);
|
|
110
115
|
return [...own.filter((account) => account.isDefault), ...own.filter((account) => !account.isDefault)];
|
|
111
116
|
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { basename, dirname } from 'node:path';
|
|
2
|
+
import { asObject, asString, int } from '../record.js';
|
|
3
|
+
/* The file Grok keeps the counts of a session in, beside its other files in the session's folder. */
|
|
4
|
+
export const GROK_USAGE_FILE = 'usage.json';
|
|
5
|
+
// `costUsdTicks` counts in ticks of 10^-10 USD, as Grok's own docs on `grok usage` say.
|
|
6
|
+
const TICKS_PER_USD = 1e10;
|
|
7
|
+
/* The folder above a session's is its working directory, URL-encoded into one name. */
|
|
8
|
+
function cwdOf(path) {
|
|
9
|
+
try {
|
|
10
|
+
return decodeURIComponent(basename(dirname(dirname(path))));
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return '';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function recordOf(counts, model, base, turn) {
|
|
17
|
+
const cacheRead = int(counts.cachedReadTokens);
|
|
18
|
+
const cacheWrite = int(counts.cacheCreationTokens);
|
|
19
|
+
const totals = {
|
|
20
|
+
calls: int(counts.modelCalls),
|
|
21
|
+
// Grok's input holds both cache kinds: its total is input plus output.
|
|
22
|
+
input: Math.max(0, int(counts.inputTokens) - cacheRead - cacheWrite),
|
|
23
|
+
cacheRead,
|
|
24
|
+
cacheWrite,
|
|
25
|
+
cacheWrite1h: 0,
|
|
26
|
+
output: int(counts.outputTokens),
|
|
27
|
+
reasoning: int(counts.reasoningTokens)
|
|
28
|
+
};
|
|
29
|
+
if (totals.input + totals.cacheRead + totals.cacheWrite + totals.output === 0) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const ticks = int(counts.costUsdTicks);
|
|
33
|
+
return {
|
|
34
|
+
...base,
|
|
35
|
+
model,
|
|
36
|
+
totals,
|
|
37
|
+
// A missing or zero cost is one Grok did not report, which the price table then estimates.
|
|
38
|
+
...(ticks === 0 ? {} : { costUsd: ticks / TICKS_PER_USD }),
|
|
39
|
+
dedupeKey: `grok\0${base.sessionId}\0${turn}\0${model}`
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/*
|
|
43
|
+
* A session's `usage.json` under `$GROK_HOME/sessions/<cwd>/<session id>/`, which Grok rewrites whole
|
|
44
|
+
* at the end of every turn. One record per turn per model the turn called; null when the file does not
|
|
45
|
+
* read as one, which leaves what the scanner held of it.
|
|
46
|
+
*/
|
|
47
|
+
export function parseGrokUsage(text, path) {
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(text);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const file = asObject(parsed);
|
|
56
|
+
if (file === null || !Array.isArray(file.turns)) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const sessionId = asString(file.sessionId) || basename(dirname(path));
|
|
60
|
+
const cwd = cwdOf(path);
|
|
61
|
+
const records = [];
|
|
62
|
+
for (const entry of file.turns) {
|
|
63
|
+
const turn = asObject(entry);
|
|
64
|
+
const timestampMs = turn === null ? Number.NaN : Date.parse(asString(turn.endedAt));
|
|
65
|
+
if (turn === null || Number.isNaN(timestampMs)) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const number = typeof turn.turnNumber === 'number' ? String(turn.turnNumber) : asString(turn.endedAt);
|
|
69
|
+
const base = { provider: 'grok', timestampMs, sessionId, cwd };
|
|
70
|
+
const byModel = asObject(turn.modelUsage);
|
|
71
|
+
const models = byModel === null
|
|
72
|
+
? [[asString(turn.primaryModelId), turn]]
|
|
73
|
+
: Object.entries(byModel).flatMap(([model, counts]) => {
|
|
74
|
+
const object = asObject(counts);
|
|
75
|
+
return object === null ? [] : [[model, object]];
|
|
76
|
+
});
|
|
77
|
+
for (const [model, counts] of models) {
|
|
78
|
+
const record = model === '' ? null : recordOf(counts, model, base, number);
|
|
79
|
+
if (record !== null) {
|
|
80
|
+
records.push(record);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return records;
|
|
85
|
+
}
|
package/dist/usage/record.d.ts
CHANGED
package/dist/usage/record.js
CHANGED
|
@@ -33,7 +33,11 @@ export function foldByKey(records) {
|
|
|
33
33
|
folded.push({ ...record, totals: { ...record.totals } });
|
|
34
34
|
continue;
|
|
35
35
|
}
|
|
36
|
-
|
|
36
|
+
const into = folded[seen];
|
|
37
|
+
maxTotals(into.totals, record.totals);
|
|
38
|
+
if (record.costUsd !== undefined) {
|
|
39
|
+
into.costUsd = Math.max(into.costUsd ?? 0, record.costUsd);
|
|
40
|
+
}
|
|
37
41
|
}
|
|
38
42
|
return folded;
|
|
39
43
|
}
|
package/dist/usage/roots.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { homeFolder } from '../home-folder.js';
|
|
3
3
|
/* Where in its config folder each CLI leaves its transcripts. A provider with no CLI has a log its host passes as a root of its own. */
|
|
4
|
-
const TRANSCRIPTS = {
|
|
4
|
+
const TRANSCRIPTS = {
|
|
5
|
+
claude: 'projects',
|
|
6
|
+
codex: 'sessions',
|
|
7
|
+
grok: 'sessions'
|
|
8
|
+
};
|
|
5
9
|
/* The transcripts of the default accounts, found through the variables the CLIs themselves read. */
|
|
6
10
|
export function usageRoots(env = process.env) {
|
|
7
11
|
const home = homeFolder(env);
|
|
8
12
|
return [
|
|
9
13
|
{ provider: 'claude', path: join(env.CLAUDE_CONFIG_DIR ?? join(home, '.claude'), TRANSCRIPTS.claude) },
|
|
10
|
-
{ provider: 'codex', path: join(env.CODEX_HOME ?? join(home, '.codex'), TRANSCRIPTS.codex) }
|
|
14
|
+
{ provider: 'codex', path: join(env.CODEX_HOME ?? join(home, '.codex'), TRANSCRIPTS.codex) },
|
|
15
|
+
{ provider: 'grok', path: join(env.GROK_HOME ?? join(home, '.grok'), TRANSCRIPTS.grok) }
|
|
11
16
|
];
|
|
12
17
|
}
|
|
13
18
|
/* The transcripts of every account, once per folder: Codex accounts over one home share its sessions. */
|
package/dist/usage/scanner.d.ts
CHANGED
package/dist/usage/scanner.js
CHANGED
|
@@ -6,6 +6,7 @@ import { isNotFound, writeAtomic } from '../fs.js';
|
|
|
6
6
|
import { decodeIndex, encodeIndex } from './index-file.js';
|
|
7
7
|
import { foldByKey } from './record.js';
|
|
8
8
|
import { claudeMightCarryUsage, parseClaudeLine } from './readers/claude.js';
|
|
9
|
+
import { GROK_USAGE_FILE, parseGrokUsage } from './readers/grok.js';
|
|
9
10
|
import { hostLogMightCarryUsage, parseHostUsageLine } from './readers/host-log.js';
|
|
10
11
|
import { cloneCodexState, codexMightCarryUsage, createCodexState, parseCodexLine } from './readers/codex.js';
|
|
11
12
|
import { usageRoots } from './roots.js';
|
|
@@ -49,7 +50,14 @@ function headOf(hash) {
|
|
|
49
50
|
// Older indexes hashed only the first 4 KB; they cannot establish that the rest stayed unchanged.
|
|
50
51
|
return `sha256:${hash.copy().digest('base64')}`;
|
|
51
52
|
}
|
|
52
|
-
|
|
53
|
+
/* A log of lines for every provider but Grok, which keeps one JSON file per session that it rewrites whole. */
|
|
54
|
+
function readsWhole(provider) {
|
|
55
|
+
return provider === 'grok';
|
|
56
|
+
}
|
|
57
|
+
function isUsageFile(provider, name) {
|
|
58
|
+
return readsWhole(provider) ? name === GROK_USAGE_FILE : name.endsWith('.jsonl');
|
|
59
|
+
}
|
|
60
|
+
async function listUsageFiles(dir, provider, out = []) {
|
|
53
61
|
let entries;
|
|
54
62
|
try {
|
|
55
63
|
entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -61,9 +69,9 @@ async function listJsonl(dir, out = []) {
|
|
|
61
69
|
for (const entry of entries) {
|
|
62
70
|
const path = join(dir, entry.name);
|
|
63
71
|
if (entry.isDirectory()) {
|
|
64
|
-
await
|
|
72
|
+
await listUsageFiles(path, provider, out);
|
|
65
73
|
}
|
|
66
|
-
else if (entry.isFile() && entry.name
|
|
74
|
+
else if (entry.isFile() && isUsageFile(provider, entry.name)) {
|
|
67
75
|
out.push(path);
|
|
68
76
|
}
|
|
69
77
|
}
|
|
@@ -103,7 +111,7 @@ function parseChunk(text, provider, from, state) {
|
|
|
103
111
|
}
|
|
104
112
|
continue;
|
|
105
113
|
}
|
|
106
|
-
// Claude Code and Codex write transcripts of their own; every other provider is a log its host keeps.
|
|
114
|
+
// Claude Code and Codex write transcripts of their own, Grok a file read whole; every other provider is a log its host keeps.
|
|
107
115
|
if (hostLogMightCarryUsage(line)) {
|
|
108
116
|
const record = parseHostUsageLine(line, provider);
|
|
109
117
|
if (record !== null) {
|
|
@@ -207,7 +215,7 @@ export class UsageScanner {
|
|
|
207
215
|
}
|
|
208
216
|
let failure = null;
|
|
209
217
|
const accountOf = accountResolver(root, sessionAccounts);
|
|
210
|
-
for (const path of await
|
|
218
|
+
for (const path of await listUsageFiles(root.path, root.provider)) {
|
|
211
219
|
files += 1;
|
|
212
220
|
try {
|
|
213
221
|
if (await this.readFileInto(path, root.provider, accountOf)) {
|
|
@@ -242,6 +250,9 @@ export class UsageScanner {
|
|
|
242
250
|
if (known !== undefined && known.provider === provider && known.size === info.size && known.mtimeMs === info.mtimeMs) {
|
|
243
251
|
return false;
|
|
244
252
|
}
|
|
253
|
+
if (readsWhole(provider)) {
|
|
254
|
+
return this.readWholeInto(path, provider, accountOf, info);
|
|
255
|
+
}
|
|
245
256
|
const appended = known !== undefined && known.provider === provider && info.size > known.size && known.head?.startsWith('sha256:') === true;
|
|
246
257
|
let read = await readHashed(path, appended ? known.offset : 0);
|
|
247
258
|
// A file only counts as grown while it starts the way it did; one rewritten larger is read again from the start.
|
|
@@ -268,4 +279,27 @@ export class UsageScanner {
|
|
|
268
279
|
this.dirty = true;
|
|
269
280
|
return true;
|
|
270
281
|
}
|
|
282
|
+
/*
|
|
283
|
+
* A file rewritten as a whole replaces what the index held of it, so a turn it still lists is never
|
|
284
|
+
* counted twice. One that does not parse, such as one caught halfway through a write, keeps the
|
|
285
|
+
* records it had and is read again on the next scan.
|
|
286
|
+
*/
|
|
287
|
+
async readWholeInto(path, provider, accountOf, info) {
|
|
288
|
+
const records = parseGrokUsage(await readFile(path, 'utf8'), path);
|
|
289
|
+
if (records === null) {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
this.index.set(path, {
|
|
293
|
+
provider,
|
|
294
|
+
size: info.size,
|
|
295
|
+
mtimeMs: info.mtimeMs,
|
|
296
|
+
offset: info.size,
|
|
297
|
+
records: foldByKey(withAccount(records, accountOf)),
|
|
298
|
+
tail: [],
|
|
299
|
+
codex: null,
|
|
300
|
+
head: null
|
|
301
|
+
});
|
|
302
|
+
this.dirty = true;
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
271
305
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/agents",
|
|
3
|
-
"version": "0.31.
|
|
3
|
+
"version": "0.31.4",
|
|
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.4",
|
|
85
|
+
"@adecore/intelligent-ui": "0.31.4",
|
|
86
86
|
"zod": "^4.6.5"
|
|
87
87
|
},
|
|
88
88
|
"devDependencies": {
|
package/src/chat/acp-backend.ts
CHANGED
|
@@ -10,12 +10,17 @@ import {
|
|
|
10
10
|
autoAllows,
|
|
11
11
|
parseInitialize,
|
|
12
12
|
parseSessionInfo,
|
|
13
|
+
promptUsageOf,
|
|
13
14
|
resolveAcpMode,
|
|
14
15
|
runtimeModeOf,
|
|
15
16
|
sessionIdOf,
|
|
17
|
+
thoughtLevelOf,
|
|
16
18
|
type AcpAgentCapabilities,
|
|
17
19
|
type AcpModeMap,
|
|
18
|
-
type
|
|
20
|
+
type AcpPermissionRequest,
|
|
21
|
+
type AcpSessionInfo,
|
|
22
|
+
type AcpThoughtLevel,
|
|
23
|
+
type AcpTurnUsage
|
|
19
24
|
} from './acp-protocol.ts';
|
|
20
25
|
import type { ApprovalDecision, BackendEvent, BackendHost, BackendLaunch, ChatBackend, TurnInput } from './backend.ts';
|
|
21
26
|
import { attachmentNote } from './input.ts';
|
|
@@ -49,6 +54,18 @@ export interface AcpAgentSpec {
|
|
|
49
54
|
model?(selection: ModelSelection): string | null;
|
|
50
55
|
// What every new, loaded or resumed session offers, for a host that fills its model catalog from the agent.
|
|
51
56
|
onSession?(info: AcpSessionInfo): void;
|
|
57
|
+
// The tokens of every turn whose `session/prompt` response reports them, for a host that keeps a usage log.
|
|
58
|
+
onUsage?(usage: AcpUsageReport): void;
|
|
59
|
+
// A permission request the host answers itself, in any runtime mode: true allows it once without asking the
|
|
60
|
+
// person, for a call the host already guards. A throw counts as false.
|
|
61
|
+
autoAllow?(request: AcpPermissionRequest): boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* One turn's usage, with the session, folder and model it ran in. */
|
|
65
|
+
export interface AcpUsageReport extends AcpTurnUsage {
|
|
66
|
+
sessionId: string;
|
|
67
|
+
cwd: string;
|
|
68
|
+
model: string;
|
|
52
69
|
}
|
|
53
70
|
|
|
54
71
|
// What `initialize` and a session request may take; a prompt and a login wait for as long as the agent runs.
|
|
@@ -77,6 +94,7 @@ export class AcpBackend implements ChatBackend {
|
|
|
77
94
|
private replaying = false;
|
|
78
95
|
private contextPending: string | null = null;
|
|
79
96
|
private prompt: { promptId: string | undefined; cancelled: boolean } | null = null;
|
|
97
|
+
private model: string | null = null;
|
|
80
98
|
|
|
81
99
|
constructor(launch: BackendLaunch, host: BackendHost, spec: AcpAgentSpec) {
|
|
82
100
|
this.launch = launch;
|
|
@@ -138,8 +156,9 @@ export class AcpBackend implements ChatBackend {
|
|
|
138
156
|
this.capabilities = capabilities;
|
|
139
157
|
const info = await this.openSession(transport, capabilities);
|
|
140
158
|
const modeId = await this.applyMode(transport, info);
|
|
141
|
-
const model = await this.applyModel(transport, info);
|
|
142
|
-
|
|
159
|
+
const { model, thoughtLevel } = await this.applyModel(transport, info);
|
|
160
|
+
this.model = model;
|
|
161
|
+
await this.applyThoughtLevel(transport, thoughtLevel);
|
|
143
162
|
this.spec.onSession?.(info);
|
|
144
163
|
this.emit({
|
|
145
164
|
type: 'session',
|
|
@@ -246,24 +265,29 @@ export class AcpBackend implements ChatBackend {
|
|
|
246
265
|
return resolved.modeId;
|
|
247
266
|
}
|
|
248
267
|
|
|
249
|
-
/*
|
|
250
|
-
|
|
268
|
+
/*
|
|
269
|
+
* Switches to the selected model where the agent offers it; answers the model the session runs and the
|
|
270
|
+
* reasoning effort it offers there, which may change with the model.
|
|
271
|
+
*/
|
|
272
|
+
private async applyModel(transport: JsonRpcTransport, info: AcpSessionInfo): Promise<{ model: string | null; thoughtLevel: AcpThoughtLevel | null }> {
|
|
251
273
|
const models = info.models;
|
|
274
|
+
const thoughtLevel = info.thoughtLevel;
|
|
252
275
|
const wanted = this.spec.model ? this.spec.model(this.launch.selection) : this.launch.selection.model;
|
|
253
276
|
if (models === null || wanted === null || wanted === models.current) {
|
|
254
|
-
return models?.current ?? wanted;
|
|
277
|
+
return { model: models?.current ?? wanted, thoughtLevel };
|
|
255
278
|
}
|
|
256
279
|
if (!models.models.some((model) => model.id === wanted)) {
|
|
257
280
|
this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} does not offer ${this.launch.modelName}. It runs ${models.current}.` });
|
|
258
|
-
return models.current;
|
|
281
|
+
return { model: models.current, thoughtLevel };
|
|
259
282
|
}
|
|
260
283
|
try {
|
|
261
284
|
if (models.configId === null) {
|
|
262
285
|
await transport.request('session/set_model', { sessionId: this.sessionId, modelId: wanted });
|
|
263
|
-
|
|
264
|
-
await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: models.configId, value: wanted });
|
|
286
|
+
return { model: wanted, thoughtLevel };
|
|
265
287
|
}
|
|
266
|
-
|
|
288
|
+
const result = await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: models.configId, value: wanted });
|
|
289
|
+
const after = thoughtLevelOf(result);
|
|
290
|
+
return { model: wanted, thoughtLevel: after === undefined ? thoughtLevel : after };
|
|
267
291
|
} catch (error) {
|
|
268
292
|
if (!(error instanceof JsonRpcError)) {
|
|
269
293
|
throw error;
|
|
@@ -273,21 +297,26 @@ export class AcpBackend implements ChatBackend {
|
|
|
273
297
|
level: 'warning',
|
|
274
298
|
text: `${this.spec.name} did not switch to ${this.launch.modelName} (${errorText(error)}). It runs ${models.current}.`
|
|
275
299
|
});
|
|
276
|
-
return models.current;
|
|
300
|
+
return { model: models.current, thoughtLevel };
|
|
277
301
|
}
|
|
278
302
|
}
|
|
279
303
|
|
|
280
|
-
|
|
281
|
-
|
|
304
|
+
/* Sets the selected effort where the session offers it; a model without one runs as it is. */
|
|
305
|
+
private async applyThoughtLevel(transport: JsonRpcTransport, level: AcpThoughtLevel | null): Promise<void> {
|
|
282
306
|
const effort = this.launch.selection.options.effort;
|
|
283
|
-
|
|
307
|
+
const choice = level?.choices.find((entry) => entry.id === effort);
|
|
308
|
+
if (level === null || choice === undefined || effort === level.current) {
|
|
284
309
|
return;
|
|
285
310
|
}
|
|
286
|
-
|
|
311
|
+
try {
|
|
312
|
+
await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: level.configId, value: choice.id });
|
|
313
|
+
} catch (error) {
|
|
287
314
|
if (!(error instanceof JsonRpcError)) {
|
|
288
315
|
throw error;
|
|
289
316
|
}
|
|
290
|
-
|
|
317
|
+
const current = level.choices.find((entry) => entry.id === level.current)?.name ?? level.current;
|
|
318
|
+
this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} kept its reasoning at ${current} instead of ${choice.name}.` });
|
|
319
|
+
}
|
|
291
320
|
}
|
|
292
321
|
|
|
293
322
|
private promptBlocks(input: TurnInput): unknown[] {
|
|
@@ -322,6 +351,7 @@ export class AcpBackend implements ChatBackend {
|
|
|
322
351
|
.then((result) => {
|
|
323
352
|
if (this.transport === transport && this.prompt === turn) {
|
|
324
353
|
this.prompt = null;
|
|
354
|
+
this.report(result);
|
|
325
355
|
this.emitAll(this.protocol.finish(turn.cancelled ? { stopReason: 'cancelled' } : result, turn.promptId));
|
|
326
356
|
}
|
|
327
357
|
})
|
|
@@ -438,7 +468,7 @@ export class AcpBackend implements ChatBackend {
|
|
|
438
468
|
}
|
|
439
469
|
return;
|
|
440
470
|
}
|
|
441
|
-
if (autoAllows(this.launch.runtimeMode, permission.kind)) {
|
|
471
|
+
if (autoAllows(this.launch.runtimeMode, permission.kind) || this.hostAllows(permission.request)) {
|
|
442
472
|
this.emitAll(permission.events.filter((event) => event.type !== 'approval.requested'));
|
|
443
473
|
const answer = this.protocol.answer(permission.requestId, 'allow');
|
|
444
474
|
if (answer) {
|
|
@@ -449,6 +479,14 @@ export class AcpBackend implements ChatBackend {
|
|
|
449
479
|
this.emitAll(permission.events);
|
|
450
480
|
}
|
|
451
481
|
|
|
482
|
+
private hostAllows(request: AcpPermissionRequest): boolean {
|
|
483
|
+
try {
|
|
484
|
+
return this.spec.autoAllow?.(request) === true;
|
|
485
|
+
} catch {
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
452
490
|
private handleExit(transport: JsonRpcTransport, exitCode: number | null, stderr: string | null): void {
|
|
453
491
|
if (this.transport !== transport) {
|
|
454
492
|
return;
|
|
@@ -459,6 +497,15 @@ export class AcpBackend implements ChatBackend {
|
|
|
459
497
|
this.emit({ type: 'exit', exitCode, ...(stderr === null ? {} : { stderr }) });
|
|
460
498
|
}
|
|
461
499
|
|
|
500
|
+
/* A cancelled turn still spent what it reports. */
|
|
501
|
+
private report(result: unknown): void {
|
|
502
|
+
const usage = this.spec.onUsage === undefined ? null : promptUsageOf(result);
|
|
503
|
+
if (usage === null) {
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
this.spec.onUsage?.({ ...usage, sessionId: this.sessionId, cwd: this.launch.cwd, model: this.model ?? this.launch.selection.model });
|
|
507
|
+
}
|
|
508
|
+
|
|
462
509
|
private emitAll(events: BackendEvent[]): void {
|
|
463
510
|
for (const event of events) {
|
|
464
511
|
this.emit(event);
|
|
@@ -466,6 +513,9 @@ export class AcpBackend implements ChatBackend {
|
|
|
466
513
|
}
|
|
467
514
|
|
|
468
515
|
private emit(event: BackendEvent): void {
|
|
516
|
+
if (event.type === 'model') {
|
|
517
|
+
this.model = event.model;
|
|
518
|
+
}
|
|
469
519
|
this.host.onEvent(event);
|
|
470
520
|
}
|
|
471
521
|
}
|