@adecore/agents 0.31.4 → 0.31.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/dist/chat/acp-backend.d.ts +7 -0
- package/dist/chat/acp-backend.js +87 -6
- package/dist/chat/acp-protocol.d.ts +6 -0
- package/dist/chat/acp-protocol.js +34 -9
- package/dist/providers/accounts/acp-account.d.ts +1 -0
- package/dist/providers/accounts/acp-account.js +25 -3
- package/dist/providers/acp-agent-state.d.ts +35 -0
- package/dist/providers/acp-agent-state.js +170 -0
- package/dist/providers/acp-provider.d.ts +2 -0
- package/dist/providers/acp-provider.js +29 -33
- package/dist/providers/catalog.d.ts +2 -0
- package/dist/providers/catalog.js +14 -4
- package/dist/providers/index.d.ts +1 -0
- package/dist/providers/index.js +1 -0
- package/dist/providers/provider.d.ts +1 -0
- package/dist/providers/registry.d.ts +1 -0
- package/dist/providers/registry.js +9 -0
- package/dist/testing/fake-acp.d.ts +3 -0
- package/dist/testing/fake-acp.js +11 -4
- package/dist/usage/aggregate.js +1 -1
- package/dist/usage/index-file.d.ts +2 -0
- package/dist/usage/index-file.js +19 -10
- package/dist/usage/pricing.d.ts +3 -2
- package/dist/usage/pricing.js +42 -8
- package/dist/usage/readers/codex.d.ts +9 -0
- package/dist/usage/readers/codex.js +75 -14
- package/dist/usage/record.d.ts +1 -0
- package/dist/usage/scanner.js +32 -8
- package/package.json +3 -3
- package/src/chat/acp-backend.ts +93 -5
- package/src/chat/acp-protocol.ts +41 -9
- package/src/providers/accounts/acp-account.ts +34 -3
- package/src/providers/acp-agent-state.ts +206 -0
- package/src/providers/acp-provider.ts +33 -37
- package/src/providers/catalog.ts +19 -5
- package/src/providers/index.ts +1 -0
- package/src/providers/provider.ts +2 -0
- package/src/providers/registry.ts +10 -0
- package/src/testing/fake-acp.ts +17 -4
- package/src/usage/aggregate.ts +1 -1
- package/src/usage/index-file.ts +25 -18
- package/src/usage/pricing.ts +46 -8
- package/src/usage/readers/codex.ts +86 -14
- package/src/usage/record.ts +1 -0
- package/src/usage/scanner.ts +35 -8
package/dist/usage/scanner.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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 { zstdDecompressSync } from 'node:zlib';
|
|
4
5
|
import { errorText } from '../error-text.js';
|
|
5
6
|
import { isNotFound, writeAtomic } from '../fs.js';
|
|
6
7
|
import { decodeIndex, encodeIndex } from './index-file.js';
|
|
@@ -11,6 +12,7 @@ import { hostLogMightCarryUsage, parseHostUsageLine } from './readers/host-log.j
|
|
|
11
12
|
import { cloneCodexState, codexMightCarryUsage, createCodexState, parseCodexLine } from './readers/codex.js';
|
|
12
13
|
import { usageRoots } from './roots.js';
|
|
13
14
|
const HASH_CHUNK_BYTES = 65536;
|
|
15
|
+
const CODEX_PARSER_VERSION = 2;
|
|
14
16
|
/*
|
|
15
17
|
* Hashes the bytes before `from` and returns the rest, in one pass over one handle, so verifying
|
|
16
18
|
* what a transcript committed and parsing what it added never reads a byte twice.
|
|
@@ -55,7 +57,7 @@ function readsWhole(provider) {
|
|
|
55
57
|
return provider === 'grok';
|
|
56
58
|
}
|
|
57
59
|
function isUsageFile(provider, name) {
|
|
58
|
-
return readsWhole(provider) ? name === GROK_USAGE_FILE : name.endsWith('.jsonl');
|
|
60
|
+
return readsWhole(provider) ? name === GROK_USAGE_FILE : name.endsWith('.jsonl') || (provider === 'codex' && name.endsWith('.jsonl.zst'));
|
|
59
61
|
}
|
|
60
62
|
async function listUsageFiles(dir, provider, out = []) {
|
|
61
63
|
let entries;
|
|
@@ -215,7 +217,12 @@ export class UsageScanner {
|
|
|
215
217
|
}
|
|
216
218
|
let failure = null;
|
|
217
219
|
const accountOf = accountResolver(root, sessionAccounts);
|
|
218
|
-
|
|
220
|
+
const paths = await listUsageFiles(root.path, root.provider);
|
|
221
|
+
const plain = new Set(paths);
|
|
222
|
+
for (const path of paths) {
|
|
223
|
+
if (root.provider === 'codex' && path.endsWith('.jsonl.zst') && plain.has(path.slice(0, -4))) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
219
226
|
files += 1;
|
|
220
227
|
try {
|
|
221
228
|
if (await this.readFileInto(path, root.provider, accountOf)) {
|
|
@@ -246,15 +253,31 @@ export class UsageScanner {
|
|
|
246
253
|
}
|
|
247
254
|
async readFileInto(path, provider, accountOf) {
|
|
248
255
|
const info = await stat(path);
|
|
249
|
-
const
|
|
250
|
-
|
|
256
|
+
const compressed = provider === 'codex' && path.endsWith('.jsonl.zst');
|
|
257
|
+
const key = compressed ? path.slice(0, -4) : path;
|
|
258
|
+
const known = this.index.get(key);
|
|
259
|
+
const currentParser = provider !== 'codex' || known?.parserVersion === CODEX_PARSER_VERSION;
|
|
260
|
+
if (currentParser &&
|
|
261
|
+
known !== undefined &&
|
|
262
|
+
known.provider === provider &&
|
|
263
|
+
(known.compressed ?? false) === compressed &&
|
|
264
|
+
known.size === info.size &&
|
|
265
|
+
known.mtimeMs === info.mtimeMs) {
|
|
251
266
|
return false;
|
|
252
267
|
}
|
|
253
268
|
if (readsWhole(provider)) {
|
|
254
269
|
return this.readWholeInto(path, provider, accountOf, info);
|
|
255
270
|
}
|
|
256
|
-
const appended =
|
|
257
|
-
|
|
271
|
+
const appended = !compressed &&
|
|
272
|
+
currentParser &&
|
|
273
|
+
known?.compressed !== true &&
|
|
274
|
+
known !== undefined &&
|
|
275
|
+
known.provider === provider &&
|
|
276
|
+
info.size > known.size &&
|
|
277
|
+
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);
|
|
258
281
|
// A file only counts as grown while it starts the way it did; one rewritten larger is read again from the start.
|
|
259
282
|
const grown = appended && headOf(read.hash) === known.head;
|
|
260
283
|
if (appended && !grown) {
|
|
@@ -273,9 +296,10 @@ export class UsageScanner {
|
|
|
273
296
|
records: foldByKey([...before, ...withAccount(parsed.records, accountOf)]),
|
|
274
297
|
tail: withAccount(parsed.tail, accountOf),
|
|
275
298
|
codex: parsed.codex,
|
|
276
|
-
head: headOf(read.hash)
|
|
299
|
+
head: headOf(read.hash),
|
|
300
|
+
...(provider === 'codex' ? { parserVersion: CODEX_PARSER_VERSION, compressed } : {})
|
|
277
301
|
};
|
|
278
|
-
this.index.set(
|
|
302
|
+
this.index.set(key, entry);
|
|
279
303
|
this.dirty = true;
|
|
280
304
|
return true;
|
|
281
305
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/agents",
|
|
3
|
-
"version": "0.31.
|
|
3
|
+
"version": "0.31.6",
|
|
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.6",
|
|
85
|
+
"@adecore/intelligent-ui": "0.31.6",
|
|
86
86
|
"zod": "^4.6.5"
|
|
87
87
|
},
|
|
88
88
|
"devDependencies": {
|
package/src/chat/acp-backend.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
2
4
|
import { attachmentImageMime, type ChatAttachment, type ModelSelection } from '@adecore/agent-contracts';
|
|
3
5
|
import { errorText } from '../error-text.ts';
|
|
4
6
|
import { MODE_ORDER } from '../modes.ts';
|
|
@@ -14,6 +16,7 @@ import {
|
|
|
14
16
|
resolveAcpMode,
|
|
15
17
|
runtimeModeOf,
|
|
16
18
|
sessionIdOf,
|
|
19
|
+
sessionUpdateOf,
|
|
17
20
|
thoughtLevelOf,
|
|
18
21
|
type AcpAgentCapabilities,
|
|
19
22
|
type AcpModeMap,
|
|
@@ -56,6 +59,10 @@ export interface AcpAgentSpec {
|
|
|
56
59
|
onSession?(info: AcpSessionInfo): void;
|
|
57
60
|
// The tokens of every turn whose `session/prompt` response reports them, for a host that keeps a usage log.
|
|
58
61
|
onUsage?(usage: AcpUsageReport): void;
|
|
62
|
+
// Called once per process, at the first `usage_update` that names the context window.
|
|
63
|
+
onContextReport?(): void;
|
|
64
|
+
// The slash commands a session offers (without the slash), each time the agent lists them.
|
|
65
|
+
onCommands?(commands: string[]): void;
|
|
59
66
|
// A permission request the host answers itself, in any runtime mode: true allows it once without asking the
|
|
60
67
|
// person, for a call the host already guards. A throw counts as false.
|
|
61
68
|
autoAllow?(request: AcpPermissionRequest): boolean;
|
|
@@ -71,6 +78,15 @@ export interface AcpUsageReport extends AcpTurnUsage {
|
|
|
71
78
|
// What `initialize` and a session request may take; a prompt and a login wait for as long as the agent runs.
|
|
72
79
|
const REQUEST_TIMEOUT_MS = 60_000;
|
|
73
80
|
|
|
81
|
+
// What a prompt carries of mentioned files; a larger file stays a path the agent opens with its own tools.
|
|
82
|
+
const MAX_EMBEDDED_FILE_BYTES = 256 * 1024;
|
|
83
|
+
const MAX_EMBEDDED_BYTES = 1024 * 1024;
|
|
84
|
+
|
|
85
|
+
function inside(folder: string, path: string): boolean {
|
|
86
|
+
const rest = relative(folder, path);
|
|
87
|
+
return rest === '' || (!rest.startsWith('..') && !isAbsolute(rest));
|
|
88
|
+
}
|
|
89
|
+
|
|
74
90
|
function nameOf(client: AcpClientInfo): string {
|
|
75
91
|
return client.title ?? client.name;
|
|
76
92
|
}
|
|
@@ -95,6 +111,10 @@ export class AcpBackend implements ChatBackend {
|
|
|
95
111
|
private contextPending: string | null = null;
|
|
96
112
|
private prompt: { promptId: string | undefined; cancelled: boolean } | null = null;
|
|
97
113
|
private model: string | null = null;
|
|
114
|
+
private contextReported = false;
|
|
115
|
+
private commands: string[] = [];
|
|
116
|
+
// An agent may send updates for a new session before it answers with the session's id.
|
|
117
|
+
private early: unknown[] = [];
|
|
98
118
|
|
|
99
119
|
constructor(launch: BackendLaunch, host: BackendHost, spec: AcpAgentSpec) {
|
|
100
120
|
this.launch = launch;
|
|
@@ -167,6 +187,9 @@ export class AcpBackend implements ChatBackend {
|
|
|
167
187
|
effectiveRuntimeMode: this.launch.runtimeMode,
|
|
168
188
|
...(modeId === null ? {} : { permissionMode: modeId })
|
|
169
189
|
});
|
|
190
|
+
for (const params of this.early.splice(0)) {
|
|
191
|
+
this.emitAll(this.protocol.update(this.sessionId, params));
|
|
192
|
+
}
|
|
170
193
|
}
|
|
171
194
|
|
|
172
195
|
private sessionParams(): Record<string, unknown> {
|
|
@@ -334,7 +357,50 @@ export class AcpBackend implements ChatBackend {
|
|
|
334
357
|
const named: ChatAttachment[] = input.attachments.filter((attachment) => !images.includes(attachment));
|
|
335
358
|
const parts = [this.contextPending, input.preamble, input.text, attachmentNote(named)].filter((part): part is string => part !== null && part !== '');
|
|
336
359
|
this.contextPending = null;
|
|
337
|
-
return [{ type: 'text', text: parts.join('\n\n') }, ...blocks];
|
|
360
|
+
return [{ type: 'text', text: parts.join('\n\n') }, ...blocks, ...this.mentionBlocks(input.mentions)];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/*
|
|
364
|
+
* The text of each mentioned file as a `resource` block, for an agent that takes embedded context. Only a text file
|
|
365
|
+
* inside the chat's folders goes along, since a mention is a path anyone who may send could name; the rest stays
|
|
366
|
+
* in the prompt as the path it is.
|
|
367
|
+
*/
|
|
368
|
+
private mentionBlocks(mentions: readonly string[]): unknown[] {
|
|
369
|
+
if (this.capabilities?.embeddedContext !== true || mentions.length === 0) {
|
|
370
|
+
return [];
|
|
371
|
+
}
|
|
372
|
+
const real = (path: string): string | null => {
|
|
373
|
+
try {
|
|
374
|
+
return realpathSync(path);
|
|
375
|
+
} catch {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
const folders = [this.launch.cwd, ...(this.launch.folders ?? [])].flatMap((folder) => real(folder) ?? []);
|
|
380
|
+
const blocks: unknown[] = [];
|
|
381
|
+
let budget = MAX_EMBEDDED_BYTES;
|
|
382
|
+
for (const mention of new Set(mentions)) {
|
|
383
|
+
const path = real(resolve(this.launch.cwd, mention));
|
|
384
|
+
if (path === null || !folders.some((folder) => inside(folder, path))) {
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
let text: string;
|
|
388
|
+
try {
|
|
389
|
+
const stat = statSync(path);
|
|
390
|
+
if (!stat.isFile() || stat.size > MAX_EMBEDDED_FILE_BYTES || stat.size > budget) {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
text = readFileSync(path, 'utf8');
|
|
394
|
+
} catch {
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (text.includes('\u0000')) {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
budget -= Buffer.byteLength(text);
|
|
401
|
+
blocks.push({ type: 'resource', resource: { uri: pathToFileURL(path).href, text } });
|
|
402
|
+
}
|
|
403
|
+
return blocks;
|
|
338
404
|
}
|
|
339
405
|
|
|
340
406
|
sendTurn(input: TurnInput): void {
|
|
@@ -364,8 +430,12 @@ export class AcpBackend implements ChatBackend {
|
|
|
364
430
|
});
|
|
365
431
|
}
|
|
366
432
|
|
|
367
|
-
/* ACP has no call to fold the context
|
|
433
|
+
/* ACP has no call to fold the context, so an agent that lists a `compact` command gets it as a prompt. */
|
|
368
434
|
compact(): void {
|
|
435
|
+
if (this.commands.includes('compact')) {
|
|
436
|
+
this.sendTurn({ text: '/compact', preamble: null, attachments: [], mentions: [], skills: [] });
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
369
439
|
this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} cannot fold its context on request.` });
|
|
370
440
|
this.emit({ type: 'turn.done', state: 'done', costUsd: 0 });
|
|
371
441
|
}
|
|
@@ -443,9 +513,11 @@ export class AcpBackend implements ChatBackend {
|
|
|
443
513
|
}
|
|
444
514
|
const id = typeof frame.id === 'number' || typeof frame.id === 'string' ? frame.id : null;
|
|
445
515
|
if (frame.method === 'session/update' && id === null) {
|
|
446
|
-
if (
|
|
447
|
-
this.
|
|
516
|
+
if (this.sessionId === '' || this.replaying) {
|
|
517
|
+
this.holdEarly(frame.params);
|
|
518
|
+
return;
|
|
448
519
|
}
|
|
520
|
+
this.emitAll(this.protocol.update(this.sessionId, frame.params));
|
|
449
521
|
return;
|
|
450
522
|
}
|
|
451
523
|
if (id === null) {
|
|
@@ -479,6 +551,14 @@ export class AcpBackend implements ChatBackend {
|
|
|
479
551
|
this.emitAll(permission.events);
|
|
480
552
|
}
|
|
481
553
|
|
|
554
|
+
/* Holds an update until the session is known; of a history a load replays, only the commands, since the thread has the rest. */
|
|
555
|
+
private holdEarly(params: unknown): void {
|
|
556
|
+
const update = sessionUpdateOf(params);
|
|
557
|
+
if (update !== null && (!this.replaying || update.commands !== null)) {
|
|
558
|
+
this.early.push(params);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
482
562
|
private hostAllows(request: AcpPermissionRequest): boolean {
|
|
483
563
|
try {
|
|
484
564
|
return this.spec.autoAllow?.(request) === true;
|
|
@@ -516,6 +596,14 @@ export class AcpBackend implements ChatBackend {
|
|
|
516
596
|
if (event.type === 'model') {
|
|
517
597
|
this.model = event.model;
|
|
518
598
|
}
|
|
599
|
+
if (event.type === 'session' && event.slashCommands !== undefined) {
|
|
600
|
+
this.commands = event.slashCommands;
|
|
601
|
+
this.spec.onCommands?.(event.slashCommands);
|
|
602
|
+
}
|
|
603
|
+
if (event.type === 'usage' && event.contextWindow !== undefined && !this.contextReported) {
|
|
604
|
+
this.contextReported = true;
|
|
605
|
+
this.spec.onContextReport?.();
|
|
606
|
+
}
|
|
519
607
|
this.host.onEvent(event);
|
|
520
608
|
}
|
|
521
609
|
}
|
package/src/chat/acp-protocol.ts
CHANGED
|
@@ -107,7 +107,7 @@ const ModeStateSchema = z.looseObject({
|
|
|
107
107
|
|
|
108
108
|
const ModeSchema = z.looseObject({ id: z.string(), name: z.string() });
|
|
109
109
|
|
|
110
|
-
const SelectValueSchema = z.looseObject({ value: z.string(), name: z.string(), description: z.string().nullish() });
|
|
110
|
+
const SelectValueSchema = z.looseObject({ value: z.string(), name: z.string(), description: z.string().nullish(), _meta: z.unknown().optional() });
|
|
111
111
|
|
|
112
112
|
const ConfigOptionSchema = z.looseObject({
|
|
113
113
|
id: z.string(),
|
|
@@ -124,7 +124,13 @@ const ModelStateSchema = z.looseObject({
|
|
|
124
124
|
availableModels: z.array(z.unknown())
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
-
const ModelEntrySchema = z.looseObject({ modelId: z.string(), name: z.string(), description: z.string().nullish() });
|
|
127
|
+
const ModelEntrySchema = z.looseObject({ modelId: z.string(), name: z.string(), description: z.string().nullish(), _meta: z.unknown().optional() });
|
|
128
|
+
|
|
129
|
+
/* The context window an agent names for a model in the model's `_meta` (`totalContextTokens`, not part of the protocol); null where it names none. */
|
|
130
|
+
function contextWindowOf(meta: unknown): number | null {
|
|
131
|
+
const tokens = isRecord(meta) ? meta.totalContextTokens : undefined;
|
|
132
|
+
return typeof tokens === 'number' && Number.isSafeInteger(tokens) && tokens > 0 ? tokens : null;
|
|
133
|
+
}
|
|
128
134
|
|
|
129
135
|
const AuthMethodSchema = z.looseObject({ id: z.string(), name: z.string() });
|
|
130
136
|
|
|
@@ -133,7 +139,7 @@ const InitializeResponseSchema = z.looseObject({
|
|
|
133
139
|
agentCapabilities: z
|
|
134
140
|
.looseObject({
|
|
135
141
|
loadSession: z.boolean().optional(),
|
|
136
|
-
promptCapabilities: z.looseObject({ image: z.boolean().optional() }).optional(),
|
|
142
|
+
promptCapabilities: z.looseObject({ image: z.boolean().optional(), embeddedContext: z.boolean().optional() }).optional(),
|
|
137
143
|
sessionCapabilities: z
|
|
138
144
|
.looseObject({
|
|
139
145
|
resume: z.unknown().optional(),
|
|
@@ -172,6 +178,8 @@ export interface AcpAgentCapabilities {
|
|
|
172
178
|
// Whether the agent takes `session/close`, which ends a session it would otherwise keep.
|
|
173
179
|
closeSession: boolean;
|
|
174
180
|
images: boolean;
|
|
181
|
+
// Whether a prompt may carry a file's text as a `resource` block.
|
|
182
|
+
embeddedContext: boolean;
|
|
175
183
|
authMethods: string[];
|
|
176
184
|
agent: AcpAgentInfo | null;
|
|
177
185
|
}
|
|
@@ -191,6 +199,7 @@ export function parseInitialize(result: unknown): AcpAgentCapabilities {
|
|
|
191
199
|
additionalDirectories: sessions?.additionalDirectories !== undefined && sessions.additionalDirectories !== null,
|
|
192
200
|
closeSession: sessions?.close !== undefined && sessions.close !== null,
|
|
193
201
|
images: capabilities?.promptCapabilities?.image === true,
|
|
202
|
+
embeddedContext: capabilities?.promptCapabilities?.embeddedContext === true,
|
|
194
203
|
authMethods: validItems(AuthMethodSchema, parsed.data.authMethods).map((method) => method.id),
|
|
195
204
|
agent: agent ? { name: agent.name, title: agent.title ?? null, version: agent.version } : null
|
|
196
205
|
};
|
|
@@ -200,6 +209,8 @@ export interface AcpModel {
|
|
|
200
209
|
id: string;
|
|
201
210
|
name: string;
|
|
202
211
|
description: string | null;
|
|
212
|
+
// In tokens, where the agent names it.
|
|
213
|
+
contextWindow: number | null;
|
|
203
214
|
}
|
|
204
215
|
|
|
205
216
|
/* The models a session offers. `configId` is the config option that picks one, or null where the agent takes `session/set_model`. */
|
|
@@ -288,7 +299,12 @@ function sessionState(options: ConfigOption[], modes: unknown, models: unknown):
|
|
|
288
299
|
models: modelOption
|
|
289
300
|
? {
|
|
290
301
|
current: modelOption.currentValue as string,
|
|
291
|
-
models: selectValues(modelOption).map((value) => ({
|
|
302
|
+
models: selectValues(modelOption).map((value) => ({
|
|
303
|
+
id: value.value,
|
|
304
|
+
name: value.name,
|
|
305
|
+
description: null,
|
|
306
|
+
contextWindow: contextWindowOf(value._meta)
|
|
307
|
+
})),
|
|
292
308
|
configId: modelOption.id
|
|
293
309
|
}
|
|
294
310
|
: modelState.success
|
|
@@ -297,7 +313,8 @@ function sessionState(options: ConfigOption[], modes: unknown, models: unknown):
|
|
|
297
313
|
models: validItems(ModelEntrySchema, modelState.data.availableModels).map((model) => ({
|
|
298
314
|
id: model.modelId,
|
|
299
315
|
name: model.name,
|
|
300
|
-
description: model.description ?? null
|
|
316
|
+
description: model.description ?? null,
|
|
317
|
+
contextWindow: contextWindowOf(model._meta)
|
|
301
318
|
})),
|
|
302
319
|
configId: null
|
|
303
320
|
}
|
|
@@ -325,6 +342,23 @@ function sessionState(options: ConfigOption[], modes: unknown, models: unknown):
|
|
|
325
342
|
};
|
|
326
343
|
}
|
|
327
344
|
|
|
345
|
+
function commandNames(commands: unknown): string[] {
|
|
346
|
+
return validItems(CommandSchema, Array.isArray(commands) ? commands : []).map((command) => command.name);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/* The session a `session/update` notification is for, and the commands it lists when it is an `available_commands_update`; null when it does not parse. */
|
|
350
|
+
export function sessionUpdateOf(params: unknown): { sessionId: string; commands: string[] | null } | null {
|
|
351
|
+
const notification = SessionNotificationSchema.safeParse(params);
|
|
352
|
+
if (!notification.success) {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
const update = notification.data.update;
|
|
356
|
+
return {
|
|
357
|
+
sessionId: notification.data.sessionId,
|
|
358
|
+
commands: update.sessionUpdate === 'available_commands_update' ? commandNames(update.availableCommands) : null
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
328
362
|
export function sessionIdOf(result: unknown): string | null {
|
|
329
363
|
const parsed = SessionResponseSchema.safeParse(result);
|
|
330
364
|
return parsed.success ? (parsed.data.sessionId ?? null) : null;
|
|
@@ -590,10 +624,8 @@ export class AcpProtocol {
|
|
|
590
624
|
}
|
|
591
625
|
case 'plan':
|
|
592
626
|
return this.plan(update);
|
|
593
|
-
case 'available_commands_update':
|
|
594
|
-
|
|
595
|
-
return [{ type: 'session', agentSessionId: null, model: null, slashCommands: commands.map((command) => command.name) }];
|
|
596
|
-
}
|
|
627
|
+
case 'available_commands_update':
|
|
628
|
+
return [{ type: 'session', agentSessionId: null, model: null, slashCommands: commandNames(update.availableCommands) }];
|
|
597
629
|
case 'current_mode_update':
|
|
598
630
|
return typeof update.currentModeId === 'string' ? [this.modeEvent(update.currentModeId)] : [];
|
|
599
631
|
case 'config_option_update': {
|
|
@@ -2,7 +2,15 @@ import { mkdtemp, rm } from 'node:fs/promises';
|
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { DEFAULT_ACP_CLIENT, type AcpClientInfo } from '../../chat/acp-backend.ts';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
ACP_AUTH_REQUIRED,
|
|
7
|
+
ACP_PROTOCOL_VERSION,
|
|
8
|
+
parseInitialize,
|
|
9
|
+
parseSessionInfo,
|
|
10
|
+
sessionIdOf,
|
|
11
|
+
sessionUpdateOf,
|
|
12
|
+
type AcpSessionInfo
|
|
13
|
+
} from '../../chat/acp-protocol.ts';
|
|
6
14
|
import type { SpawnChatProcess } from '../../chat/chat-process.ts';
|
|
7
15
|
import { JsonRpcError, JsonRpcTransport, type JsonRpcFrame } from '../../chat/json-rpc-transport.ts';
|
|
8
16
|
import { ASK_TIMEOUT_MS, type AccountReading } from './status.ts';
|
|
@@ -24,6 +32,8 @@ export interface AcpAccountProbe {
|
|
|
24
32
|
timer?: ProbeTimer;
|
|
25
33
|
// What the probe's session offered, for a host that fills its model catalog from the agent.
|
|
26
34
|
onSession?(info: AcpSessionInfo): void;
|
|
35
|
+
// The slash commands the probe's session offered, when the agent listed them before the session closed.
|
|
36
|
+
onCommands?(commands: string[]): void;
|
|
27
37
|
}
|
|
28
38
|
|
|
29
39
|
const SIGNED_OUT: AccountReading = { signedIn: false, email: null, plan: null, organization: null };
|
|
@@ -63,6 +73,20 @@ export async function askAcpAccount(probe: AcpAccountProbe): Promise<AccountRead
|
|
|
63
73
|
const folder = await mkdtemp(join(tmpdir(), 'acp-account-'));
|
|
64
74
|
const client = probe.client ?? DEFAULT_ACP_CLIENT;
|
|
65
75
|
let transport: JsonRpcTransport | null = null;
|
|
76
|
+
// An agent may list its commands before it answers with the session's id.
|
|
77
|
+
const listed = new Map<string, string[]>();
|
|
78
|
+
let sessionId: string | null = null;
|
|
79
|
+
const noteCommands = (frame: JsonRpcFrame): void => {
|
|
80
|
+
const update = frame.method === 'session/update' && (frame.id === undefined || frame.id === null) ? sessionUpdateOf(frame.params) : null;
|
|
81
|
+
if (update === null || update.commands === null) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (sessionId === null) {
|
|
85
|
+
listed.set(update.sessionId, update.commands);
|
|
86
|
+
} else if (update.sessionId === sessionId) {
|
|
87
|
+
probe.onCommands?.(update.commands);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
66
90
|
try {
|
|
67
91
|
const started = new JsonRpcTransport({
|
|
68
92
|
command: [...probe.command],
|
|
@@ -74,7 +98,10 @@ export async function askAcpAccount(probe: AcpAccountProbe): Promise<AccountRead
|
|
|
74
98
|
// The probe's own deadline covers every request.
|
|
75
99
|
requestTimeoutMs: null,
|
|
76
100
|
versionField: true,
|
|
77
|
-
onFrame: (frame) =>
|
|
101
|
+
onFrame: (frame) => {
|
|
102
|
+
noteCommands(frame);
|
|
103
|
+
refuseRequest(started, frame);
|
|
104
|
+
},
|
|
78
105
|
onExit: () => undefined
|
|
79
106
|
});
|
|
80
107
|
transport = started;
|
|
@@ -98,11 +125,15 @@ export async function askAcpAccount(probe: AcpAccountProbe): Promise<AccountRead
|
|
|
98
125
|
}
|
|
99
126
|
throw error;
|
|
100
127
|
}
|
|
101
|
-
|
|
128
|
+
sessionId = sessionIdOf(result);
|
|
102
129
|
if (sessionId === null) {
|
|
103
130
|
throw new Error(`${probe.name} opened a session without an id`);
|
|
104
131
|
}
|
|
105
132
|
probe.onSession?.(parseSessionInfo(result, sessionId, capabilities.agent));
|
|
133
|
+
const commands = listed.get(sessionId);
|
|
134
|
+
if (commands !== undefined) {
|
|
135
|
+
probe.onCommands?.(commands);
|
|
136
|
+
}
|
|
106
137
|
if (capabilities.closeSession) {
|
|
107
138
|
// The answer is known by now; an agent that cannot close it keeps an empty session, nothing worse.
|
|
108
139
|
await started.request('session/close', { sessionId }).catch(() => undefined);
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { ModelOptionDescriptorSchema, type ModelOptionDescriptor, type ProviderCapabilities } from '@adecore/agent-contracts';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import type { AcpSessionInfo } from '../chat/acp-protocol.ts';
|
|
4
|
+
import type { ModelCatalog } from './catalog.ts';
|
|
5
|
+
|
|
6
|
+
/* Where a provider keeps what its agent reported, so a host that starts again offers it before the agent runs. The value is the provider's own. */
|
|
7
|
+
export interface AcpCatalogStore {
|
|
8
|
+
load(): unknown;
|
|
9
|
+
save(value: unknown): void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const AdoptedSchema = z.object({
|
|
13
|
+
defaultModel: z.string().min(1),
|
|
14
|
+
models: z.array(z.object({ slug: z.string().min(1), name: z.string(), contextWindow: z.number().int().positive().optional() })).min(1),
|
|
15
|
+
options: z.array(ModelOptionDescriptorSchema)
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
type Adopted = z.infer<typeof AdoptedSchema>;
|
|
19
|
+
|
|
20
|
+
/* An agent's model id may reach a command line, so one that reads as a flag is left out. */
|
|
21
|
+
function adoptableModels(info: AcpSessionInfo): Adopted['models'] {
|
|
22
|
+
return (info.models?.models ?? [])
|
|
23
|
+
.filter((model) => model.id !== '' && !model.id.startsWith('-'))
|
|
24
|
+
.map((model) => ({ slug: model.id, name: model.name, ...(model.contextWindow === null ? {} : { contextWindow: model.contextWindow }) }));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* The reasoning effort a session offers, as the `effort` option the composer draws for every provider. */
|
|
28
|
+
function effortOption(info: AcpSessionInfo): ModelOptionDescriptor[] {
|
|
29
|
+
const level = info.thoughtLevel;
|
|
30
|
+
if (level === null || !level.choices.some((choice) => choice.id === level.current)) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
return [
|
|
34
|
+
{
|
|
35
|
+
id: 'effort',
|
|
36
|
+
label: 'Reasoning',
|
|
37
|
+
type: 'select',
|
|
38
|
+
defaultChoice: level.current,
|
|
39
|
+
choices: level.choices.map((choice) => ({
|
|
40
|
+
id: choice.id,
|
|
41
|
+
label: choice.name,
|
|
42
|
+
...(choice.description === null ? {} : { description: choice.description })
|
|
43
|
+
}))
|
|
44
|
+
}
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* Parses one stored part on its own, so a part a later version wrote differently costs only that part. */
|
|
49
|
+
function storedPart<T>(schema: z.ZodType<T>, value: unknown): T | null {
|
|
50
|
+
const parsed = schema.safeParse(value);
|
|
51
|
+
return parsed.success ? parsed.data : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AcpAgentStateOptions {
|
|
55
|
+
catalog: ModelCatalog;
|
|
56
|
+
adoptModels: boolean;
|
|
57
|
+
store: AcpCatalogStore | null;
|
|
58
|
+
// The capabilities the provider reports before what the agent said is laid over them.
|
|
59
|
+
base: ProviderCapabilities;
|
|
60
|
+
// What the host set itself, which wins over what the agent said.
|
|
61
|
+
overrides: Partial<ProviderCapabilities>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/*
|
|
65
|
+
* What an agent reported about itself, outside any one session: the models it offers and what follows from it for the
|
|
66
|
+
* provider's capabilities. Kept in the host's store when it has one, and every change is told to the listeners, so a host
|
|
67
|
+
* can hand its clients the new list.
|
|
68
|
+
*/
|
|
69
|
+
export class AcpAgentState {
|
|
70
|
+
// Changed in place, so a host that spread the provider into a value of its own reads the same object.
|
|
71
|
+
readonly capabilities: ProviderCapabilities;
|
|
72
|
+
private readonly options: AcpAgentStateOptions;
|
|
73
|
+
private readonly listeners = new Set<() => void>();
|
|
74
|
+
private adopted: Adopted | null = null;
|
|
75
|
+
// Whether the agent reported its context window in a `usage_update`, once in any session.
|
|
76
|
+
private reportsContext = false;
|
|
77
|
+
// The slash commands the last session listed; null until one did.
|
|
78
|
+
private commands: string[] | null = null;
|
|
79
|
+
|
|
80
|
+
constructor(options: AcpAgentStateOptions) {
|
|
81
|
+
this.options = options;
|
|
82
|
+
this.capabilities = { ...options.base, ...options.overrides };
|
|
83
|
+
this.restore();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
onChange(listener: () => void): () => void {
|
|
87
|
+
this.listeners.add(listener);
|
|
88
|
+
return () => {
|
|
89
|
+
this.listeners.delete(listener);
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
session(info: AcpSessionInfo): void {
|
|
94
|
+
if (!this.options.adoptModels || info.models === null) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const adopted: Adopted = { defaultModel: info.models.current, models: adoptableModels(info), options: effortOption(info) };
|
|
98
|
+
const changed = this.adopt(adopted);
|
|
99
|
+
this.settle(changed);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
commandsListed(commands: readonly string[]): void {
|
|
103
|
+
const names = [...new Set(commands)].sort();
|
|
104
|
+
if (this.commands !== null && JSON.stringify(names) === JSON.stringify(this.commands)) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
this.commands = names;
|
|
108
|
+
const changed = this.refresh();
|
|
109
|
+
// The commands are saved even when the capabilities stay, so a restart knows them too.
|
|
110
|
+
this.save();
|
|
111
|
+
if (changed) {
|
|
112
|
+
this.notify();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
contextReported(): void {
|
|
117
|
+
if (this.reportsContext) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
this.reportsContext = true;
|
|
121
|
+
this.settle(this.refresh());
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private restore(): void {
|
|
125
|
+
let stored: unknown;
|
|
126
|
+
try {
|
|
127
|
+
stored = this.options.store?.load();
|
|
128
|
+
} catch {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (typeof stored !== 'object' || stored === null) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const record = stored as Record<string, unknown>;
|
|
135
|
+
const adopted = storedPart(AdoptedSchema, record.models);
|
|
136
|
+
if (adopted !== null && this.options.adoptModels) {
|
|
137
|
+
this.adopt(adopted);
|
|
138
|
+
}
|
|
139
|
+
this.reportsContext = record.reportsContext === true;
|
|
140
|
+
this.commands = storedPart(z.array(z.string()), record.commands);
|
|
141
|
+
this.refresh();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* What the agent showed it can do, under what the host set itself. */
|
|
145
|
+
private learned(): Partial<ProviderCapabilities> {
|
|
146
|
+
const commands = this.commands ?? [];
|
|
147
|
+
return {
|
|
148
|
+
...(this.reportsContext ? { reportsContextWindow: true } : {}),
|
|
149
|
+
...(commands.includes('compact') ? { compaction: 'prompt' as const } : {}),
|
|
150
|
+
...(commands.includes('goal') ? { goals: 'command' as const } : {})
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* Lays what the agent showed over the capabilities, in place; answers whether they changed. */
|
|
155
|
+
private refresh(): boolean {
|
|
156
|
+
const next: ProviderCapabilities = { ...this.options.base, ...this.learned(), ...this.options.overrides };
|
|
157
|
+
if (JSON.stringify(next) === JSON.stringify(this.capabilities)) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
const capabilities = this.capabilities as Partial<ProviderCapabilities>;
|
|
161
|
+
for (const key of Object.keys(capabilities) as Array<keyof ProviderCapabilities>) {
|
|
162
|
+
delete capabilities[key];
|
|
163
|
+
}
|
|
164
|
+
Object.assign(capabilities, next);
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* Answers whether the catalog changed; a list without its default is refused there and leaves the one adopted before. */
|
|
169
|
+
private adopt(adopted: Adopted): boolean {
|
|
170
|
+
if (!adopted.models.some((model) => model.slug === adopted.defaultModel)) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
this.adopted = adopted;
|
|
174
|
+
return this.options.catalog.adopt(adopted.models, adopted.defaultModel, adopted.options);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* Saves and tells the listeners once something changed. */
|
|
178
|
+
private settle(changed: boolean): void {
|
|
179
|
+
if (changed) {
|
|
180
|
+
this.save();
|
|
181
|
+
this.notify();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private save(): void {
|
|
186
|
+
try {
|
|
187
|
+
this.options.store?.save(this.stored());
|
|
188
|
+
} catch {
|
|
189
|
+
// A store that cannot write costs the next start what it would have kept, never this session.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private notify(): void {
|
|
194
|
+
for (const listener of [...this.listeners]) {
|
|
195
|
+
listener();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private stored(): Record<string, unknown> {
|
|
200
|
+
return {
|
|
201
|
+
...(this.adopted === null ? {} : { models: this.adopted }),
|
|
202
|
+
...(this.reportsContext ? { reportsContext: true } : {}),
|
|
203
|
+
...(this.commands === null ? {} : { commands: this.commands })
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
}
|