@adecore/agents 0.29.3 → 0.31.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 +59 -0
- package/dist/chat/acp-backend.js +407 -0
- package/dist/chat/acp-protocol.d.ts +131 -0
- package/dist/chat/acp-protocol.js +605 -0
- package/dist/chat/codex-transport.d.ts +4 -27
- package/dist/chat/codex-transport.js +4 -185
- package/dist/chat/fixtures/acp-v1-turn.json +240 -0
- package/dist/chat/index.d.ts +2 -0
- package/dist/chat/index.js +1 -0
- package/dist/chat/json-rpc-transport.d.ts +50 -0
- package/dist/chat/json-rpc-transport.js +203 -0
- package/dist/providers/accounts/accounts.d.ts +10 -6
- package/dist/providers/accounts/accounts.js +19 -19
- package/dist/providers/accounts/service.d.ts +3 -0
- package/dist/providers/accounts/service.js +33 -17
- package/dist/providers/accounts/store.d.ts +4 -5
- package/dist/providers/accounts/store.js +12 -12
- package/dist/providers/acp-provider.d.ts +22 -0
- package/dist/providers/acp-provider.js +45 -0
- package/dist/providers/catalog.js +1 -0
- package/dist/providers/index.d.ts +1 -0
- package/dist/providers/index.js +1 -0
- package/dist/providers/provider.d.ts +1 -1
- package/dist/providers/registry.d.ts +1 -0
- package/dist/providers/registry.js +4 -0
- package/dist/testing/fake-acp.d.ts +10 -0
- package/dist/testing/fake-acp.js +262 -0
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/usage/index-file.d.ts +1 -1
- package/dist/usage/index-file.js +3 -1
- package/dist/usage/index.d.ts +2 -1
- package/dist/usage/index.js +2 -0
- package/dist/usage/limits/monitor.d.ts +3 -1
- package/dist/usage/limits/monitor.js +19 -4
- package/dist/usage/limits/normalize.d.ts +1 -0
- package/dist/usage/limits/normalize.js +2 -1
- package/dist/usage/readers/host-log.d.ts +16 -0
- package/dist/usage/readers/host-log.js +49 -0
- package/dist/usage/roots.js +6 -2
- package/dist/usage/scanner.js +13 -2
- package/package.json +3 -3
- package/src/chat/acp-backend.ts +476 -0
- package/src/chat/acp-protocol.ts +763 -0
- package/src/chat/codex-transport.ts +6 -207
- package/src/chat/fixtures/acp-v1-turn.json +240 -0
- package/src/chat/index.ts +2 -0
- package/src/chat/json-rpc-transport.ts +253 -0
- package/src/providers/accounts/accounts.ts +28 -25
- package/src/providers/accounts/service.ts +49 -18
- package/src/providers/accounts/store.ts +14 -17
- package/src/providers/acp-provider.ts +73 -0
- package/src/providers/catalog.ts +1 -0
- package/src/providers/index.ts +1 -0
- package/src/providers/provider.ts +2 -1
- package/src/providers/registry.ts +5 -0
- package/src/testing/fake-acp.ts +296 -0
- package/src/testing/index.ts +1 -0
- package/src/usage/index-file.ts +3 -2
- package/src/usage/index.ts +2 -1
- package/src/usage/limits/monitor.ts +24 -5
- package/src/usage/limits/normalize.ts +2 -1
- package/src/usage/readers/host-log.ts +72 -0
- package/src/usage/roots.ts +7 -3
- package/src/usage/scanner.ts +16 -5
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { AgentKind, ProviderCapabilities } from '@adecore/agent-contracts';
|
|
2
|
+
import { createAcpBackend, type AcpAgentSpec } from '../chat/acp-backend.ts';
|
|
3
|
+
import type { ModelCatalog } from './catalog.ts';
|
|
4
|
+
import { detectCli, type CliDetection } from './detect.ts';
|
|
5
|
+
import type { ChatProvider, ProviderHome } from './provider.ts';
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* What every ACP agent can do through this backend. A file change comes as the text before and after;
|
|
9
|
+
* a message names its mentions and attachments by path, as text the agent opens with its own tools.
|
|
10
|
+
*/
|
|
11
|
+
export const ACP_CAPABILITIES: ProviderCapabilities = {
|
|
12
|
+
chat: true,
|
|
13
|
+
terminal: false,
|
|
14
|
+
hooks: false,
|
|
15
|
+
streamsToolOutput: false,
|
|
16
|
+
diffs: 'before-after',
|
|
17
|
+
attachments: true,
|
|
18
|
+
mentions: true,
|
|
19
|
+
denyReason: false,
|
|
20
|
+
allowAlways: true,
|
|
21
|
+
asyncQuestions: false,
|
|
22
|
+
compaction: 'none',
|
|
23
|
+
reportsCost: false,
|
|
24
|
+
reportsContextWindow: false,
|
|
25
|
+
reportsThinking: true,
|
|
26
|
+
slashCommands: true
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/* One ACP agent as a host offers it: the backend's spec, plus what makes it a provider. */
|
|
30
|
+
export interface AcpProviderSpec extends AcpAgentSpec {
|
|
31
|
+
kind: AgentKind;
|
|
32
|
+
// The executable, and the arguments that start it as an ACP agent on its stdio.
|
|
33
|
+
command: string;
|
|
34
|
+
args?: readonly string[];
|
|
35
|
+
catalog: ModelCatalog;
|
|
36
|
+
// Over `ACP_CAPABILITIES`, for an agent that reports its cost or context window, or whose commands fold its context.
|
|
37
|
+
capabilities?: Partial<ProviderCapabilities>;
|
|
38
|
+
// What a terminal runs to continue one of its sessions; empty where it has none.
|
|
39
|
+
resumeCommand?: string;
|
|
40
|
+
home?: ProviderHome;
|
|
41
|
+
ignoresProjectConfig?: boolean;
|
|
42
|
+
oneShotArgs?(prompt: string): string[];
|
|
43
|
+
oneShotStdinArgs?: readonly string[];
|
|
44
|
+
// Absent leaves the agent to chats only.
|
|
45
|
+
firstPromptArgs?(prompt: string): string[];
|
|
46
|
+
// Absent asks the executable for `--version`.
|
|
47
|
+
detect?(command: string, env: Record<string, string | undefined>): Promise<CliDetection>;
|
|
48
|
+
enabled?(): boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createAcpProvider(spec: AcpProviderSpec): ChatProvider {
|
|
52
|
+
const capabilities = { ...ACP_CAPABILITIES, ...spec.capabilities };
|
|
53
|
+
return {
|
|
54
|
+
kind: spec.kind,
|
|
55
|
+
name: spec.name,
|
|
56
|
+
catalog: spec.catalog,
|
|
57
|
+
capabilities,
|
|
58
|
+
command: [spec.command, ...(spec.args ?? [])],
|
|
59
|
+
resumeCommand: spec.resumeCommand ?? '',
|
|
60
|
+
...(spec.ignoresProjectConfig === undefined ? {} : { ignoresProjectConfig: spec.ignoresProjectConfig }),
|
|
61
|
+
...(spec.home ? { home: spec.home } : {}),
|
|
62
|
+
...(spec.oneShotArgs ? { oneShotArgs: spec.oneShotArgs } : {}),
|
|
63
|
+
...(spec.oneShotStdinArgs ? { oneShotStdinArgs: spec.oneShotStdinArgs } : {}),
|
|
64
|
+
...(spec.enabled ? { enabled: spec.enabled } : {}),
|
|
65
|
+
firstPromptArgs:
|
|
66
|
+
spec.firstPromptArgs ??
|
|
67
|
+
(() => {
|
|
68
|
+
throw new Error(`${spec.name} is only available as a chat.`);
|
|
69
|
+
}),
|
|
70
|
+
detect: spec.detect ?? ((command, env) => detectCli(command, env)),
|
|
71
|
+
createBackend: createAcpBackend(spec)
|
|
72
|
+
};
|
|
73
|
+
}
|
package/src/providers/catalog.ts
CHANGED
|
@@ -41,6 +41,7 @@ export class ModelCatalog {
|
|
|
41
41
|
slug: model.slug,
|
|
42
42
|
name: model.name,
|
|
43
43
|
badge: model.badge,
|
|
44
|
+
...(model.vendor === undefined ? {} : { vendor: model.vendor }),
|
|
44
45
|
legacy: model.legacy === true,
|
|
45
46
|
isDefault: model.slug === this.data.defaultModel,
|
|
46
47
|
options: this.profileOf(model.slug)?.options ?? [],
|
package/src/providers/index.ts
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 { ACP_CAPABILITIES, type AcpProviderSpec, createAcpProvider } from './acp-provider.ts';
|
|
2
3
|
export { AccountError, type AccountLaunches, storedAccount, launchEnv } from './accounts/launch.ts';
|
|
3
4
|
export { ProviderAccountsService, type ProviderAccountsOptions } from './accounts/service.ts';
|
|
4
5
|
export type { ShadowHomeReport } from './accounts/shadow-home.ts';
|
|
@@ -11,7 +11,8 @@ export interface ProviderHome {
|
|
|
11
11
|
// Variables that sign the CLI in on their own, over whatever login the folder holds.
|
|
12
12
|
readonly loginEnv: readonly string[];
|
|
13
13
|
// What a person types in a terminal to sign in; the account's folder comes from the terminal's environment.
|
|
14
|
-
|
|
14
|
+
// Absent on a CLI that signs in from its first chat, which nothing outside that chat can ask about.
|
|
15
|
+
readonly loginCommand?: string;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
/*
|
|
@@ -94,6 +94,11 @@ export class ProviderRegistry {
|
|
|
94
94
|
);
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/* The kinds this host offers, in the order every menu lists them. */
|
|
98
|
+
kinds(): AgentKind[] {
|
|
99
|
+
return this.providers.map((provider) => provider.kind);
|
|
100
|
+
}
|
|
101
|
+
|
|
97
102
|
/* The provider of a kind, one this host does not offer included. */
|
|
98
103
|
get(kind: AgentKind): ChatProvider {
|
|
99
104
|
return this.providers.find((provider) => provider.kind === kind) ?? this.fallback(kind);
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Network-free ACP agent. Prompt commands exercise permissions, cancels, plans, edits and resumes
|
|
3
|
+
* through the JSON-RPC shapes of the Agent Client Protocol, version 1.
|
|
4
|
+
*/
|
|
5
|
+
import type { FakeCli } from './fake-cli.ts';
|
|
6
|
+
|
|
7
|
+
type Frame = Record<string, unknown>;
|
|
8
|
+
|
|
9
|
+
// The prompts of every session, outside any one process: a real agent keeps them on disk, so a later process loads them too.
|
|
10
|
+
const sessionHistory = new Map<string, string[]>();
|
|
11
|
+
|
|
12
|
+
export interface FakeAcpOptions {
|
|
13
|
+
// Advertises `loadSession`; on by default.
|
|
14
|
+
loadSession?: boolean;
|
|
15
|
+
// Advertises `session/resume`, which a client then prefers over a load.
|
|
16
|
+
resumeSession?: boolean;
|
|
17
|
+
// Answers session requests with "authentication required" until `authenticate` names this method.
|
|
18
|
+
authMethod?: string;
|
|
19
|
+
// The modes the session offers, as `session/set_mode` takes them; empty offers none.
|
|
20
|
+
modes?: readonly string[];
|
|
21
|
+
// Reports models the way agents did before config options: `models` and `session/set_model`.
|
|
22
|
+
legacyModels?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
|
|
26
|
+
return (io) => {
|
|
27
|
+
const modes = options.modes ?? ['ask', 'edit', 'yolo'];
|
|
28
|
+
let authenticated = options.authMethod === undefined;
|
|
29
|
+
let sessionId = '';
|
|
30
|
+
let mode = modes[0] ?? '';
|
|
31
|
+
let model = 'fake-small';
|
|
32
|
+
let serverRequestId = 0;
|
|
33
|
+
let callCounter = 0;
|
|
34
|
+
let prompt: { rpcId: number | string; cancelled: boolean } | null = null;
|
|
35
|
+
let permission: { rpcId: number; onAnswer(outcome: Frame): void } | null = null;
|
|
36
|
+
|
|
37
|
+
const send = (frame: Frame): void => {
|
|
38
|
+
io.out({ jsonrpc: '2.0', ...frame });
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const respond = (id: unknown, result: unknown): void => {
|
|
42
|
+
send({ id, result });
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const refuse = (id: unknown, code: number, message: string): void => {
|
|
46
|
+
send({ id, error: { code, message } });
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const update = (fields: Frame): void => {
|
|
50
|
+
send({ method: 'session/update', params: { sessionId, update: fields } });
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const say = (text: string): void => {
|
|
54
|
+
const half = Math.ceil(text.length / 2);
|
|
55
|
+
update({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: text.slice(0, half) } });
|
|
56
|
+
update({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: text.slice(half) } });
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const endTurn = (stopReason: string): void => {
|
|
60
|
+
if (prompt === null) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
update({ sessionUpdate: 'usage_update', used: 1200, size: 200000, cost: { amount: 0.01, currency: 'USD' } });
|
|
64
|
+
respond(prompt.rpcId, { stopReason });
|
|
65
|
+
prompt = null;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const sessionState = (): Frame => {
|
|
69
|
+
const modelChoices = [
|
|
70
|
+
{ value: 'fake-small', name: 'Fake small' },
|
|
71
|
+
{ value: 'fake-large', name: 'Fake large' }
|
|
72
|
+
];
|
|
73
|
+
return {
|
|
74
|
+
...(modes.length === 0 ? {} : { modes: { currentModeId: mode, availableModes: modes.map((id) => ({ id, name: id })) } }),
|
|
75
|
+
...(options.legacyModels
|
|
76
|
+
? { models: { currentModelId: model, availableModels: modelChoices.map((choice) => ({ modelId: choice.value, name: choice.name })) } }
|
|
77
|
+
: { configOptions: [{ id: 'model', name: 'Model', type: 'select', category: 'model', currentValue: model, options: modelChoices }] })
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const askPermission = (toolCall: Frame, onAnswer: (outcome: Frame) => void): void => {
|
|
82
|
+
const rpcId = serverRequestId++;
|
|
83
|
+
permission = { rpcId, onAnswer };
|
|
84
|
+
send({
|
|
85
|
+
id: rpcId,
|
|
86
|
+
method: 'session/request_permission',
|
|
87
|
+
params: {
|
|
88
|
+
sessionId,
|
|
89
|
+
toolCall,
|
|
90
|
+
options: [
|
|
91
|
+
{ optionId: 'once', name: 'Allow once', kind: 'allow_once' },
|
|
92
|
+
{ optionId: 'always', name: 'Always allow', kind: 'allow_always' },
|
|
93
|
+
{ optionId: 'no', name: 'Reject', kind: 'reject_once' }
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const handlePrompt = (id: unknown, params: Frame): void => {
|
|
100
|
+
const blocks = Array.isArray(params.prompt) ? (params.prompt as Frame[]) : [];
|
|
101
|
+
const text = blocks
|
|
102
|
+
.filter((block) => block.type === 'text')
|
|
103
|
+
.map((block) => String(block.text))
|
|
104
|
+
.join('\n');
|
|
105
|
+
const images = blocks.filter((block) => block.type === 'image').length;
|
|
106
|
+
const history = sessionHistory.get(sessionId) ?? [];
|
|
107
|
+
sessionHistory.set(sessionId, [...history, text]);
|
|
108
|
+
prompt = { rpcId: id as number | string, cancelled: false };
|
|
109
|
+
const last = text.split('\n\n').at(-1) ?? text;
|
|
110
|
+
if (last === 'history?') {
|
|
111
|
+
say(`prompts before: ${history.length}`);
|
|
112
|
+
endTurn('end_turn');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (last === 'mode?') {
|
|
116
|
+
say(`mode: ${mode}, model: ${model}`);
|
|
117
|
+
endTurn('end_turn');
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (last === 'crash') {
|
|
121
|
+
io.exit(1);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (last === 'slow') {
|
|
125
|
+
say('working');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (last === 'plan') {
|
|
129
|
+
update({
|
|
130
|
+
sessionUpdate: 'plan',
|
|
131
|
+
entries: [
|
|
132
|
+
{ content: 'Read the code', priority: 'high', status: 'completed' },
|
|
133
|
+
{ content: 'Write the fix', priority: 'medium', status: 'in_progress' }
|
|
134
|
+
]
|
|
135
|
+
});
|
|
136
|
+
endTurn('end_turn');
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (last === 'refuse' || last === 'max tokens') {
|
|
140
|
+
say('no');
|
|
141
|
+
endTurn(last === 'refuse' ? 'refusal' : 'max_tokens');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (last.startsWith('edit:')) {
|
|
145
|
+
const path = last.slice(5).trim();
|
|
146
|
+
const toolCallId = `call-${++callCounter}`;
|
|
147
|
+
const content = [{ type: 'diff', path, oldText: 'one\ntwo\n', newText: 'one\nthree\n' }];
|
|
148
|
+
update({ sessionUpdate: 'tool_call', toolCallId, title: `Edit ${path}`, kind: 'edit', status: 'pending', locations: [{ path }] });
|
|
149
|
+
askPermission({ toolCallId, title: `Edit ${path}`, kind: 'edit', content }, (outcome) => {
|
|
150
|
+
const allowed = outcome.outcome === 'selected' && outcome.optionId !== 'no';
|
|
151
|
+
update({ sessionUpdate: 'tool_call_update', toolCallId, status: allowed ? 'completed' : 'failed', content });
|
|
152
|
+
endTurn(outcome.outcome === 'cancelled' ? 'cancelled' : 'end_turn');
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (last.startsWith('tool:')) {
|
|
157
|
+
const command = last.slice(5).trim();
|
|
158
|
+
const toolCallId = `call-${++callCounter}`;
|
|
159
|
+
update({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'I should run it.' } });
|
|
160
|
+
update({ sessionUpdate: 'tool_call', toolCallId, title: `Run ${command}`, kind: 'execute', status: 'pending', rawInput: { command } });
|
|
161
|
+
askPermission({ toolCallId }, (outcome) => {
|
|
162
|
+
if (outcome.outcome === 'cancelled') {
|
|
163
|
+
update({ sessionUpdate: 'tool_call_update', toolCallId, status: 'failed' });
|
|
164
|
+
endTurn('cancelled');
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (outcome.optionId === 'no') {
|
|
168
|
+
update({
|
|
169
|
+
sessionUpdate: 'tool_call_update',
|
|
170
|
+
toolCallId,
|
|
171
|
+
status: 'failed',
|
|
172
|
+
content: [{ type: 'content', content: { type: 'text', text: 'rejected' } }]
|
|
173
|
+
});
|
|
174
|
+
say('not run');
|
|
175
|
+
endTurn('end_turn');
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
update({ sessionUpdate: 'tool_call_update', toolCallId, status: 'in_progress' });
|
|
179
|
+
update({
|
|
180
|
+
sessionUpdate: 'tool_call_update',
|
|
181
|
+
toolCallId,
|
|
182
|
+
status: 'completed',
|
|
183
|
+
content: [{ type: 'content', content: { type: 'text', text: `ran ${command} (${String(outcome.optionId)})` } }]
|
|
184
|
+
});
|
|
185
|
+
say('done');
|
|
186
|
+
endTurn('end_turn');
|
|
187
|
+
});
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
update({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'Thinking.' } });
|
|
191
|
+
say(`echo: ${text}${images > 0 ? ` (${images} images)` : ''}`);
|
|
192
|
+
endTurn('end_turn');
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const handle = (frame: Frame): void => {
|
|
196
|
+
const id = frame.id;
|
|
197
|
+
const params = (typeof frame.params === 'object' && frame.params !== null ? frame.params : {}) as Frame;
|
|
198
|
+
if (frame.method === undefined) {
|
|
199
|
+
// The client's answer to a permission request.
|
|
200
|
+
if (permission !== null && id === permission.rpcId) {
|
|
201
|
+
const answered = permission;
|
|
202
|
+
permission = null;
|
|
203
|
+
const result = frame.result as { outcome?: Frame } | undefined;
|
|
204
|
+
answered.onAnswer(result?.outcome ?? { outcome: 'cancelled' });
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
switch (frame.method) {
|
|
209
|
+
case 'initialize':
|
|
210
|
+
respond(id, {
|
|
211
|
+
protocolVersion: 1,
|
|
212
|
+
agentCapabilities: {
|
|
213
|
+
loadSession: options.loadSession ?? true,
|
|
214
|
+
promptCapabilities: { image: true, audio: false, embeddedContext: false },
|
|
215
|
+
sessionCapabilities: options.resumeSession ? { resume: {} } : {}
|
|
216
|
+
},
|
|
217
|
+
authMethods: options.authMethod === undefined ? [] : [{ id: options.authMethod, name: 'Sign in' }],
|
|
218
|
+
agentInfo: { name: 'fake-acp', title: 'Fake ACP', version: '0.0.1' }
|
|
219
|
+
});
|
|
220
|
+
return;
|
|
221
|
+
case 'authenticate':
|
|
222
|
+
authenticated = params.methodId === options.authMethod;
|
|
223
|
+
respond(id, {});
|
|
224
|
+
return;
|
|
225
|
+
case 'session/new':
|
|
226
|
+
if (!authenticated) {
|
|
227
|
+
refuse(id, -32000, 'Authentication required');
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
sessionId = `fake-session-${Math.random().toString(36).slice(2, 8)}`;
|
|
231
|
+
sessionHistory.set(sessionId, []);
|
|
232
|
+
respond(id, { sessionId, ...sessionState() });
|
|
233
|
+
return;
|
|
234
|
+
case 'session/load':
|
|
235
|
+
case 'session/resume': {
|
|
236
|
+
const history = sessionHistory.get(String(params.sessionId));
|
|
237
|
+
if (!authenticated) {
|
|
238
|
+
refuse(id, -32000, 'Authentication required');
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (history === undefined) {
|
|
242
|
+
refuse(id, -32002, 'Session not found');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
sessionId = String(params.sessionId);
|
|
246
|
+
if (frame.method === 'session/load') {
|
|
247
|
+
for (const earlier of history) {
|
|
248
|
+
update({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: earlier } });
|
|
249
|
+
update({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'replayed' } });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
respond(id, sessionState());
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
case 'session/set_mode':
|
|
256
|
+
mode = String(params.modeId);
|
|
257
|
+
respond(id, {});
|
|
258
|
+
update({ sessionUpdate: 'current_mode_update', currentModeId: mode });
|
|
259
|
+
return;
|
|
260
|
+
case 'session/set_model':
|
|
261
|
+
model = String(params.modelId);
|
|
262
|
+
respond(id, {});
|
|
263
|
+
return;
|
|
264
|
+
case 'session/set_config_option':
|
|
265
|
+
if (params.configId === 'model') {
|
|
266
|
+
model = String(params.value);
|
|
267
|
+
}
|
|
268
|
+
respond(id, { configOptions: sessionState().configOptions ?? [] });
|
|
269
|
+
return;
|
|
270
|
+
case 'session/prompt':
|
|
271
|
+
handlePrompt(id, params);
|
|
272
|
+
return;
|
|
273
|
+
case 'session/cancel':
|
|
274
|
+
if (prompt !== null) {
|
|
275
|
+
prompt.cancelled = true;
|
|
276
|
+
if (permission === null) {
|
|
277
|
+
endTurn('cancelled');
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return;
|
|
281
|
+
default:
|
|
282
|
+
if (id !== undefined) {
|
|
283
|
+
refuse(id, -32601, `Method not found: ${String(frame.method)}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
onLine: (line) => {
|
|
290
|
+
handle(JSON.parse(line) as Frame);
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export const fakeAcp: FakeCli = fakeAcpWith({});
|
package/src/testing/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Public API: exports.test.ts snapshots this list, so a name added or dropped here is an API change.
|
|
2
2
|
export { type FakeIo, type FakeProgram, type FakeCli, type StartedFake, type InProcessCli, inProcess, runOverStdio } from './fake-cli.ts';
|
|
3
|
+
export { type FakeAcpOptions, fakeAcpWith, fakeAcp } from './fake-acp.ts';
|
|
3
4
|
export { fakeClaude, claudeStoppingOnResume } from './fake-claude.ts';
|
|
4
5
|
export { FAKE_CHILD_STEPS, fakeCodexForks, type FakeCodexOptions, fakeCodexWith, fakeCodex } from './fake-codex.ts';
|
|
5
6
|
export { testAccounts } from './accounts.ts';
|
package/src/usage/index-file.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { UsageProviderSchema, type UsageProvider } from '@adecore/agent-contracts';
|
|
2
2
|
import type { UsageRecord } from './record.ts';
|
|
3
3
|
import type { CodexParserState } from './readers/codex.ts';
|
|
4
4
|
|
|
@@ -147,7 +147,8 @@ export function decodeIndex(text: string): UsageIndex {
|
|
|
147
147
|
const accounts = Array.isArray(document.accounts) ? document.accounts : [];
|
|
148
148
|
const name = (table: string[], at: number): string | null => (typeof table[at] === 'string' ? table[at] : null);
|
|
149
149
|
for (const [path, file] of Object.entries(document.files ?? {})) {
|
|
150
|
-
|
|
150
|
+
// Every provider the schema knows, so a new one is never dropped from the index it was saved in.
|
|
151
|
+
if (!UsageProviderSchema.safeParse(file.p).success) {
|
|
151
152
|
continue;
|
|
152
153
|
}
|
|
153
154
|
if (typeof file.s !== 'number' || typeof file.m !== 'number' || typeof file.o !== 'number' || file.o < 0 || !Array.isArray(file.r)) {
|
package/src/usage/index.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// Public API: exports.test.ts snapshots this list, so a name added or dropped here is an API change.
|
|
2
2
|
export { usageAccountsOf, usageRootsOf, limitAccountsOf, chatSessionAccounts } from './accounts.ts';
|
|
3
3
|
export { UsageMonitor, type ProbeEnv, type LimitAccount, type LimitAccounts, type UsageMonitorOptions } from './limits/monitor.ts';
|
|
4
|
-
export type
|
|
4
|
+
export { fraction, type LimitsUpdate, type ProviderReading } from './limits/normalize.ts';
|
|
5
5
|
export type { ProbeResult } from './limits/probe.ts';
|
|
6
6
|
export { LITELLM_URL } from './pricing.ts';
|
|
7
|
+
export { hostUsageLine, type HostUsageEntry } from './readers/host-log.ts';
|
|
7
8
|
export type { KnownProject } from './projects.ts';
|
|
8
9
|
export { type UsageRootPath, usageRoots } from './roots.ts';
|
|
9
10
|
export type { UsageScannerOptions } from './scanner.ts';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { USAGE_PROVIDERS, type UsageLimitsProvider, type UsageLimitsSnapshot, type UsageProvider } from '@adecore/agent-contracts';
|
|
2
2
|
import type { CodexClientInfo } from '../../chat/codex-transport.ts';
|
|
3
3
|
import { ClientSinks } from '../../client-sinks.ts';
|
|
4
4
|
import { errorText } from '../../error-text.ts';
|
|
@@ -14,7 +14,7 @@ const MAX_BACKOFF_MS = 60 * 60_000;
|
|
|
14
14
|
/* An account other than the default one that nobody started a CLI under for this long is only read when a person asks. */
|
|
15
15
|
const RECENT_USE_MS = 24 * 60 * 60_000;
|
|
16
16
|
|
|
17
|
-
const PROVIDERS: readonly UsageProvider[] =
|
|
17
|
+
const PROVIDERS: readonly UsageProvider[] = USAGE_PROVIDERS;
|
|
18
18
|
|
|
19
19
|
export type ProbeEnv = Record<string, string>;
|
|
20
20
|
|
|
@@ -62,6 +62,12 @@ export interface UsageMonitorOptions {
|
|
|
62
62
|
accounts?: LimitAccounts;
|
|
63
63
|
/* How to ask; a test answers without starting a CLI. */
|
|
64
64
|
probe?: (kind: UsageProvider, command: readonly string[], env: ProbeEnv) => Promise<ProbeResult>;
|
|
65
|
+
/*
|
|
66
|
+
* A plan the host reads itself, for a provider with no CLI to ask (a subscription API the host calls with
|
|
67
|
+
* its own key). It answers in place of the probe, with no environment and no check that a CLI is installed:
|
|
68
|
+
* what it says is the whole story, a missing key included.
|
|
69
|
+
*/
|
|
70
|
+
plans?: Partial<Record<UsageProvider, () => Promise<ProbeResult>>>;
|
|
65
71
|
now?: () => number;
|
|
66
72
|
// How the host names itself to the app-server a probe opens.
|
|
67
73
|
client?: CodexClientInfo;
|
|
@@ -71,12 +77,14 @@ export interface UsageMonitorOptions {
|
|
|
71
77
|
* What is left of each plan, per account. Two sources feed it: a read of our own every five minutes,
|
|
72
78
|
* which starts the CLI, asks and ends it, and the events a running turn streams in between, which
|
|
73
79
|
* cost nothing and arrive the moment a number moves. Neither reads a credential: both CLIs already
|
|
74
|
-
* hold their own login and answer the question themselves.
|
|
80
|
+
* hold their own login and answer the question themselves. A provider without a CLI is read by the
|
|
81
|
+
* host that holds its key (`plans`), on the same clock.
|
|
75
82
|
*/
|
|
76
83
|
export class UsageMonitor {
|
|
77
84
|
private readonly registry: ProviderRegistry;
|
|
78
85
|
private readonly accounts: LimitAccounts | null;
|
|
79
86
|
private readonly probe: NonNullable<UsageMonitorOptions['probe']>;
|
|
87
|
+
private readonly plans: NonNullable<UsageMonitorOptions['plans']>;
|
|
80
88
|
private readonly now: () => number;
|
|
81
89
|
private readonly sinks = new ClientSinks<AgentEvent>();
|
|
82
90
|
// By account id, so the default account of a CLI is under the CLI's kind.
|
|
@@ -87,6 +95,7 @@ export class UsageMonitor {
|
|
|
87
95
|
constructor(options: UsageMonitorOptions) {
|
|
88
96
|
this.registry = options.providers;
|
|
89
97
|
this.accounts = options.accounts ?? null;
|
|
98
|
+
this.plans = options.plans ?? {};
|
|
90
99
|
this.probe = options.probe ?? ((kind, command, env) => (kind === 'claude' ? probeClaude(command, env) : probeCodex(command, env, options.client)));
|
|
91
100
|
this.now = options.now ?? Date.now;
|
|
92
101
|
for (const kind of PROVIDERS) {
|
|
@@ -197,16 +206,18 @@ export class UsageMonitor {
|
|
|
197
206
|
continue;
|
|
198
207
|
}
|
|
199
208
|
const info = installed.find((provider) => provider.kind === kind);
|
|
200
|
-
if (info === undefined || !info.installed) {
|
|
209
|
+
if (this.plans[kind] === undefined && (info === undefined || !info.installed)) {
|
|
201
210
|
state.published = { ...blank(kind), checkedAt: this.now(), unavailable: { reason: 'not-installed', message: null } };
|
|
202
211
|
state.nextAt = this.now() + PROBE_INTERVAL_MS;
|
|
203
212
|
changed = true;
|
|
204
213
|
continue;
|
|
205
214
|
}
|
|
215
|
+
// The next read is counted from when this one started, so a read that takes a few seconds does not push the tick of the clock past its time.
|
|
216
|
+
const startedAt = this.now();
|
|
206
217
|
const result = await this.read(account);
|
|
207
218
|
const failed = 'unavailable' in result && result.unavailable?.reason === 'failed';
|
|
208
219
|
state.backoffMs = failed ? Math.min(MAX_BACKOFF_MS, state.backoffMs * 2) : PROBE_INTERVAL_MS;
|
|
209
|
-
state.nextAt =
|
|
220
|
+
state.nextAt = startedAt + state.backoffMs;
|
|
210
221
|
if (failed && state.published.windows.length > 0) {
|
|
211
222
|
// A pass that could not reach the CLI leaves the last good numbers where they are.
|
|
212
223
|
continue;
|
|
@@ -223,6 +234,14 @@ export class UsageMonitor {
|
|
|
223
234
|
}
|
|
224
235
|
|
|
225
236
|
private async read(account: LimitAccount): Promise<ProbeResult> {
|
|
237
|
+
const plan = this.plans[account.kind];
|
|
238
|
+
if (plan !== undefined) {
|
|
239
|
+
try {
|
|
240
|
+
return await plan();
|
|
241
|
+
} catch (e) {
|
|
242
|
+
return { unavailable: { reason: 'failed', message: errorText(e) } };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
226
245
|
let env: ProbeEnv;
|
|
227
246
|
try {
|
|
228
247
|
env = this.accounts === null ? definedEnv(process.env) : this.accounts.envFor(account.kind, account.id);
|
|
@@ -26,7 +26,8 @@ function num(value: unknown): number | null {
|
|
|
26
26
|
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
/* A share of a plan used, held to the bar: a provider may report more than all of it. */
|
|
30
|
+
export function fraction(value: number): number {
|
|
30
31
|
return Math.min(1, Math.max(0, value));
|
|
31
32
|
}
|
|
32
33
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { UsageProvider } from '@adecore/agent-contracts';
|
|
2
|
+
import { asObject, asString, int, type UsageRecord } from '../record.ts';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* One call to an API a host makes itself, a line of the usage log it keeps. Such an API has no CLI and so
|
|
6
|
+
* no transcript, so the host writes this line for each call and the scanner reads the folder like one.
|
|
7
|
+
* The kinds are disjoint, as they are in a record: `input` is what was neither read from nor written to
|
|
8
|
+
* a cache, and `reasoning` is a part of `output`. Each host maps the counts of its own API onto them, since
|
|
9
|
+
* the APIs do not agree: some report a prompt that holds its cached part, some report the input apart from both
|
|
10
|
+
* cache kinds, and only some have a cache write at all.
|
|
11
|
+
*/
|
|
12
|
+
export interface HostUsageEntry {
|
|
13
|
+
at: number;
|
|
14
|
+
model: string;
|
|
15
|
+
session: string;
|
|
16
|
+
cwd: string;
|
|
17
|
+
input: number;
|
|
18
|
+
cacheRead: number;
|
|
19
|
+
cacheWrite: number;
|
|
20
|
+
output: number;
|
|
21
|
+
reasoning: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function hostUsageLine(entry: HostUsageEntry): string {
|
|
25
|
+
return `${JSON.stringify(entry)}\n`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* A line of this log names its model, which keeps `JSON.parse` off anything else a folder holds. */
|
|
29
|
+
export function hostLogMightCarryUsage(line: string): boolean {
|
|
30
|
+
return line.includes('"model"');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* The provider is the root's, so one reader serves every API a host logs. */
|
|
34
|
+
export function parseHostUsageLine(line: string, provider: UsageProvider): UsageRecord | null {
|
|
35
|
+
let parsed: unknown;
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(line);
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const entry = asObject(parsed);
|
|
42
|
+
if (entry === null) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const model = asString(entry.model);
|
|
46
|
+
const timestampMs = typeof entry.at === 'number' && Number.isFinite(entry.at) ? entry.at : Number.NaN;
|
|
47
|
+
if (model === '' || Number.isNaN(timestampMs)) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const totals = {
|
|
51
|
+
calls: 1,
|
|
52
|
+
input: int(entry.input),
|
|
53
|
+
cacheRead: int(entry.cacheRead),
|
|
54
|
+
cacheWrite: int(entry.cacheWrite),
|
|
55
|
+
cacheWrite1h: 0,
|
|
56
|
+
output: int(entry.output),
|
|
57
|
+
reasoning: int(entry.reasoning)
|
|
58
|
+
};
|
|
59
|
+
if (totals.input + totals.cacheRead + totals.cacheWrite + totals.output === 0) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
provider,
|
|
64
|
+
timestampMs,
|
|
65
|
+
model,
|
|
66
|
+
sessionId: asString(entry.session),
|
|
67
|
+
cwd: asString(entry.cwd),
|
|
68
|
+
totals,
|
|
69
|
+
// Every line is a call of its own, so nothing is folded.
|
|
70
|
+
dedupeKey: null
|
|
71
|
+
};
|
|
72
|
+
}
|
package/src/usage/roots.ts
CHANGED
|
@@ -9,8 +9,8 @@ export interface UsageRootPath {
|
|
|
9
9
|
accounts?: string[];
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
/* Where in its config folder each CLI leaves its transcripts. */
|
|
13
|
-
const TRANSCRIPTS: Record<UsageProvider, string> = { claude: 'projects', codex: 'sessions' };
|
|
12
|
+
/* Where in its config folder each CLI leaves its transcripts. A provider with no CLI has a log its host passes as a root of its own. */
|
|
13
|
+
const TRANSCRIPTS: Partial<Record<UsageProvider, string>> & Record<'claude' | 'codex', string> = { claude: 'projects', codex: 'sessions' };
|
|
14
14
|
|
|
15
15
|
/* The transcripts of the default accounts, found through the variables the CLIs themselves read. */
|
|
16
16
|
export function usageRoots(env: Record<string, string | undefined> = process.env): UsageRootPath[] {
|
|
@@ -25,7 +25,11 @@ export function usageRoots(env: Record<string, string | undefined> = process.env
|
|
|
25
25
|
export function accountRoots(folders: ReadonlyArray<{ id: string; kind: UsageProvider; folder: string }>): UsageRootPath[] {
|
|
26
26
|
const roots = new Map<string, UsageRootPath & { accounts: string[] }>();
|
|
27
27
|
for (const { id, kind, folder } of folders) {
|
|
28
|
-
const
|
|
28
|
+
const transcripts = TRANSCRIPTS[kind];
|
|
29
|
+
if (transcripts === undefined) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const path = join(folder, transcripts);
|
|
29
33
|
const key = `${kind}\0${path}`;
|
|
30
34
|
const root = roots.get(key);
|
|
31
35
|
if (root === undefined) {
|