@aragon-agent/cli 0.6.24 → 0.6.26
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/CHANGELOG.md +1924 -1786
- package/README.md +2730 -2562
- package/dist/agent/controller.d.ts +49 -3
- package/dist/agent/controller.js +78 -3
- package/dist/agent/conversation-reset.d.ts +91 -0
- package/dist/agent/conversation-reset.js +66 -0
- package/dist/agent/model-probe-limits.d.ts +39 -0
- package/dist/agent/model-probe-limits.js +25 -0
- package/dist/agent/model-probe.d.ts +104 -0
- package/dist/agent/model-probe.js +391 -0
- package/dist/agent/reducer.d.ts +22 -0
- package/dist/agent/reducer.js +49 -14
- package/dist/cli.js +214 -2
- package/dist/commands/branch-command.d.ts +61 -0
- package/dist/commands/branch-command.js +117 -0
- package/dist/commands/builtins.js +62 -40
- package/dist/commands/clear-command.d.ts +27 -0
- package/dist/commands/clear-command.js +107 -0
- package/dist/commands/registry.d.ts +48 -1
- package/dist/commands/session-command.d.ts +54 -0
- package/dist/commands/session-command.js +109 -0
- package/dist/config/load.d.ts +15 -0
- package/dist/config/model-profiles.d.ts +8 -0
- package/dist/config/model-profiles.js +10 -0
- package/dist/exec/index.js +10 -1
- package/dist/logging/logger.d.ts +1 -1
- package/dist/proc/terminal-plan.d.ts +155 -0
- package/dist/proc/terminal-plan.js +360 -0
- package/dist/session/branch-invocation.d.ts +83 -0
- package/dist/session/branch-invocation.js +109 -0
- package/dist/session/branch-launch.d.ts +70 -0
- package/dist/session/branch-launch.js +248 -0
- package/dist/session/checkpoint.d.ts +257 -0
- package/dist/session/checkpoint.js +512 -0
- package/dist/session/clear-archive.d.ts +51 -0
- package/dist/session/clear-archive.js +160 -0
- package/dist/session/cli-commands.d.ts +2 -0
- package/dist/session/cli-commands.js +36 -5
- package/dist/session/identity.d.ts +72 -0
- package/dist/session/identity.js +148 -0
- package/dist/session/limits.d.ts +66 -0
- package/dist/session/limits.js +66 -0
- package/dist/session/persist.d.ts +30 -5
- package/dist/session/startup-session.d.ts +107 -0
- package/dist/session/startup-session.js +315 -0
- package/dist/session/store.d.ts +60 -1
- package/dist/session/store.js +95 -6
- package/dist/todo/types.d.ts +5 -3
- package/dist/ui/App.d.ts +54 -2
- package/dist/ui/App.js +154 -28
- package/dist/ui/Header.js +8 -16
- package/dist/ui/StatusBar.d.ts +8 -0
- package/dist/ui/StatusBar.js +12 -3
- package/dist/ui/entries/CompactionCard.d.ts +5 -4
- package/dist/ui/entries/CompactionCard.js +5 -4
- package/dist/ui/layout/header-hints.d.ts +68 -0
- package/dist/ui/layout/header-hints.js +138 -0
- package/dist/ui/layout/session-chip.d.ts +52 -0
- package/dist/ui/layout/session-chip.js +64 -0
- package/dist/ui/layout/status-layout.d.ts +10 -0
- package/dist/ui/layout/status-layout.js +8 -1
- package/dist/ui/overlays/HelpOverlay.js +17 -5
- package/dist/ui/overlays/ModelDiscoveryList.d.ts +23 -0
- package/dist/ui/overlays/ModelDiscoveryList.js +161 -0
- package/dist/ui/overlays/ModelProfileEditor.d.ts +18 -1
- package/dist/ui/overlays/ModelProfileEditor.js +117 -68
- package/dist/ui/overlays/SettingsScreen.d.ts +8 -0
- package/dist/ui/overlays/SettingsScreen.js +34 -2
- package/dist/ui/overlays/model-probe-view.d.ts +128 -0
- package/dist/ui/overlays/model-probe-view.js +225 -0
- package/dist/ui/overlays/model-profile-state.d.ts +9 -1
- package/dist/ui/overlays/model-profile-state.js +10 -0
- package/dist/ui/overlays/use-model-probe.d.ts +30 -0
- package/dist/ui/overlays/use-model-probe.js +109 -0
- package/dist/ui/session-bridge.d.ts +26 -0
- package/dist/ui/session-bridge.js +32 -0
- package/package.json +2 -2
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The connection probe behind `Test connection` and `Discover models`
|
|
3
|
+
* (model-profile-connection-test §4.2-§4.8).
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS IS NOT `ModelRegistry.discoverModels()`: that path folds every exception
|
|
6
|
+
* into `[]` and stores the result in a five-minute process-wide cache. Using it here
|
|
7
|
+
* would (a) collapse "wrong key", "wrong URL" and "this account owns no models" into
|
|
8
|
+
* one sentence, and (b) let a probe of an UNSAVED draft endpoint poison `/model`,
|
|
9
|
+
* `/context` and the main session's window resolution for five minutes. So the probe
|
|
10
|
+
* talks to the adapter directly and classifies by `LLMError.errorType`.
|
|
11
|
+
*
|
|
12
|
+
* WHY `complete()` AND NOT ONLY `listModels()`: a model can be listed and still be
|
|
13
|
+
* unable to generate (gateway aliases, permission layers). One real round trip is the
|
|
14
|
+
* only honest answer to "does this work right now", and it is also the only evidence
|
|
15
|
+
* that can produce `model_not_found`.
|
|
16
|
+
*
|
|
17
|
+
* ASCII ONLY: `agent/` is inside `glyphs.test.ts::inScope`.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import { LLMError, MIN_MAX_OUTPUT_TOKENS, classifyOutputLimitFailure, } from '@aragon-agent/core';
|
|
21
|
+
import { isValidEndpointUrl } from '../config/model-profiles.js';
|
|
22
|
+
import { getLogger } from '../logging/logger.js';
|
|
23
|
+
import { PROBE_LIMITS } from './model-probe-limits.js';
|
|
24
|
+
/** `Reply with the single word OK.` - one isolated call, no tools, no system prompt. */
|
|
25
|
+
const PROBE_PROMPT = 'Reply with the single word OK.';
|
|
26
|
+
const UNKNOWN_DETAIL = 'The provider failed in an unrecognized way.';
|
|
27
|
+
const NO_KEY_DETAIL_SUFFIX = ' Set one here or in the environment.';
|
|
28
|
+
/**
|
|
29
|
+
* Which key this draft would actually use (§4.2.1).
|
|
30
|
+
*
|
|
31
|
+
* The editor's `apiKey` field is ALWAYS the empty string: it is the replacement
|
|
32
|
+
* input, not the live credential. `finishProfileEditor` resolves `keep` to the
|
|
33
|
+
* saved entry key and `shared` to `null`, and the runtime then falls back to the
|
|
34
|
+
* shared per-provider key. Without this table the most common path - editing an
|
|
35
|
+
* existing profile, whose credential mode defaults to `keep` - would report
|
|
36
|
+
* `(no key)` for a profile that has one.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveDraftKey(input) {
|
|
39
|
+
const nonEmpty = (value) => value && value.trim().length > 0 ? value.trim() : null;
|
|
40
|
+
if (input.credentialMode === 'replace')
|
|
41
|
+
return nonEmpty(input.draftKey);
|
|
42
|
+
if (input.credentialMode === 'keep') {
|
|
43
|
+
return nonEmpty(input.existingKey) ?? nonEmpty(input.sharedKey);
|
|
44
|
+
}
|
|
45
|
+
return nonEmpty(input.sharedKey);
|
|
46
|
+
}
|
|
47
|
+
/** Trailing slashes are not part of an endpoint's identity. */
|
|
48
|
+
function normalizeBaseUrl(baseUrl) {
|
|
49
|
+
const trimmed = (baseUrl ?? '').trim();
|
|
50
|
+
if (trimmed.length === 0)
|
|
51
|
+
return null;
|
|
52
|
+
return trimmed.replace(/\/+$/, '');
|
|
53
|
+
}
|
|
54
|
+
function keyDigest(apiKey) {
|
|
55
|
+
const trimmed = (apiKey ?? '').trim();
|
|
56
|
+
if (trimmed.length === 0)
|
|
57
|
+
return 'none';
|
|
58
|
+
return createHash('sha256').update(trimmed).digest('hex').slice(0, 12);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The host a failure sentence names - same four clauses as
|
|
62
|
+
* `profileEndpointSummary()` in `ui/overlays/ModelProfilePicker.tsx`, which this
|
|
63
|
+
* layer may not import (agent -> ui would invert the layering). `model-probe.test.ts`
|
|
64
|
+
* asserts the two agree, so the duplication cannot drift silently.
|
|
65
|
+
*/
|
|
66
|
+
export function endpointHost(baseUrl) {
|
|
67
|
+
if (!baseUrl || baseUrl.trim().length === 0)
|
|
68
|
+
return 'the provider';
|
|
69
|
+
try {
|
|
70
|
+
const url = new URL(baseUrl.trim());
|
|
71
|
+
return url.username || url.password || url.search || url.hash ? 'this endpoint'
|
|
72
|
+
: url.host || 'the provider';
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return 'this endpoint';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function fingerprint(provider, baseUrl, apiKey, model) {
|
|
79
|
+
const connection = `${provider}|${normalizeBaseUrl(baseUrl) ?? ''}|${keyDigest(apiKey)}`;
|
|
80
|
+
return model === undefined ? connection : `${connection}|${model.trim()}`;
|
|
81
|
+
}
|
|
82
|
+
/** Absolute sentence so a redacted, truncated message can never show a key. */
|
|
83
|
+
function redactDetail(text, secrets) {
|
|
84
|
+
let safe = text;
|
|
85
|
+
for (const secret of secrets) {
|
|
86
|
+
if (secret.length > 0)
|
|
87
|
+
safe = safe.split(secret).join('[redacted]');
|
|
88
|
+
}
|
|
89
|
+
return safe.replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, PROBE_LIMITS.detailChars);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* One timer, two recorded facts. `cancelled` and `timeout` must be distinguishable
|
|
93
|
+
* AFTER the fact, because an aborted adapter may resolve quietly or throw something
|
|
94
|
+
* that looks like a network error (§4.3 step 6).
|
|
95
|
+
*/
|
|
96
|
+
function createDeadline(timeoutMs, parent) {
|
|
97
|
+
const controller = new AbortController();
|
|
98
|
+
let fired = false;
|
|
99
|
+
const onParentAbort = () => controller.abort();
|
|
100
|
+
if (parent) {
|
|
101
|
+
if (parent.aborted)
|
|
102
|
+
controller.abort();
|
|
103
|
+
else
|
|
104
|
+
parent.addEventListener('abort', onParentAbort, { once: true });
|
|
105
|
+
}
|
|
106
|
+
const handle = setTimeout(() => { fired = true; controller.abort(); }, timeoutMs);
|
|
107
|
+
unrefTimer(handle);
|
|
108
|
+
return {
|
|
109
|
+
signal: controller.signal,
|
|
110
|
+
isFired: () => fired,
|
|
111
|
+
isCancelled: () => parent?.aborted === true,
|
|
112
|
+
dispose: () => {
|
|
113
|
+
clearTimeout(handle);
|
|
114
|
+
parent?.removeEventListener('abort', onParentAbort);
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Run the best-effort model list under a cap. It never rejects and never vetoes.
|
|
120
|
+
*
|
|
121
|
+
* The cap timer is `unref`'d exactly like the deadline (§4.8): after a fast verdict the
|
|
122
|
+
* list is still in flight, and a referenced five-second timer would keep the process
|
|
123
|
+
* alive on exit for a number nobody is going to read.
|
|
124
|
+
*
|
|
125
|
+
* Taking a THUNK rather than a promise is what stops a synchronous throw (§4.3 step 3):
|
|
126
|
+
* an adapter that fails before its first `await` - a test double, or a guard clause -
|
|
127
|
+
* would otherwise escape past this layer and cancel a completion not yet attempted.
|
|
128
|
+
*/
|
|
129
|
+
function withCap(call, ms) {
|
|
130
|
+
let pending;
|
|
131
|
+
try {
|
|
132
|
+
pending = call();
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return Promise.resolve(undefined);
|
|
136
|
+
}
|
|
137
|
+
return new Promise((resolve) => {
|
|
138
|
+
const handle = setTimeout(() => resolve(undefined), ms);
|
|
139
|
+
unrefTimer(handle);
|
|
140
|
+
const settle = (value) => { clearTimeout(handle); resolve(value); };
|
|
141
|
+
pending.then((value) => settle(value), () => settle(undefined));
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/** Timers and immediates both expose `unref<Timer>`; neither may outlive the probe. */
|
|
145
|
+
function unrefTimer(handle) {
|
|
146
|
+
const unrefable = handle;
|
|
147
|
+
unrefable.unref?.();
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The list AS IT STANDS when the verdict is reached.
|
|
151
|
+
*
|
|
152
|
+
* One macrotask of grace, so a list that resolved while the completion was in flight
|
|
153
|
+
* publishes itself before we judge - and a list still in flight is NOT waited for,
|
|
154
|
+
* because it has no veto over the verdict (§4.3 step 3 / RV-P1-1).
|
|
155
|
+
*/
|
|
156
|
+
function listAtVerdict(listing) {
|
|
157
|
+
const grant = new Promise((resolve) => {
|
|
158
|
+
unrefTimer(setImmediate(() => resolve(undefined)));
|
|
159
|
+
});
|
|
160
|
+
return Promise.race([listing, grant]);
|
|
161
|
+
}
|
|
162
|
+
function guardFailure(provider, request, targetId) {
|
|
163
|
+
const base = { ok: false, targetId, latencyMs: 0 };
|
|
164
|
+
if (!provider) {
|
|
165
|
+
return { ...base, kind: 'unsupported_provider',
|
|
166
|
+
detail: `This build has no adapter for ${request.provider}.` };
|
|
167
|
+
}
|
|
168
|
+
if (!request.apiKey || request.apiKey.trim().length === 0) {
|
|
169
|
+
return { ...base, kind: 'missing_key',
|
|
170
|
+
detail: `No API key for ${request.provider}.${NO_KEY_DETAIL_SUFFIX}` };
|
|
171
|
+
}
|
|
172
|
+
if (request.baseUrl && !isValidEndpointUrl(request.baseUrl)) {
|
|
173
|
+
return { ...base, kind: 'invalid_base_url',
|
|
174
|
+
detail: 'Base URL must be an http(s) URL without credentials, query or fragment.' };
|
|
175
|
+
}
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
function mentionsModel(err) {
|
|
179
|
+
return /model/i.test(err.message);
|
|
180
|
+
}
|
|
181
|
+
/** The ONE classification matrix (§4.4). Signal facts are checked by the caller. */
|
|
182
|
+
function classifyError(err, facts) {
|
|
183
|
+
if (!(err instanceof LLMError))
|
|
184
|
+
return { kind: 'unknown', detail: UNKNOWN_DETAIL };
|
|
185
|
+
const status = typeof err.statusCode === 'number' ? err.statusCode : undefined;
|
|
186
|
+
const suffix = status === undefined ? '' : ` (HTTP ${status})`;
|
|
187
|
+
switch (err.errorType) {
|
|
188
|
+
case 'auth_error':
|
|
189
|
+
return { kind: 'auth', detail: `Key rejected by ${facts.provider}${suffix}.` };
|
|
190
|
+
case 'invalid_request':
|
|
191
|
+
case 'context_overflow':
|
|
192
|
+
return mentionsModel(err)
|
|
193
|
+
? { kind: 'model_not_found',
|
|
194
|
+
detail: `The provider did not accept model "${facts.model}"${suffix}.` }
|
|
195
|
+
: { kind: 'invalid_request', detail: `The provider rejected the request${suffix}.` };
|
|
196
|
+
case 'rate_limit':
|
|
197
|
+
return { kind: 'rate_limit',
|
|
198
|
+
detail: `Rate limited by ${facts.provider}${suffix || ' (HTTP 429)'}. Try again shortly.` };
|
|
199
|
+
case 'overloaded':
|
|
200
|
+
case 'server_error':
|
|
201
|
+
return { kind: 'provider_unavailable',
|
|
202
|
+
detail: `${facts.provider} is unavailable right now${suffix}.` };
|
|
203
|
+
case 'network_error':
|
|
204
|
+
return { kind: 'network',
|
|
205
|
+
detail: `Could not reach ${facts.host}. Check the base URL, DNS, proxy or VPN.` };
|
|
206
|
+
case 'timeout':
|
|
207
|
+
return { kind: 'timeout',
|
|
208
|
+
detail: `No answer from ${facts.host} within ${facts.timeoutSeconds} s.` };
|
|
209
|
+
default:
|
|
210
|
+
return { kind: 'unknown', detail: UNKNOWN_DETAIL };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function firstText(message) {
|
|
214
|
+
const block = message.content.find((part) => part.type === 'text');
|
|
215
|
+
return block && block.type === 'text' ? block.text : undefined;
|
|
216
|
+
}
|
|
217
|
+
/** Only text literally present in the error body may count as "the field is wrong". */
|
|
218
|
+
function errorBody(err) {
|
|
219
|
+
return typeof err.raw === 'string' ? err.raw : err.message;
|
|
220
|
+
}
|
|
221
|
+
function isTokenFieldRejection(err, provider) {
|
|
222
|
+
if (!(err instanceof LLMError))
|
|
223
|
+
return false;
|
|
224
|
+
return classifyOutputLimitFailure(provider, err.statusCode ?? 0, errorBody(err)) !== undefined;
|
|
225
|
+
}
|
|
226
|
+
function requestFor(request, deadline, maxTokens) {
|
|
227
|
+
return {
|
|
228
|
+
model: request.model.trim(),
|
|
229
|
+
messages: [{ role: 'user', content: PROBE_PROMPT }],
|
|
230
|
+
apiKey: request.apiKey ?? '',
|
|
231
|
+
...(request.baseUrl ? { baseUrl: request.baseUrl } : {}),
|
|
232
|
+
signal: deadline.signal,
|
|
233
|
+
...(maxTokens === undefined ? {} : { maxTokens }),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function logProbe(facts, outcome, now) {
|
|
237
|
+
try {
|
|
238
|
+
getLogger().child('llm').info('probe_result', {
|
|
239
|
+
provider: facts.provider, model: facts.model, host: facts.host,
|
|
240
|
+
latencyMs: outcome.latencyMs, ok: outcome.ok, kind: outcome.kind ?? 'ok',
|
|
241
|
+
at: now,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
catch { /* logging must never fail a probe */ }
|
|
245
|
+
}
|
|
246
|
+
function candidateOf(model) {
|
|
247
|
+
return {
|
|
248
|
+
id: model.id,
|
|
249
|
+
name: model.name && model.name !== model.id ? model.name : model.id,
|
|
250
|
+
...(model.contextWindowSource === 'api' && model.contextWindow
|
|
251
|
+
? { contextWindow: model.contextWindow } : {}),
|
|
252
|
+
supportsTools: model.supportsTools === true,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
async function runDiscovery(provider, request, connectionId) {
|
|
256
|
+
const guard = guardFailure(provider, request, connectionId);
|
|
257
|
+
if (guard) {
|
|
258
|
+
return { ok: false, connectionId, kind: guard.kind, detail: guard.detail, models: [],
|
|
259
|
+
truncated: false, total: 0 };
|
|
260
|
+
}
|
|
261
|
+
const deadline = createDeadline(PROBE_LIMITS.discoveryTimeoutMs, request.signal);
|
|
262
|
+
const facts = { provider: request.provider, model: request.model,
|
|
263
|
+
host: endpointHost(request.baseUrl), timeoutSeconds: PROBE_LIMITS.discoveryTimeoutMs / 1000 };
|
|
264
|
+
try {
|
|
265
|
+
const listed = await provider.listModels(request.apiKey, request.baseUrl ?? undefined, deadline.signal);
|
|
266
|
+
const candidates = (listed ?? []).map(candidateOf).filter((entry) => entry.id.length > 0);
|
|
267
|
+
return { ok: true, connectionId, total: candidates.length,
|
|
268
|
+
models: candidates.slice(0, PROBE_LIMITS.maxListedModels),
|
|
269
|
+
truncated: candidates.length > PROBE_LIMITS.maxListedModels };
|
|
270
|
+
}
|
|
271
|
+
catch (failure) {
|
|
272
|
+
const verdict = request.signal?.aborted ? { kind: 'cancelled', detail: 'Cancelled.' }
|
|
273
|
+
: deadline.isFired()
|
|
274
|
+
? { kind: 'timeout',
|
|
275
|
+
detail: `No answer from ${facts.host} within ${facts.timeoutSeconds} s.` }
|
|
276
|
+
: classifyError(failure, facts);
|
|
277
|
+
return { ok: false, connectionId, kind: verdict.kind, detail: verdict.detail, models: [],
|
|
278
|
+
truncated: false, total: 0 };
|
|
279
|
+
}
|
|
280
|
+
finally {
|
|
281
|
+
deadline.dispose();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/** Create the probe port. One instance per App; `SettingsScreen` only passes it down. */
|
|
285
|
+
export function createModelProbe(deps) {
|
|
286
|
+
const now = deps.now ?? Date.now;
|
|
287
|
+
return {
|
|
288
|
+
targetFingerprint: (target) => fingerprint(target.provider, target.baseUrl, target.apiKey, target.model),
|
|
289
|
+
connectionFingerprint: (target) => fingerprint(target.provider, target.baseUrl, target.apiKey),
|
|
290
|
+
discover: (request) => runDiscovery(deps.registry.get(request.provider), request, fingerprint(request.provider, request.baseUrl, request.apiKey)),
|
|
291
|
+
test: (request) => runTest(deps, request),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
async function runTest(deps, request) {
|
|
295
|
+
const clock = deps.now ?? Date.now;
|
|
296
|
+
const started = clock();
|
|
297
|
+
const targetId = fingerprint(request.provider, request.baseUrl, request.apiKey, request.model);
|
|
298
|
+
const provider = deps.registry.get(request.provider);
|
|
299
|
+
const guard = guardFailure(provider, request, targetId);
|
|
300
|
+
if (guard)
|
|
301
|
+
return guard;
|
|
302
|
+
const adapter = provider;
|
|
303
|
+
const deadline = createDeadline(PROBE_LIMITS.testTimeoutMs, request.signal);
|
|
304
|
+
const facts = { provider: request.provider, model: request.model.trim(),
|
|
305
|
+
host: endpointHost(request.baseUrl), timeoutSeconds: PROBE_LIMITS.testTimeoutMs / 1000 };
|
|
306
|
+
// The list runs CONCURRENTLY and has no veto (§4.3 step 3): it may only decorate a
|
|
307
|
+
// verdict the completion already produced. Nothing waits for it except the
|
|
308
|
+
// model-less path, which has no completion to wait for.
|
|
309
|
+
const listing = withCap(() => adapter.listModels(request.apiKey, request.baseUrl ?? undefined, deadline.signal), PROBE_LIMITS.listModelsBudgetMs);
|
|
310
|
+
try {
|
|
311
|
+
return await completeAndJudge(adapter, request, deadline, facts, { targetId, started, now: clock, listing });
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
deadline.dispose();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
async function completeAndJudge(provider, request, deadline, facts, context) {
|
|
318
|
+
const base = { targetId: context.targetId };
|
|
319
|
+
if (request.model.trim().length === 0) {
|
|
320
|
+
// Nothing to complete: list only, and report what the key can reach.
|
|
321
|
+
const reachable = await context.listing;
|
|
322
|
+
const latencyMs = context.now() - context.started;
|
|
323
|
+
if (request.signal?.aborted) {
|
|
324
|
+
return { ...base, ok: false, latencyMs, kind: 'cancelled', detail: 'Cancelled.' };
|
|
325
|
+
}
|
|
326
|
+
if (deadline.isFired()) {
|
|
327
|
+
return { ...base, ok: false, latencyMs, kind: 'timeout',
|
|
328
|
+
detail: `No answer from ${facts.host} within ${facts.timeoutSeconds} s.` };
|
|
329
|
+
}
|
|
330
|
+
return { ...base, ok: true, latencyMs,
|
|
331
|
+
...(reachable ? { availableModels: reachable.length } : {}) };
|
|
332
|
+
}
|
|
333
|
+
let message;
|
|
334
|
+
let failure;
|
|
335
|
+
try {
|
|
336
|
+
message = await provider.complete(requestFor(request, deadline, MIN_MAX_OUTPUT_TOKENS));
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
failure = error;
|
|
340
|
+
}
|
|
341
|
+
if (failure !== undefined && isTokenFieldRejection(failure, request.provider)) {
|
|
342
|
+
// Exactly ONE repair retry with the cap left to the adapter's own learned field.
|
|
343
|
+
try {
|
|
344
|
+
message = await provider.complete(requestFor(request, deadline));
|
|
345
|
+
failure = undefined;
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
failure = error;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const latencyMs = context.now() - context.started;
|
|
352
|
+
if (request.signal?.aborted) {
|
|
353
|
+
return { ...base, ok: false, latencyMs, kind: 'cancelled', detail: 'Cancelled.' };
|
|
354
|
+
}
|
|
355
|
+
if (deadline.isFired()) {
|
|
356
|
+
return { ...base, ok: false, latencyMs, kind: 'timeout',
|
|
357
|
+
detail: `No answer from ${facts.host} within ${facts.timeoutSeconds} s.` };
|
|
358
|
+
}
|
|
359
|
+
if (failure !== undefined || !message) {
|
|
360
|
+
const verdict = classifyError(failure, facts);
|
|
361
|
+
const secrets = [request.apiKey ?? ''];
|
|
362
|
+
const outcome = { ...base, ok: false, latencyMs, kind: verdict.kind,
|
|
363
|
+
detail: redactDetail(verdict.detail, secrets) };
|
|
364
|
+
const reachable = await listAtVerdict(context.listing);
|
|
365
|
+
if (reachable)
|
|
366
|
+
outcome.availableModels = reachable.length;
|
|
367
|
+
if (verdict.kind === 'model_not_found' && reachable) {
|
|
368
|
+
outcome.modelListed = reachable.some((model) => model.id === facts.model);
|
|
369
|
+
}
|
|
370
|
+
logProbe(facts, outcome, context.now());
|
|
371
|
+
return outcome;
|
|
372
|
+
}
|
|
373
|
+
const text = firstText(message);
|
|
374
|
+
if (text === undefined) {
|
|
375
|
+
const outcome = { ...base, ok: false, latencyMs, kind: 'unknown',
|
|
376
|
+
detail: UNKNOWN_DETAIL };
|
|
377
|
+
logProbe(facts, outcome, context.now());
|
|
378
|
+
return outcome;
|
|
379
|
+
}
|
|
380
|
+
const reachable = await listAtVerdict(context.listing);
|
|
381
|
+
const outcome = {
|
|
382
|
+
...base, ok: true, latencyMs,
|
|
383
|
+
reply: text.trim().slice(0, PROBE_LIMITS.replyChars),
|
|
384
|
+
...(message.usage ? { usage: { inputTokens: message.usage.inputTokens,
|
|
385
|
+
outputTokens: message.usage.outputTokens } } : {}),
|
|
386
|
+
...(reachable ? { availableModels: reachable.length,
|
|
387
|
+
modelListed: reachable.some((model) => model.id === facts.model) } : {}),
|
|
388
|
+
};
|
|
389
|
+
logProbe(facts, outcome, context.now());
|
|
390
|
+
return outcome;
|
|
391
|
+
}
|
package/dist/agent/reducer.d.ts
CHANGED
|
@@ -385,6 +385,14 @@ export interface ViewState {
|
|
|
385
385
|
*/
|
|
386
386
|
droppedEntries: number;
|
|
387
387
|
}
|
|
388
|
+
/**
|
|
389
|
+
* The zero session total.
|
|
390
|
+
*
|
|
391
|
+
* ONE LITERAL FOR TWO SITES (`initialViewState` and `resetConversation`). They
|
|
392
|
+
* used to spell the object out twice, which is how `cacheReadTokens` would have
|
|
393
|
+
* been added to one of them and not the other.
|
|
394
|
+
*/
|
|
395
|
+
export declare const EMPTY_USAGE_TOTAL: UsageTotal;
|
|
388
396
|
export interface ViewStateSeed {
|
|
389
397
|
/** Start with thinking blocks expanded. Defaults to FALSE, the product default. */
|
|
390
398
|
thinkingVisible?: boolean;
|
|
@@ -490,6 +498,20 @@ export type ViewAction = {
|
|
|
490
498
|
type: 'clearTranscript';
|
|
491
499
|
} | {
|
|
492
500
|
type: 'resetConversation';
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Zero the session's spend WITHOUT touching the conversation
|
|
504
|
+
* (tui-session-id-resume D-10 / R-9 / T-45).
|
|
505
|
+
*
|
|
506
|
+
* A separate action rather than `resetConversation`, because the two mean
|
|
507
|
+
* different things: `/clear` ends the conversation and the spend together,
|
|
508
|
+
* while an in-TUI `/resume` adopts another conversation and starts a NEW id -
|
|
509
|
+
* so the counter must restart, but the entries that were just restored must
|
|
510
|
+
* not be thrown away with it. `context` is left untouched for the reason
|
|
511
|
+
* `resetConversation` records: the meter, not the reducer, owns occupancy.
|
|
512
|
+
*/
|
|
513
|
+
| {
|
|
514
|
+
type: 'resetUsage';
|
|
493
515
|
} | {
|
|
494
516
|
type: 'setOverlay';
|
|
495
517
|
overlay: Overlay;
|
package/dist/agent/reducer.js
CHANGED
|
@@ -24,7 +24,7 @@ export const DEFAULT_TOAST_TTL_MS = 2500;
|
|
|
24
24
|
* used to spell the object out twice, which is how `cacheReadTokens` would have
|
|
25
25
|
* been added to one of them and not the other.
|
|
26
26
|
*/
|
|
27
|
-
const EMPTY_USAGE_TOTAL = {
|
|
27
|
+
export const EMPTY_USAGE_TOTAL = {
|
|
28
28
|
inputTokens: 0,
|
|
29
29
|
outputTokens: 0,
|
|
30
30
|
cacheReadTokens: 0,
|
|
@@ -767,25 +767,32 @@ export function viewReducer(state, action) {
|
|
|
767
767
|
...state,
|
|
768
768
|
entries: [],
|
|
769
769
|
// The count describes what the RETAIN RING removed from the visible
|
|
770
|
-
// transcript. `/clear` empties it by intent, so carrying the number
|
|
770
|
+
// transcript. `/clear screen` empties it by intent, so carrying the number
|
|
771
771
|
// over would make the exported transcript claim a loss that did not
|
|
772
772
|
// happen.
|
|
773
773
|
droppedEntries: 0,
|
|
774
774
|
streamingId: undefined,
|
|
775
775
|
expandedToolIds: {},
|
|
776
776
|
teamEntryId: undefined,
|
|
777
|
-
// The list goes with the screen it was on. `/clear` is an explicit
|
|
778
|
-
// instruction, which is I-2's standing exception (`TodoClearReason`
|
|
779
|
-
// `'user'`, the same door `/todo clear` uses)
|
|
777
|
+
// The list goes with the screen it was on. `/clear screen` is an explicit
|
|
778
|
+
// user instruction, which is I-2's standing exception (`TodoClearReason`
|
|
779
|
+
// `'user'`, the same door `/todo clear` uses) - so the command clears the
|
|
780
780
|
// STORE via `controller.clearTodos()` and this clears the MIRROR, exactly
|
|
781
781
|
// the pairing `resetConversation` below has always had.
|
|
782
782
|
//
|
|
783
783
|
// The mirror write is same-valued with the store's own `todoCleared`
|
|
784
784
|
// dispatch rather than redundant with it: it is what makes this action
|
|
785
|
-
// stand on its own. `
|
|
786
|
-
//
|
|
785
|
+
// stand on its own. `commands/clear-command.ts` is its only dispatcher
|
|
786
|
+
// TODAY - `/clear` itself now goes through `resetConversation`, because
|
|
787
|
+
// clearing the SCREEN and clearing the SESSION are different intentions
|
|
788
|
+
// (clear-command-context-reset D-3) - and a hypothetical second one would
|
|
787
789
|
// otherwise bring the bug back in identical form and in silence.
|
|
788
790
|
//
|
|
791
|
+
// KEPT RATHER THAN FOLDED INTO `resetConversation` for the same reason:
|
|
792
|
+
// a user who wants the screen back must not have to destroy their context
|
|
793
|
+
// to get it, and an action with no dispatcher is dead code by `CLAUDE.md`
|
|
794
|
+
// section 4 - which is what `/clear screen` prevents.
|
|
795
|
+
//
|
|
789
796
|
// The entry id is dropped because the entry it pointed at is gone.
|
|
790
797
|
todos: null,
|
|
791
798
|
todoEntryId: undefined,
|
|
@@ -799,9 +806,12 @@ export function viewReducer(state, action) {
|
|
|
799
806
|
// id must go with it or `compactionEnd` would `mapEntry` an id that no
|
|
800
807
|
// longer exists. `compaction` STAYS — unlike `retry` it is not a
|
|
801
808
|
// projection of an entry, it is the live state of a subsystem that is
|
|
802
|
-
// still running
|
|
809
|
+
// still running. `/clear screen` clears the screen, not the session.
|
|
803
810
|
compactionEntryId: undefined,
|
|
804
811
|
};
|
|
812
|
+
case 'resetUsage':
|
|
813
|
+
// The one field, for the one reason: a new id bills from zero (D-10).
|
|
814
|
+
return { ...state, usageTotal: EMPTY_USAGE_TOTAL };
|
|
805
815
|
case 'resetConversation':
|
|
806
816
|
return {
|
|
807
817
|
...state,
|
|
@@ -810,22 +820,47 @@ export function viewReducer(state, action) {
|
|
|
810
820
|
droppedEntries: 0,
|
|
811
821
|
streamingId: undefined,
|
|
812
822
|
expandedToolIds: {},
|
|
813
|
-
// `context` IS DELIBERATELY UNTOUCHED (I-1). `
|
|
814
|
-
// `controller.
|
|
815
|
-
// the meter dirty and schedules a publication; zeroing here would make
|
|
816
|
-
//
|
|
823
|
+
// `context` IS DELIBERATELY UNTOUCHED (I-1). `clear-command.ts` calls
|
|
824
|
+
// `controller.resetConversation()` BEFORE dispatching this, and that marks
|
|
825
|
+
// the meter dirty and schedules a publication; zeroing here would make the
|
|
826
|
+
// final state `0` rather than the meter's real answer - which for an
|
|
817
827
|
// emptied conversation is the system prompt's own couple of percent, not
|
|
818
828
|
// nothing. `usageTotal` DOES reset: that is session spend, not occupancy.
|
|
819
829
|
usageTotal: EMPTY_USAGE_TOTAL,
|
|
820
830
|
teamEntryId: undefined,
|
|
821
|
-
// `/
|
|
822
|
-
// with it
|
|
831
|
+
// `/clear` clears `messages`, so the belief is gone and the list goes
|
|
832
|
+
// with it - the other half of I-2. `controller.clearMessages()` clears
|
|
823
833
|
// the STORE; this clears the mirror.
|
|
824
834
|
todos: null,
|
|
825
835
|
todoEntryId: undefined,
|
|
826
836
|
retryEntryId: undefined,
|
|
827
837
|
retry: null,
|
|
828
838
|
compactionEntryId: undefined,
|
|
839
|
+
// THE SIX RUN FACTS THIS BRANCH USED TO KEEP (clear-command-context-reset
|
|
840
|
+
// §3.6 / P0-3). `reduceRunFacts` above already zeroes `runPhase`,
|
|
841
|
+
// `activeTool`, `runOutcome` and `seenSteeringIds`, so nothing here
|
|
842
|
+
// repeats those four - these six are the actual gap, and each one
|
|
843
|
+
// otherwise describes a run that no longer exists.
|
|
844
|
+
//
|
|
845
|
+
// `status` is the status bar: a late `runEnd` would correct it a moment
|
|
846
|
+
// later, and until then the user is told a conversation that is over is
|
|
847
|
+
// still running. `team` is a dispatch panel with nothing dispatching.
|
|
848
|
+
// `fastEntryId` points at a `kind: 'fast'` card that `entries: []` just
|
|
849
|
+
// deleted, the same dangling-id rule `retryEntryId` and
|
|
850
|
+
// `compactionEntryId` above already follow.
|
|
851
|
+
//
|
|
852
|
+
// `turnProduced` / `errorNoticed` / `aborted` are ALSO cleared by
|
|
853
|
+
// `submit`, which is not a reason to leave them out: clearing is an
|
|
854
|
+
// independent entry point, so a user who clears and then only reads the
|
|
855
|
+
// panel would carry the previous turn's three facts into a conversation
|
|
856
|
+
// they never submitted into - and `runEnd`'s settle rule reads exactly
|
|
857
|
+
// those three.
|
|
858
|
+
status: 'idle',
|
|
859
|
+
team: null,
|
|
860
|
+
fastEntryId: undefined,
|
|
861
|
+
turnProduced: false,
|
|
862
|
+
errorNoticed: false,
|
|
863
|
+
aborted: false,
|
|
829
864
|
};
|
|
830
865
|
case 'setOverlay':
|
|
831
866
|
return { ...state, overlay: action.overlay };
|