@adecore/agents 0.31.4 → 0.31.5
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/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
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createAcpBackend } from '../chat/acp-backend.js';
|
|
2
2
|
import { askAcpAccount } from './accounts/acp-account.js';
|
|
3
|
+
import { AcpAgentState } from './acp-agent-state.js';
|
|
3
4
|
import { writeAcpUsage } from './acp-usage-log.js';
|
|
4
5
|
import { detectCli } from './detect.js';
|
|
5
6
|
/*
|
|
6
|
-
* What every ACP agent can do through this backend. A file change comes as the text
|
|
7
|
-
* a message names its mentions and attachments by path,
|
|
7
|
+
* What every ACP agent can do through this backend, before what it shows of itself. A file change comes as the text
|
|
8
|
+
* before and after; a message names its mentions and attachments by path, and carries the files themselves where the agent takes them.
|
|
8
9
|
*/
|
|
9
10
|
export const ACP_CAPABILITIES = {
|
|
10
11
|
chat: true,
|
|
@@ -25,38 +26,22 @@ export const ACP_CAPABILITIES = {
|
|
|
25
26
|
};
|
|
26
27
|
/* Agents whose own usage files the scanner reads; a log of their turns would count every one twice. */
|
|
27
28
|
const OWN_USAGE_FILES = new Set(['grok']);
|
|
28
|
-
/* An agent's model id may reach a command line, so one that reads as a flag is left out. */
|
|
29
|
-
function adoptableModels(info) {
|
|
30
|
-
return (info.models?.models ?? []).filter((model) => model.id !== '' && !model.id.startsWith('-')).map((model) => ({ slug: model.id, name: model.name }));
|
|
31
|
-
}
|
|
32
|
-
/* The reasoning effort a session offers, as the `effort` option the composer draws for every provider. */
|
|
33
|
-
function effortOption(info) {
|
|
34
|
-
const level = info.thoughtLevel;
|
|
35
|
-
if (level === null || !level.choices.some((choice) => choice.id === level.current)) {
|
|
36
|
-
return [];
|
|
37
|
-
}
|
|
38
|
-
return [
|
|
39
|
-
{
|
|
40
|
-
id: 'effort',
|
|
41
|
-
label: 'Reasoning',
|
|
42
|
-
type: 'select',
|
|
43
|
-
defaultChoice: level.current,
|
|
44
|
-
choices: level.choices.map((choice) => ({
|
|
45
|
-
id: choice.id,
|
|
46
|
-
label: choice.name,
|
|
47
|
-
...(choice.description === null ? {} : { description: choice.description })
|
|
48
|
-
}))
|
|
49
|
-
}
|
|
50
|
-
];
|
|
51
|
-
}
|
|
52
29
|
export function createAcpProvider(spec) {
|
|
53
|
-
const
|
|
30
|
+
const state = new AcpAgentState({
|
|
31
|
+
catalog: spec.catalog,
|
|
32
|
+
adoptModels: spec.adoptModels !== false,
|
|
33
|
+
store: spec.catalogStore ?? null,
|
|
34
|
+
base: ACP_CAPABILITIES,
|
|
35
|
+
overrides: spec.capabilities ?? {}
|
|
36
|
+
});
|
|
54
37
|
const onSession = (info) => {
|
|
55
|
-
|
|
56
|
-
spec.catalog.adopt(adoptableModels(info), info.models.current, effortOption(info));
|
|
57
|
-
}
|
|
38
|
+
state.session(info);
|
|
58
39
|
spec.onSession?.(info);
|
|
59
40
|
};
|
|
41
|
+
const onCommands = (commands) => {
|
|
42
|
+
state.commandsListed(commands);
|
|
43
|
+
spec.onCommands?.(commands);
|
|
44
|
+
};
|
|
60
45
|
const usageLog = spec.usageLog === undefined || OWN_USAGE_FILES.has(spec.kind) ? null : spec.usageLog;
|
|
61
46
|
const onUsage = usageLog === null
|
|
62
47
|
? spec.onUsage
|
|
@@ -68,7 +53,8 @@ export function createAcpProvider(spec) {
|
|
|
68
53
|
kind: spec.kind,
|
|
69
54
|
name: spec.name,
|
|
70
55
|
catalog: spec.catalog,
|
|
71
|
-
capabilities,
|
|
56
|
+
capabilities: state.capabilities,
|
|
57
|
+
onChange: (listener) => state.onChange(listener),
|
|
72
58
|
command: [spec.command, ...(spec.args ?? [])],
|
|
73
59
|
resumeCommand: spec.resumeCommand ?? '',
|
|
74
60
|
...(spec.ignoresProjectConfig === undefined ? {} : { ignoresProjectConfig: spec.ignoresProjectConfig }),
|
|
@@ -90,9 +76,19 @@ export function createAcpProvider(spec) {
|
|
|
90
76
|
command,
|
|
91
77
|
env: { ...env, ...spec.env },
|
|
92
78
|
...(spec.client ? { client: spec.client } : {}),
|
|
93
|
-
onSession
|
|
79
|
+
onSession,
|
|
80
|
+
onCommands
|
|
94
81
|
})
|
|
95
82
|
}),
|
|
96
|
-
createBackend: createAcpBackend({
|
|
83
|
+
createBackend: createAcpBackend({
|
|
84
|
+
...spec,
|
|
85
|
+
onSession,
|
|
86
|
+
onCommands,
|
|
87
|
+
onContextReport: () => {
|
|
88
|
+
state.contextReported();
|
|
89
|
+
spec.onContextReport?.();
|
|
90
|
+
},
|
|
91
|
+
...(onUsage === undefined ? {} : { onUsage })
|
|
92
|
+
})
|
|
97
93
|
};
|
|
98
94
|
}
|
|
@@ -5,12 +5,14 @@ export type ModelCatalogInput = Omit<ModelCatalogData, 'updatedAt'> & {
|
|
|
5
5
|
export declare class ModelCatalog {
|
|
6
6
|
private readonly shippedAt;
|
|
7
7
|
private data;
|
|
8
|
+
private hostWindows;
|
|
8
9
|
constructor(data?: ModelCatalogInput);
|
|
9
10
|
get defaultModel(): string;
|
|
10
11
|
replace(data: ModelCatalogData): boolean;
|
|
11
12
|
adopt(models: ReadonlyArray<{
|
|
12
13
|
slug: string;
|
|
13
14
|
name: string;
|
|
15
|
+
contextWindow?: number;
|
|
14
16
|
}>, defaultModel: string, options?: readonly ModelOptionDescriptor[]): boolean;
|
|
15
17
|
list(): ModelInfo[];
|
|
16
18
|
nameOf(slug: string): string;
|
|
@@ -11,6 +11,8 @@ function timeOf(data) {
|
|
|
11
11
|
export class ModelCatalog {
|
|
12
12
|
shippedAt;
|
|
13
13
|
data;
|
|
14
|
+
// The sizes the host's own default had before an agent's models replaced it, for an adopted model that names none.
|
|
15
|
+
hostWindows;
|
|
14
16
|
constructor(data = manifest) {
|
|
15
17
|
this.shippedAt = timeOf(data);
|
|
16
18
|
this.data = data;
|
|
@@ -25,13 +27,14 @@ export class ModelCatalog {
|
|
|
25
27
|
}
|
|
26
28
|
const changed = JSON.stringify(data) !== JSON.stringify(this.data);
|
|
27
29
|
this.data = data;
|
|
30
|
+
this.hostWindows = undefined;
|
|
28
31
|
return changed;
|
|
29
32
|
}
|
|
30
33
|
/*
|
|
31
34
|
* Takes the models an agent reported for itself in place of the list, the agent's current model as the
|
|
32
35
|
* default and its order kept; answers whether the models changed. Every model gets the profile the
|
|
33
36
|
* default has now, as an agent names its models but not what each can be asked for, with `options`
|
|
34
|
-
* in place of the profile's options of the same id.
|
|
37
|
+
* in place of the profile's options of the same id, and the context window in tokens a model names as its only size.
|
|
35
38
|
*/
|
|
36
39
|
adopt(models, defaultModel, options = []) {
|
|
37
40
|
if (models.length === 0 || !models.some((model) => model.slug === defaultModel)) {
|
|
@@ -39,12 +42,19 @@ export class ModelCatalog {
|
|
|
39
42
|
}
|
|
40
43
|
const base = this.profileOf(this.data.defaultModel) ?? { options: [], contextWindowTokens: {} };
|
|
41
44
|
const replaced = new Set(options.map((option) => option.id));
|
|
42
|
-
|
|
45
|
+
this.hostWindows ??= base.contextWindowTokens;
|
|
46
|
+
const profile = { ...base, contextWindowTokens: this.hostWindows, options: [...base.options.filter((option) => !replaced.has(option.id)), ...options] };
|
|
47
|
+
const profileName = (window) => (window === undefined ? ADOPTED_PROFILE : `${ADOPTED_PROFILE}-${window}`);
|
|
48
|
+
const profiles = {};
|
|
49
|
+
for (const model of models) {
|
|
50
|
+
profiles[profileName(model.contextWindow)] =
|
|
51
|
+
model.contextWindow === undefined ? profile : { ...profile, contextWindowTokens: { '*': model.contextWindow } };
|
|
52
|
+
}
|
|
43
53
|
const data = {
|
|
44
54
|
...(this.data.updatedAt === undefined ? {} : { updatedAt: this.data.updatedAt }),
|
|
45
55
|
defaultModel,
|
|
46
|
-
profiles
|
|
47
|
-
models: models.map((model) => ({ slug: model.slug, name: model.name, profile:
|
|
56
|
+
profiles,
|
|
57
|
+
models: models.map((model) => ({ slug: model.slug, name: model.name, profile: profileName(model.contextWindow) }))
|
|
48
58
|
};
|
|
49
59
|
const changed = JSON.stringify(data) !== JSON.stringify(this.data);
|
|
50
60
|
this.data = data;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { type AcpCatalogStore } from './acp-agent-state.ts';
|
|
1
2
|
export { ACP_CAPABILITIES, type AcpProviderSpec, createAcpProvider } from './acp-provider.ts';
|
|
2
3
|
export { type AcpAccountProbe, askAcpAccount, type ProbeTimer } from './accounts/acp-account.ts';
|
|
3
4
|
export { AccountError, type AccountLaunches, storedAccount, launchEnv } from './accounts/launch.ts';
|
package/dist/providers/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Public API: exports.test.ts snapshots this list, so a name added or dropped here is an API change.
|
|
2
|
+
export {} from './acp-agent-state.js';
|
|
2
3
|
export { ACP_CAPABILITIES, createAcpProvider } from './acp-provider.js';
|
|
3
4
|
export { askAcpAccount } from './accounts/acp-account.js';
|
|
4
5
|
export { AccountError, storedAccount, launchEnv } from './accounts/launch.js';
|
|
@@ -25,5 +25,6 @@ export interface ChatProvider {
|
|
|
25
25
|
detect(command: string, env: Record<string, string | undefined>): Promise<CliDetection>;
|
|
26
26
|
enabled?(): boolean;
|
|
27
27
|
askAccount?(command: readonly string[], env: Record<string, string | undefined>): Promise<AccountReading>;
|
|
28
|
+
onChange?(listener: () => void): () => void;
|
|
28
29
|
createBackend(launch: BackendLaunch, host: BackendHost): ChatBackend;
|
|
29
30
|
}
|
|
@@ -23,6 +23,7 @@ export declare class ProviderRegistry {
|
|
|
23
23
|
oneShotProvider(preferred?: AgentKind): Promise<ChatProvider | null>;
|
|
24
24
|
catalogFor(kind: AgentKind): ModelCatalog;
|
|
25
25
|
enabled(kind: AgentKind): boolean;
|
|
26
|
+
onChange(listener: (kind: AgentKind) => void): () => void;
|
|
26
27
|
invalidate(kind: AgentKind): void;
|
|
27
28
|
protected fallback(kind: AgentKind): ChatProvider;
|
|
28
29
|
private detection;
|
|
@@ -97,6 +97,15 @@ export class ProviderRegistry {
|
|
|
97
97
|
enabled(kind) {
|
|
98
98
|
return this.get(kind).enabled?.() !== false;
|
|
99
99
|
}
|
|
100
|
+
/* Calls the listener with the kind of every provider whose models or capabilities changed, so a host can list them again; answers how to stop. */
|
|
101
|
+
onChange(listener) {
|
|
102
|
+
const stops = this.providers.flatMap((provider) => (provider.onChange ? [provider.onChange(() => listener(provider.kind))] : []));
|
|
103
|
+
return () => {
|
|
104
|
+
for (const stop of stops) {
|
|
105
|
+
stop();
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
100
109
|
invalidate(kind) {
|
|
101
110
|
this.cache.delete(kind);
|
|
102
111
|
}
|
|
@@ -8,6 +8,9 @@ export interface FakeAcpOptions {
|
|
|
8
8
|
closeSession?: boolean;
|
|
9
9
|
turnUsage?: Record<string, number>;
|
|
10
10
|
reasoningEffort?: 'accept' | 'reject';
|
|
11
|
+
contextWindows?: boolean;
|
|
12
|
+
commands?: readonly string[];
|
|
13
|
+
embeddedContext?: boolean;
|
|
11
14
|
}
|
|
12
15
|
export declare function fakeAcpWith(options: FakeAcpOptions): FakeCli;
|
|
13
16
|
export declare const fakeAcp: FakeCli;
|
package/dist/testing/fake-acp.js
CHANGED
|
@@ -38,9 +38,10 @@ export function fakeAcpWith(options) {
|
|
|
38
38
|
prompt = null;
|
|
39
39
|
};
|
|
40
40
|
const sessionState = () => {
|
|
41
|
+
const meta = (totalContextTokens) => (options.contextWindows ? { _meta: { totalContextTokens } } : {});
|
|
41
42
|
const modelChoices = [
|
|
42
|
-
{ value: 'fake-small', name: 'Fake small' },
|
|
43
|
-
{ value: 'fake-large', name: 'Fake large' }
|
|
43
|
+
{ value: 'fake-small', name: 'Fake small', ...meta(128000) },
|
|
44
|
+
{ value: 'fake-large', name: 'Fake large', ...meta(1000000) }
|
|
44
45
|
];
|
|
45
46
|
const effortChoices = [
|
|
46
47
|
{ value: 'low', name: 'Low', description: 'Answers quickly' },
|
|
@@ -53,7 +54,7 @@ export function fakeAcpWith(options) {
|
|
|
53
54
|
return {
|
|
54
55
|
...(modes.length === 0 ? {} : { modes: { currentModeId: mode, availableModes: modes.map((id) => ({ id, name: id })) } }),
|
|
55
56
|
...(options.legacyModels
|
|
56
|
-
? { models: { currentModelId: model, availableModels: modelChoices.map((choice) => ({ modelId:
|
|
57
|
+
? { models: { currentModelId: model, availableModels: modelChoices.map(({ value, ...choice }) => ({ modelId: value, ...choice })) } }
|
|
57
58
|
: {
|
|
58
59
|
configOptions: [
|
|
59
60
|
{ id: 'model', name: 'Model', type: 'select', category: 'model', currentValue: model, options: modelChoices },
|
|
@@ -193,7 +194,7 @@ export function fakeAcpWith(options) {
|
|
|
193
194
|
protocolVersion: 1,
|
|
194
195
|
agentCapabilities: {
|
|
195
196
|
loadSession: options.loadSession ?? true,
|
|
196
|
-
promptCapabilities: { image: true, audio: false, embeddedContext:
|
|
197
|
+
promptCapabilities: { image: true, audio: false, embeddedContext: options.embeddedContext === true },
|
|
197
198
|
sessionCapabilities: { ...(options.resumeSession ? { resume: {} } : {}), ...(options.closeSession ? { close: {} } : {}) }
|
|
198
199
|
},
|
|
199
200
|
authMethods: options.authMethod === undefined ? [] : [{ id: options.authMethod, name: 'Sign in' }],
|
|
@@ -211,6 +212,12 @@ export function fakeAcpWith(options) {
|
|
|
211
212
|
}
|
|
212
213
|
sessionId = `fake-session-${Math.random().toString(36).slice(2, 8)}`;
|
|
213
214
|
sessionHistory.set(sessionId, []);
|
|
215
|
+
if (options.commands !== undefined) {
|
|
216
|
+
update({
|
|
217
|
+
sessionUpdate: 'available_commands_update',
|
|
218
|
+
availableCommands: options.commands.map((name) => ({ name, description: name }))
|
|
219
|
+
});
|
|
220
|
+
}
|
|
214
221
|
respond(id, { sessionId, ...sessionState() });
|
|
215
222
|
return;
|
|
216
223
|
case 'session/load':
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/agents",
|
|
3
|
-
"version": "0.31.
|
|
3
|
+
"version": "0.31.5",
|
|
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.5",
|
|
85
|
+
"@adecore/intelligent-ui": "0.31.5",
|
|
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);
|