@adecore/agents 0.31.11 → 0.32.0
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 +4 -2
- package/dist/chat/acp-backend.js +50 -11
- package/dist/chat/acp-protocol.d.ts +29 -4
- package/dist/chat/acp-protocol.js +262 -23
- package/dist/chat/backend.d.ts +3 -0
- package/dist/chat/chat-core.js +6 -3
- package/dist/chat/chat-session.js +5 -2
- package/dist/chat/claude-backend.js +4 -4
- package/dist/chat/claude-protocol.d.ts +4 -1
- package/dist/chat/claude-protocol.js +46 -2
- package/dist/chat/codex-backend.js +4 -4
- package/dist/chat/fixtures/antigravity-1.4.1-question.json +69 -0
- package/dist/chat/fixtures/antigravity-1.4.1-quota.json +35 -0
- package/dist/chat/fixtures/grok-1.0.62-limits.json +66 -0
- package/dist/chat/fixtures/grok-1.0.62-question.json +99 -0
- package/dist/chat/json-rpc-transport.js +2 -2
- package/dist/chat/projector.js +22 -10
- package/dist/providers/accounts/service.js +4 -0
- package/dist/providers/claude-provider.js +2 -1
- package/dist/providers/detect.d.ts +1 -1
- package/dist/providers/detect.js +6 -3
- package/dist/providers/registry.d.ts +7 -0
- package/dist/providers/registry.js +64 -5
- package/dist/skills.d.ts +9 -1
- package/dist/skills.js +178 -20
- package/dist/testing/fake-acp-capture.d.ts +1 -0
- package/dist/testing/fake-acp-capture.js +4 -3
- package/dist/usage/accounts.js +3 -2
- package/package.json +3 -3
- package/src/chat/acp-backend.ts +56 -11
- package/src/chat/acp-protocol.ts +308 -26
- package/src/chat/backend.ts +5 -1
- package/src/chat/chat-core.ts +6 -3
- package/src/chat/chat-session.ts +5 -2
- package/src/chat/claude-backend.ts +4 -4
- package/src/chat/claude-protocol.ts +48 -2
- package/src/chat/codex-backend.ts +4 -4
- package/src/chat/fixtures/antigravity-1.4.1-question.json +69 -0
- package/src/chat/fixtures/antigravity-1.4.1-quota.json +35 -0
- package/src/chat/fixtures/grok-1.0.62-limits.json +66 -0
- package/src/chat/fixtures/grok-1.0.62-question.json +99 -0
- package/src/chat/json-rpc-transport.ts +2 -2
- package/src/chat/projector.ts +24 -12
- package/src/providers/accounts/service.ts +4 -0
- package/src/providers/claude-provider.ts +2 -1
- package/src/providers/detect.ts +7 -3
- package/src/providers/registry.ts +71 -5
- package/src/skills.ts +198 -19
- package/src/testing/fake-acp-capture.ts +8 -4
- package/src/usage/accounts.ts +3 -2
|
@@ -51,12 +51,45 @@ export class ProviderRegistry {
|
|
|
51
51
|
detect;
|
|
52
52
|
env;
|
|
53
53
|
cache = new Map();
|
|
54
|
+
listeners = new Set();
|
|
55
|
+
// What the registry listens to on each provider, so one taken away is let go.
|
|
56
|
+
stops = new Map();
|
|
54
57
|
constructor(options = {}) {
|
|
55
|
-
this.providers = options.providers ??
|
|
58
|
+
this.providers = [...(options.providers ?? BUILT_IN_PROVIDERS)];
|
|
56
59
|
this.commands = options.commands ?? {};
|
|
57
60
|
this.detect = options.detect ?? null;
|
|
58
61
|
this.env = options.env ?? process.env;
|
|
59
62
|
}
|
|
63
|
+
/*
|
|
64
|
+
* Offers one more provider after the host started, such as a CLI a person installed since, or puts it in the
|
|
65
|
+
* place of the one of its kind. The `onChange` listeners hear its kind, so a host hands its clients the new list.
|
|
66
|
+
*/
|
|
67
|
+
add(provider) {
|
|
68
|
+
const index = this.providers.findIndex((entry) => entry.kind === provider.kind);
|
|
69
|
+
if (index === -1) {
|
|
70
|
+
this.providers.push(provider);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
this.unfollow(this.providers[index]);
|
|
74
|
+
this.providers[index] = provider;
|
|
75
|
+
}
|
|
76
|
+
this.cache.delete(provider.kind);
|
|
77
|
+
if (this.listeners.size > 0) {
|
|
78
|
+
this.follow(provider);
|
|
79
|
+
}
|
|
80
|
+
this.changed(provider.kind);
|
|
81
|
+
}
|
|
82
|
+
/* Stops offering a kind; a record that names it gets the stand-in `get` answers for a kind the host does not offer. */
|
|
83
|
+
remove(kind) {
|
|
84
|
+
const index = this.providers.findIndex((entry) => entry.kind === kind);
|
|
85
|
+
if (index === -1) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
this.unfollow(this.providers[index]);
|
|
89
|
+
this.providers.splice(index, 1);
|
|
90
|
+
this.cache.delete(kind);
|
|
91
|
+
this.changed(kind);
|
|
92
|
+
}
|
|
60
93
|
async list() {
|
|
61
94
|
return await Promise.all(this.providers.map(async (provider) => ({
|
|
62
95
|
kind: provider.kind,
|
|
@@ -97,12 +130,24 @@ export class ProviderRegistry {
|
|
|
97
130
|
enabled(kind) {
|
|
98
131
|
return this.get(kind).enabled?.() !== false;
|
|
99
132
|
}
|
|
100
|
-
/*
|
|
133
|
+
/*
|
|
134
|
+
* Calls the listener with the kind of every provider whose models or capabilities changed, and of every one
|
|
135
|
+
* added or removed, so a host can list them again; answers how to stop.
|
|
136
|
+
*/
|
|
101
137
|
onChange(listener) {
|
|
102
|
-
|
|
138
|
+
if (this.listeners.size === 0) {
|
|
139
|
+
for (const provider of this.providers) {
|
|
140
|
+
this.follow(provider);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
this.listeners.add(listener);
|
|
103
144
|
return () => {
|
|
104
|
-
|
|
105
|
-
|
|
145
|
+
this.listeners.delete(listener);
|
|
146
|
+
if (this.listeners.size === 0) {
|
|
147
|
+
for (const stop of this.stops.values()) {
|
|
148
|
+
stop();
|
|
149
|
+
}
|
|
150
|
+
this.stops.clear();
|
|
106
151
|
}
|
|
107
152
|
};
|
|
108
153
|
}
|
|
@@ -112,6 +157,20 @@ export class ProviderRegistry {
|
|
|
112
157
|
fallback(kind) {
|
|
113
158
|
return unofferedProvider(kind);
|
|
114
159
|
}
|
|
160
|
+
follow(provider) {
|
|
161
|
+
if (provider.onChange && !this.stops.has(provider)) {
|
|
162
|
+
this.stops.set(provider, provider.onChange(() => this.changed(provider.kind)));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
unfollow(provider) {
|
|
166
|
+
this.stops.get(provider)?.();
|
|
167
|
+
this.stops.delete(provider);
|
|
168
|
+
}
|
|
169
|
+
changed(kind) {
|
|
170
|
+
for (const listener of [...this.listeners]) {
|
|
171
|
+
listener(kind);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
115
174
|
async detection(provider) {
|
|
116
175
|
if (provider.enabled?.() === false) {
|
|
117
176
|
return DISABLED;
|
package/dist/skills.d.ts
CHANGED
|
@@ -3,6 +3,12 @@ export interface SkillRoot {
|
|
|
3
3
|
dir: string;
|
|
4
4
|
source: ChatSkillSource;
|
|
5
5
|
prefix?: string;
|
|
6
|
+
depth?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface SkillScanLimits {
|
|
9
|
+
directories: number;
|
|
10
|
+
skills: number;
|
|
11
|
+
entries?: number;
|
|
6
12
|
}
|
|
7
13
|
export interface SkillRootOptions {
|
|
8
14
|
home?: string;
|
|
@@ -11,7 +17,9 @@ export interface SkillRootOptions {
|
|
|
11
17
|
export declare function parseFrontmatter(text: string): Record<string, string>;
|
|
12
18
|
export declare function pluginSkillRoots(configDir: string): Promise<SkillRoot[]>;
|
|
13
19
|
export declare function skillRootsFor(kind: AgentKind, cwd: string, options?: SkillRootOptions): Promise<SkillRoot[]>;
|
|
14
|
-
export declare function
|
|
20
|
+
export declare function whileBusy<T>(operation: () => Promise<T>, wait?: (ms: number) => Promise<void>): Promise<T>;
|
|
21
|
+
export declare function mapLimited<T, R>(items: readonly T[], limit: number, work: (item: T) => Promise<R>): Promise<R[]>;
|
|
22
|
+
export declare function scanSkillRoots(roots: SkillRoot[], limits?: SkillScanLimits): Promise<ChatSkill[]>;
|
|
15
23
|
export declare function discoverSkills(kind: AgentKind, cwd: string, options?: SkillRootOptions): Promise<ChatSkill[]>;
|
|
16
24
|
export declare class SkillIndex {
|
|
17
25
|
private readonly options;
|
package/dist/skills.js
CHANGED
|
@@ -1,8 +1,38 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
|
-
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { open, readdir, readFile, realpath, stat } from 'node:fs/promises';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, join, parse } from 'node:path';
|
|
5
5
|
import { isNotFound } from './fs.js';
|
|
6
|
+
const SCAN_LIMITS = { directories: 2000, skills: 1000 };
|
|
7
|
+
const ENTRIES_PER_FOLDER = 1000;
|
|
8
|
+
// Paths one folder resolves or reads at once, so a large folder never runs the process out of file handles.
|
|
9
|
+
const SCAN_CONCURRENCY = 16;
|
|
10
|
+
// A process short of file handles gets them back as other work closes its files, so a few short waits cover it.
|
|
11
|
+
const BUSY_RETRIES = 3;
|
|
12
|
+
const BUSY_RETRY_MS = 50;
|
|
13
|
+
// Codex finds skills up to five folders below a root (`MAX_SCAN_DEPTH` of 6 in its skill loader counts the root itself).
|
|
14
|
+
const CODEX_SKILL_DEPTH = 5;
|
|
15
|
+
// Folders a package manager or a build fills. One may hold a SKILL.md of its own, but nothing under it is listed.
|
|
16
|
+
const DEPENDENCY_FOLDERS = new Set([
|
|
17
|
+
'node_modules',
|
|
18
|
+
'bower_components',
|
|
19
|
+
'jspm_packages',
|
|
20
|
+
'vendor',
|
|
21
|
+
'Pods',
|
|
22
|
+
'Carthage',
|
|
23
|
+
'DerivedData',
|
|
24
|
+
'__pycache__',
|
|
25
|
+
'venv',
|
|
26
|
+
'site-packages',
|
|
27
|
+
'target',
|
|
28
|
+
'dist',
|
|
29
|
+
'build',
|
|
30
|
+
'out',
|
|
31
|
+
'coverage'
|
|
32
|
+
]);
|
|
33
|
+
// What a scan reads of a SKILL.md; the front matter sits at its top.
|
|
34
|
+
const HEAD_BYTES = 64 * 1024;
|
|
35
|
+
const MAX_DESCRIPTION = 1024;
|
|
6
36
|
const NAME = /^[A-Za-z][A-Za-z0-9_:-]*$/;
|
|
7
37
|
function unquote(value) {
|
|
8
38
|
const quoted = /^(['"])([\s\S]*)\1$/.exec(value);
|
|
@@ -99,9 +129,9 @@ export async function skillRootsFor(kind, cwd, options = {}) {
|
|
|
99
129
|
const home = options.home ?? homedir();
|
|
100
130
|
if (kind === 'codex') {
|
|
101
131
|
return [
|
|
102
|
-
{ dir: join(home, '.agents', 'skills'), source: 'user' },
|
|
103
|
-
{ dir: join(home, '.codex', 'skills'), source: 'user' },
|
|
104
|
-
...projectDirs(cwd, home).map((dir) => ({ dir: join(dir, '.agents', 'skills'), source: 'project' }))
|
|
132
|
+
{ dir: join(home, '.agents', 'skills'), source: 'user', depth: CODEX_SKILL_DEPTH },
|
|
133
|
+
{ dir: join(home, '.codex', 'skills'), source: 'user', depth: CODEX_SKILL_DEPTH },
|
|
134
|
+
...projectDirs(cwd, home).map((dir) => ({ dir: join(dir, '.agents', 'skills'), source: 'project', depth: CODEX_SKILL_DEPTH }))
|
|
105
135
|
];
|
|
106
136
|
}
|
|
107
137
|
const configDir = options.claudeConfigDir ?? join(home, '.claude');
|
|
@@ -111,38 +141,166 @@ export async function skillRootsFor(kind, cwd, options = {}) {
|
|
|
111
141
|
...(await pluginSkillRoots(configDir))
|
|
112
142
|
];
|
|
113
143
|
}
|
|
114
|
-
|
|
115
|
-
|
|
144
|
+
/* Too many files open, in this process or on the system: it says nothing about the path that was asked for. */
|
|
145
|
+
function isBusy(e) {
|
|
146
|
+
return typeof e === 'object' && e !== null && 'code' in e && (e.code === 'EMFILE' || e.code === 'ENFILE');
|
|
147
|
+
}
|
|
148
|
+
function sleep(ms) {
|
|
149
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
150
|
+
}
|
|
151
|
+
/* Runs `operation` again a few times while the process is short of file handles, and throws if it still is. */
|
|
152
|
+
export async function whileBusy(operation, wait = sleep) {
|
|
153
|
+
for (let attempt = 1;; attempt++) {
|
|
154
|
+
try {
|
|
155
|
+
return await operation();
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
if (!isBusy(e) || attempt > BUSY_RETRIES) {
|
|
159
|
+
throw e;
|
|
160
|
+
}
|
|
161
|
+
await wait(BUSY_RETRY_MS * attempt);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/* `work` over every item with at most `limit` running at once, answered in the order of `items`. */
|
|
166
|
+
export async function mapLimited(items, limit, work) {
|
|
167
|
+
const results = new Array(items.length);
|
|
168
|
+
let next = 0;
|
|
169
|
+
const worker = async () => {
|
|
170
|
+
while (next < items.length) {
|
|
171
|
+
const index = next++;
|
|
172
|
+
results[index] = await work(items[index]);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
176
|
+
return results;
|
|
177
|
+
}
|
|
178
|
+
/* The top of a folder's SKILL.md, or null when it has none. A process short of file handles throws instead of finding none. */
|
|
179
|
+
async function skillHead(folder) {
|
|
180
|
+
let handle;
|
|
116
181
|
try {
|
|
117
|
-
|
|
182
|
+
handle = await whileBusy(() => open(join(folder, 'SKILL.md'), 'r'));
|
|
183
|
+
}
|
|
184
|
+
catch (e) {
|
|
185
|
+
if (isBusy(e)) {
|
|
186
|
+
throw e;
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
if (!(await handle.stat()).isFile()) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
const buffer = Buffer.alloc(HEAD_BYTES);
|
|
195
|
+
const { bytesRead } = await handle.read(buffer, 0, HEAD_BYTES, 0);
|
|
196
|
+
return buffer.subarray(0, bytesRead).toString('utf8');
|
|
118
197
|
}
|
|
119
198
|
catch {
|
|
120
199
|
return null;
|
|
121
200
|
}
|
|
201
|
+
finally {
|
|
202
|
+
await handle.close();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function skillOf(root, folder, text) {
|
|
122
206
|
const fields = parseFrontmatter(text);
|
|
123
207
|
const own = fields.name && NAME.test(fields.name) ? fields.name : folder;
|
|
124
208
|
const name = root.prefix ? `${root.prefix}:${own}` : own;
|
|
125
209
|
if (!NAME.test(name)) {
|
|
126
210
|
return null;
|
|
127
211
|
}
|
|
128
|
-
return { name, description: fields.description ?? '', source: root.source };
|
|
212
|
+
return { name, description: (fields.description ?? '').slice(0, MAX_DESCRIPTION), source: root.source };
|
|
213
|
+
}
|
|
214
|
+
/* The folder a path leads to after every link, or null when it leads to no folder. */
|
|
215
|
+
async function realFolder(path) {
|
|
216
|
+
try {
|
|
217
|
+
const real = await whileBusy(() => realpath(path));
|
|
218
|
+
return (await whileBusy(() => stat(real))).isDirectory() ? real : null;
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
if (isBusy(e)) {
|
|
222
|
+
throw e;
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/*
|
|
228
|
+
* The skills under one root, nearest first, down to the root's depth. A folder with a SKILL.md is a skill and is not
|
|
229
|
+
* searched further. Each root keeps its own record of the folders it read or went into, by real path, so a link back
|
|
230
|
+
* up ends there, while a skill two plugins link in shows under each of them.
|
|
231
|
+
*/
|
|
232
|
+
async function scanRoot(root, limits) {
|
|
233
|
+
const start = await realFolder(root.dir);
|
|
234
|
+
if (start === null) {
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
const visited = new Set([start]);
|
|
238
|
+
const depth = root.depth ?? 1;
|
|
239
|
+
const entriesPerFolder = limits.entries ?? ENTRIES_PER_FOLDER;
|
|
240
|
+
const skills = [];
|
|
241
|
+
let level = [root.dir];
|
|
242
|
+
let listed = 0;
|
|
243
|
+
for (let below = 1; below <= depth && level.length > 0; below++) {
|
|
244
|
+
const next = [];
|
|
245
|
+
for (const folder of level) {
|
|
246
|
+
if (listed >= limits.directories || skills.length >= limits.skills) {
|
|
247
|
+
return skills;
|
|
248
|
+
}
|
|
249
|
+
listed++;
|
|
250
|
+
let entries;
|
|
251
|
+
try {
|
|
252
|
+
entries = await whileBusy(() => readdir(folder, { withFileTypes: true }));
|
|
253
|
+
}
|
|
254
|
+
catch (e) {
|
|
255
|
+
if (isBusy(e)) {
|
|
256
|
+
throw e;
|
|
257
|
+
}
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
// A skill manager links each skill in from a shared folder, so a link counts as much as a folder.
|
|
261
|
+
const candidates = entries
|
|
262
|
+
.filter((entry) => (entry.isDirectory() || entry.isSymbolicLink()) && !entry.name.startsWith('.'))
|
|
263
|
+
.map((entry) => ({ name: entry.name, path: join(folder, entry.name) }))
|
|
264
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
265
|
+
.slice(0, entriesPerFolder);
|
|
266
|
+
const reals = await mapLimited(candidates, SCAN_CONCURRENCY, (candidate) => realFolder(candidate.path));
|
|
267
|
+
const fresh = candidates.flatMap((candidate, index) => {
|
|
268
|
+
const real = reals[index];
|
|
269
|
+
return real === null || real === undefined || visited.has(real) ? [] : [{ ...candidate, real }];
|
|
270
|
+
});
|
|
271
|
+
const heads = await mapLimited(fresh, SCAN_CONCURRENCY, (candidate) => skillHead(candidate.path));
|
|
272
|
+
for (const [index, candidate] of fresh.entries()) {
|
|
273
|
+
// Two links in one folder can lead to the same place; the first one read it.
|
|
274
|
+
if (visited.has(candidate.real)) {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const head = heads[index];
|
|
278
|
+
if (head !== null && head !== undefined) {
|
|
279
|
+
visited.add(candidate.real);
|
|
280
|
+
const skill = skillOf(root, candidate.name, head);
|
|
281
|
+
if (skill !== null && skills.length < limits.skills) {
|
|
282
|
+
skills.push(skill);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
else if (below < depth && !DEPENDENCY_FOLDERS.has(candidate.name)) {
|
|
286
|
+
visited.add(candidate.real);
|
|
287
|
+
next.push(candidate.path);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
level = next;
|
|
292
|
+
}
|
|
293
|
+
return skills;
|
|
129
294
|
}
|
|
130
295
|
/* Every skill under these roots, sorted by name; the first root that carries a name wins. */
|
|
131
|
-
export async function scanSkillRoots(roots) {
|
|
296
|
+
export async function scanSkillRoots(roots, limits = SCAN_LIMITS) {
|
|
132
297
|
const found = new Map();
|
|
133
298
|
for (const root of roots) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
entries = await readdir(root.dir, { withFileTypes: true });
|
|
137
|
-
}
|
|
138
|
-
catch {
|
|
139
|
-
continue;
|
|
299
|
+
if (found.size >= limits.skills) {
|
|
300
|
+
break;
|
|
140
301
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const skills = await Promise.all(folders.map((entry) => readSkill(root, entry.name)));
|
|
144
|
-
for (const skill of skills) {
|
|
145
|
-
if (skill && !found.has(skill.name)) {
|
|
302
|
+
for (const skill of await scanRoot(root, limits)) {
|
|
303
|
+
if (!found.has(skill.name) && found.size < limits.skills) {
|
|
146
304
|
found.set(skill.name, skill);
|
|
147
305
|
}
|
|
148
306
|
}
|
|
@@ -7,6 +7,7 @@ export interface AcpCapture {
|
|
|
7
7
|
setConfigOption: unknown;
|
|
8
8
|
promptUpdates: unknown[];
|
|
9
9
|
promptResponse: unknown;
|
|
10
|
+
promptError?: unknown;
|
|
10
11
|
titleFromPrompt?: boolean;
|
|
11
12
|
}
|
|
12
13
|
export declare function fakeAcpCapture(capture: AcpCapture, answers?: unknown[]): FakeCli;
|
|
@@ -13,16 +13,17 @@ export function fakeAcpCapture(capture, answers = []) {
|
|
|
13
13
|
const play = (updates, promptId, text) => {
|
|
14
14
|
for (const [index, fields] of updates.entries()) {
|
|
15
15
|
const permission = fields.permission;
|
|
16
|
-
|
|
16
|
+
const request = fields.request;
|
|
17
|
+
if (permission !== undefined || request !== undefined) {
|
|
17
18
|
const rpcId = nextRpcId++;
|
|
18
19
|
waiting = { rpcId, rest: updates.slice(index + 1), promptId, text };
|
|
19
|
-
send({ id: rpcId, method: 'session/request_permission', params: permission });
|
|
20
|
+
send({ id: rpcId, method: request?.method ?? 'session/request_permission', params: request?.params ?? permission });
|
|
20
21
|
return;
|
|
21
22
|
}
|
|
22
23
|
const named = capture.titleFromPrompt === true && fields.sessionUpdate === 'session_info_update';
|
|
23
24
|
update(named ? { ...fields, title: text } : fields);
|
|
24
25
|
}
|
|
25
|
-
send({ id: promptId, result: capture.promptResponse });
|
|
26
|
+
send(capture.promptError === undefined ? { id: promptId, result: capture.promptResponse } : { id: promptId, error: capture.promptError });
|
|
26
27
|
};
|
|
27
28
|
return {
|
|
28
29
|
onLine: (line) => {
|
package/dist/usage/accounts.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { USAGE_PROVIDERS } from '@adecore/agent-contracts';
|
|
2
|
-
import { definedEnv } from '../providers/accounts/launch.js';
|
|
2
|
+
import { definedEnv, storedAccount } from '../providers/accounts/launch.js';
|
|
3
3
|
import { accountRoots } from './roots.js';
|
|
4
4
|
function isUsageProvider(kind) {
|
|
5
5
|
return USAGE_PROVIDERS.includes(kind);
|
|
@@ -48,7 +48,8 @@ export function limitAccountsOf(service, nameOf, baseEnv) {
|
|
|
48
48
|
list: () => machineAccounts(service.snapshot(), nameOf)
|
|
49
49
|
.filter((account) => account.isDefault || (account.enabled && account.signedIn))
|
|
50
50
|
.map((account) => ({ ...usageAccount(account), isDefault: account.isDefault })),
|
|
51
|
-
|
|
51
|
+
// A plan read is no start: the default account goes unnamed, so turning it off does not fail its read.
|
|
52
|
+
envFor: (kind, id) => definedEnv(service.envFor(kind, storedAccount(kind, id), baseEnv)),
|
|
52
53
|
lastUsedAt: (id) => service.lastLaunchAt(id)
|
|
53
54
|
};
|
|
54
55
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/agents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
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.
|
|
85
|
-
"@adecore/intelligent-ui": "0.
|
|
84
|
+
"@adecore/agent-contracts": "0.32.0",
|
|
85
|
+
"@adecore/intelligent-ui": "0.32.0",
|
|
86
86
|
"zod": "^4.6.5"
|
|
87
87
|
},
|
|
88
88
|
"devDependencies": {
|
package/src/chat/acp-backend.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
2
2
|
import { isAbsolute, relative, resolve } from 'node:path';
|
|
3
3
|
import { pathToFileURL } from 'node:url';
|
|
4
|
-
import { attachmentImageMime, type ChatAttachment, type ModelSelection } from '@adecore/agent-contracts';
|
|
4
|
+
import { attachmentImageMime, type AgentKind, type ChatAttachment, type ModelSelection } from '@adecore/agent-contracts';
|
|
5
5
|
import { errorText } from '../error-text.ts';
|
|
6
6
|
import { MODE_ORDER } from '../modes.ts';
|
|
7
7
|
import {
|
|
8
8
|
ACP_AUTH_REQUIRED,
|
|
9
9
|
ACP_PROTOCOL_VERSION,
|
|
10
10
|
AcpProtocol,
|
|
11
|
+
acpErrorLimit,
|
|
11
12
|
acpStopWords,
|
|
12
13
|
autoAllows,
|
|
14
|
+
GROK_QUESTION_METHODS,
|
|
13
15
|
parseInitialize,
|
|
14
16
|
parseSessionInfo,
|
|
15
17
|
promptUsageOf,
|
|
@@ -21,6 +23,7 @@ import {
|
|
|
21
23
|
type AcpAgentCapabilities,
|
|
22
24
|
type AcpModeMap,
|
|
23
25
|
type AcpPermissionRequest,
|
|
26
|
+
type AcpQuestion,
|
|
24
27
|
type AcpSessionInfo,
|
|
25
28
|
type AcpThoughtLevel,
|
|
26
29
|
type AcpTurnUsage
|
|
@@ -42,6 +45,8 @@ export const DEFAULT_ACP_CLIENT: AcpClientInfo = { name: 'agents', title: 'Agent
|
|
|
42
45
|
export interface AcpAgentSpec {
|
|
43
46
|
// The agent as a person reads it, in the notes and errors the backend writes.
|
|
44
47
|
name: string;
|
|
48
|
+
// Which agent this is, for an error code only that agent gives a meaning; absent reads none of them.
|
|
49
|
+
kind?: AgentKind;
|
|
45
50
|
// Variables set over the launch's environment.
|
|
46
51
|
env?: Record<string, string>;
|
|
47
52
|
// The agent's own mode ids per runtime mode, best first. A runtime mode the agent has no id for runs in
|
|
@@ -124,7 +129,7 @@ export class AcpBackend implements ChatBackend {
|
|
|
124
129
|
this.host = host;
|
|
125
130
|
this.spec = spec;
|
|
126
131
|
this.client = spec.client ?? DEFAULT_ACP_CLIENT;
|
|
127
|
-
this.protocol = new AcpProtocol(launch.generation, spec.modes ?? {}, acpStopWords(spec.name), spec.adoptTitles === true);
|
|
132
|
+
this.protocol = new AcpProtocol(launch.generation, spec.modes ?? {}, acpStopWords(spec.name), spec.adoptTitles === true, launch.now);
|
|
128
133
|
}
|
|
129
134
|
|
|
130
135
|
get running(): boolean {
|
|
@@ -411,7 +416,7 @@ export class AcpBackend implements ChatBackend {
|
|
|
411
416
|
sendTurn(input: TurnInput): void {
|
|
412
417
|
const transport = this.transport;
|
|
413
418
|
if (!transport) {
|
|
414
|
-
this.emit({ type: 'failed', message:
|
|
419
|
+
this.emit({ type: 'failed', message: `${this.spec.name} is not running` });
|
|
415
420
|
return;
|
|
416
421
|
}
|
|
417
422
|
const prompt = this.promptBlocks(input);
|
|
@@ -430,7 +435,8 @@ export class AcpBackend implements ChatBackend {
|
|
|
430
435
|
// A process that went has its exit tell the thread; anything else ends this turn alone.
|
|
431
436
|
if (this.transport === transport && transport.alive && this.prompt === turn) {
|
|
432
437
|
this.prompt = null;
|
|
433
|
-
|
|
438
|
+
const limit = error instanceof JsonRpcError ? acpErrorLimit(error.code, error.data, this.spec.kind ?? null) : null;
|
|
439
|
+
this.emitAll(this.protocol.abort(turn.promptId, turn.cancelled ? null : errorText(error), limit));
|
|
434
440
|
}
|
|
435
441
|
});
|
|
436
442
|
}
|
|
@@ -441,7 +447,7 @@ export class AcpBackend implements ChatBackend {
|
|
|
441
447
|
this.sendTurn({ text: '/compact', preamble: null, attachments: [], mentions: [], skills: [] });
|
|
442
448
|
return;
|
|
443
449
|
}
|
|
444
|
-
this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} cannot
|
|
450
|
+
this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} cannot compact its context.` });
|
|
445
451
|
this.emit({ type: 'turn.done', state: 'done', costUsd: 0 });
|
|
446
452
|
}
|
|
447
453
|
|
|
@@ -457,7 +463,7 @@ export class AcpBackend implements ChatBackend {
|
|
|
457
463
|
}
|
|
458
464
|
this.prompt.cancelled = true;
|
|
459
465
|
try {
|
|
460
|
-
for (const cancelled of this.protocol.
|
|
466
|
+
for (const cancelled of this.protocol.cancelRequests()) {
|
|
461
467
|
transport.respond(cancelled.rpcId, cancelled.result);
|
|
462
468
|
this.emit({ type: 'request.withdrawn', requestId: cancelled.requestId });
|
|
463
469
|
}
|
|
@@ -479,12 +485,23 @@ export class AcpBackend implements ChatBackend {
|
|
|
479
485
|
return true;
|
|
480
486
|
}
|
|
481
487
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
488
|
+
respondQuestion(requestId: string, answers: Record<string, string>): boolean {
|
|
489
|
+
const answer = this.protocol.answerQuestion(requestId, answers);
|
|
490
|
+
if (!answer) {
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
if (!this.respond(requestId, answer.rpcId, answer.result)) {
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
this.emitAll(answer.events);
|
|
497
|
+
return true;
|
|
485
498
|
}
|
|
486
499
|
|
|
487
500
|
declineRequest(requestId: string): boolean {
|
|
501
|
+
if (this.protocol.hasQuestion(requestId)) {
|
|
502
|
+
const cancelled = this.protocol.cancelQuestion(requestId);
|
|
503
|
+
return cancelled !== null && this.respond(requestId, cancelled.rpcId, cancelled.result);
|
|
504
|
+
}
|
|
488
505
|
return this.protocol.hasPermission(requestId) && this.respondApproval(requestId, 'deny');
|
|
489
506
|
}
|
|
490
507
|
|
|
@@ -532,10 +549,25 @@ export class AcpBackend implements ChatBackend {
|
|
|
532
549
|
if (id === null) {
|
|
533
550
|
return;
|
|
534
551
|
}
|
|
552
|
+
if (typeof frame.method === 'string' && GROK_QUESTION_METHODS.includes(frame.method)) {
|
|
553
|
+
const question = this.protocol.question(id, this.sessionId, frame.params);
|
|
554
|
+
if (question === null) {
|
|
555
|
+
transport.respondError(id, -32602, 'Not a question of this session');
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
this.ask(transport, question);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
535
561
|
if (frame.method !== 'session/request_permission') {
|
|
536
562
|
transport.respondError(id, -32601, `${nameOf(this.client)} does not handle ${String(frame.method)}`);
|
|
537
563
|
return;
|
|
538
564
|
}
|
|
565
|
+
// Before any runtime mode answers it: a question is never the agent's to allow.
|
|
566
|
+
const interaction = this.protocol.interaction(id, this.sessionId, frame.params);
|
|
567
|
+
if (interaction !== null) {
|
|
568
|
+
this.ask(transport, interaction);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
539
571
|
const permission = this.protocol.permission(id, this.sessionId, frame.params);
|
|
540
572
|
if (permission === null) {
|
|
541
573
|
transport.respondError(id, -32602, 'Not a permission request of this session');
|
|
@@ -543,7 +575,7 @@ export class AcpBackend implements ChatBackend {
|
|
|
543
575
|
}
|
|
544
576
|
if (this.prompt?.cancelled) {
|
|
545
577
|
this.emitAll(permission.events.filter((event) => event.type !== 'approval.requested'));
|
|
546
|
-
const answer = this.protocol.
|
|
578
|
+
const answer = this.protocol.cancelRequests().find((cancelled) => cancelled.requestId === permission.requestId);
|
|
547
579
|
if (answer) {
|
|
548
580
|
transport.respond(answer.rpcId, answer.result);
|
|
549
581
|
}
|
|
@@ -560,6 +592,19 @@ export class AcpBackend implements ChatBackend {
|
|
|
560
592
|
this.emitAll(permission.events);
|
|
561
593
|
}
|
|
562
594
|
|
|
595
|
+
/* Puts a question to the person, or turns it down at once when the turn is being cancelled. */
|
|
596
|
+
private ask(transport: JsonRpcTransport, question: AcpQuestion): void {
|
|
597
|
+
if (this.prompt?.cancelled) {
|
|
598
|
+
this.emitAll(question.events.filter((event) => event.type !== 'question.requested'));
|
|
599
|
+
const cancelled = this.protocol.cancelQuestion(question.requestId);
|
|
600
|
+
if (cancelled) {
|
|
601
|
+
transport.respond(cancelled.rpcId, cancelled.result);
|
|
602
|
+
}
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
this.emitAll(question.events);
|
|
606
|
+
}
|
|
607
|
+
|
|
563
608
|
/* Holds an update until the session is known; of a history a load replays, only the commands, since the thread has the rest. */
|
|
564
609
|
private holdEarly(params: unknown): void {
|
|
565
610
|
const update = sessionUpdateOf(params);
|
|
@@ -582,7 +627,7 @@ export class AcpBackend implements ChatBackend {
|
|
|
582
627
|
}
|
|
583
628
|
this.transport = null;
|
|
584
629
|
this.prompt = null;
|
|
585
|
-
this.protocol.
|
|
630
|
+
this.protocol.cancelRequests();
|
|
586
631
|
this.emit({ type: 'exit', exitCode, ...(stderr === null ? {} : { stderr }) });
|
|
587
632
|
}
|
|
588
633
|
|