@commonlyai/cli 0.1.65 → 0.1.67
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/package.json +1 -1
- package/src/commands/agent.js +31 -1
- package/src/lib/adapters/pi.js +20 -3
- package/src/lib/upstream-refusal.js +205 -0
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
import { detectBwrap } from '../lib/sandbox/bwrap.js';
|
|
43
43
|
import { resolvePublicSandboxMode } from '../lib/sandbox/mode.js';
|
|
44
44
|
import { detectSeatbelt } from '../lib/sandbox/seatbelt.js';
|
|
45
|
+
import { describeUpstreamRefusal } from '../lib/upstream-refusal.js';
|
|
45
46
|
import {
|
|
46
47
|
DEFAULT_HOOK_TIMEOUT_MS,
|
|
47
48
|
clampHookTimeoutMs,
|
|
@@ -1249,13 +1250,22 @@ export const performRun = ({
|
|
|
1249
1250
|
const heartbeatControlReply = (event.type === 'heartbeat' || event.payload?.hasHeartbeat === true)
|
|
1250
1251
|
&& /^(HEARTBEAT_OK|HEARTBEAT_NOOP)$/i.test(replyText);
|
|
1251
1252
|
const silentReply = !replyText || replyText === 'NO_REPLY' || heartbeatControlReply;
|
|
1253
|
+
// A refused model route is not a silent turn. pi reports the refusal on its
|
|
1254
|
+
// own JSON stream and exits 0 with no text, so before this the seat log read
|
|
1255
|
+
// `no wrapper-post (empty output)` and the cause — a 429 budget message — was
|
|
1256
|
+
// nowhere (TASK-096; two and a half days of this on one seat).
|
|
1257
|
+
const upstream = result.upstream || null;
|
|
1252
1258
|
let delivered = agentPostedItself;
|
|
1253
1259
|
let deliveryRefusal = null;
|
|
1254
1260
|
|
|
1255
1261
|
if (event.type === 'agent.ask') {
|
|
1256
1262
|
if (silentReply) {
|
|
1257
1263
|
const reason = heartbeatControlReply ? replyText : (replyText || 'empty output');
|
|
1258
|
-
|
|
1264
|
+
// The same naming rule as the chat path below: an ask that went
|
|
1265
|
+
// unanswered because the route refused must not read as the agent
|
|
1266
|
+
// having nothing to say.
|
|
1267
|
+
if (upstream) log(`[${event.type}] ${describeUpstreamRefusal(upstream)} — no private response`);
|
|
1268
|
+
else log(`[${event.type}] no private response (${reason})`);
|
|
1259
1269
|
} else {
|
|
1260
1270
|
try {
|
|
1261
1271
|
await client.post(
|
|
@@ -1304,6 +1314,11 @@ export const performRun = ({
|
|
|
1304
1314
|
+ `(matched message ${suppressedBy.id} by ${suppressedBy.author} `
|
|
1305
1315
|
+ `via ${suppressedBy.basis})`,
|
|
1306
1316
|
);
|
|
1317
|
+
} else if (upstream) {
|
|
1318
|
+
// The status is always named; the body only when the keep-list kept one.
|
|
1319
|
+
// No retry is added: the adapter's own ladder has already run, and a
|
|
1320
|
+
// refused route is refused on the next attempt too.
|
|
1321
|
+
log(`[${event.type}] ${describeUpstreamRefusal(upstream)} — nothing posted this turn`);
|
|
1307
1322
|
} else {
|
|
1308
1323
|
log(`[${event.type}] no wrapper-post (${reason}) — nothing posted this turn`);
|
|
1309
1324
|
}
|
|
@@ -1394,6 +1409,21 @@ export const performRun = ({
|
|
|
1394
1409
|
},
|
|
1395
1410
|
};
|
|
1396
1411
|
}
|
|
1412
|
+
// A refusal is not a decline. `no_action` with no reason means "the agent
|
|
1413
|
+
// chose not to answer" and hands a human wake to one remaining listener;
|
|
1414
|
+
// nothing about the agent's choice happened here — the model never ran — so
|
|
1415
|
+
// say what did happen, and let the event close rather than fan a
|
|
1416
|
+
// route-wide refusal out across the rest of the fleet.
|
|
1417
|
+
if (!delivered && upstream) {
|
|
1418
|
+
return {
|
|
1419
|
+
outcome: 'no_action',
|
|
1420
|
+
reason: `upstream-refused-${upstream.status}`,
|
|
1421
|
+
details: {
|
|
1422
|
+
status: upstream.status,
|
|
1423
|
+
...(upstream.detail ? { detail: upstream.detail } : {}),
|
|
1424
|
+
},
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1397
1427
|
return { outcome: delivered ? 'posted' : 'no_action' };
|
|
1398
1428
|
};
|
|
1399
1429
|
|
package/src/lib/adapters/pi.js
CHANGED
|
@@ -38,6 +38,10 @@ import { mkdir, readdir, writeFile } from 'fs/promises';
|
|
|
38
38
|
import { homedir } from 'os';
|
|
39
39
|
import { dirname, join } from 'path';
|
|
40
40
|
import { fileURLToPath } from 'url';
|
|
41
|
+
import {
|
|
42
|
+
readUpstreamRefusal,
|
|
43
|
+
spawnCredentials,
|
|
44
|
+
} from '../upstream-refusal.js';
|
|
41
45
|
import { buildMemoryPreamble } from '../memory-bridge.js';
|
|
42
46
|
import { GRANT_BROKER_REFUSAL, isGrantBrokerUrl } from './pi-mcp-client.mjs';
|
|
43
47
|
import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
|
|
@@ -326,7 +330,7 @@ export const extractReply = (stdout) => {
|
|
|
326
330
|
return { text, sawAssistant, errors };
|
|
327
331
|
};
|
|
328
332
|
|
|
329
|
-
const runPi = ({ args, cwd, env, payload, timeoutMs, spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
|
|
333
|
+
const runPi = ({ args, cwd, env, payload, timeoutMs, credentials = [], spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
|
|
330
334
|
let stdout = '';
|
|
331
335
|
let stderr = '';
|
|
332
336
|
let timedOut = false;
|
|
@@ -352,6 +356,11 @@ const runPi = ({ args, cwd, env, payload, timeoutMs, spawnImpl = childSpawn }) =
|
|
|
352
356
|
clearTimeout(timer);
|
|
353
357
|
if (timedOut) return reject(new Error(`pi timed out after ${timeoutMs}ms`));
|
|
354
358
|
const reply = extractReply(stdout);
|
|
359
|
+
// A refused model route is NOT an error event: pi retries the ladder itself
|
|
360
|
+
// and exits 0 with no assistant text, which is why this read has to happen
|
|
361
|
+
// on the stream rather than on the exit code (TASK-096, measured 2026-09-22).
|
|
362
|
+
const upstream = readUpstreamRefusal(stdout, { credentials });
|
|
363
|
+
if (upstream) reply.upstream = upstream;
|
|
355
364
|
if (code !== 0 && !reply.sawAssistant) {
|
|
356
365
|
const tail = (reply.errors.join(' | ') || stderr).trim().slice(-600);
|
|
357
366
|
return reject(new Error(`pi exited ${code}: ${tail}`));
|
|
@@ -383,6 +392,12 @@ export default {
|
|
|
383
392
|
if (!baseEnv[provider.apiKeyEnv]) {
|
|
384
393
|
throw new Error(`pi adapter: ${provider.apiKeyEnv} is not set — the seat's environment must carry the provider key (LiteLLM virtual key)`);
|
|
385
394
|
}
|
|
395
|
+
// The values this spawn is handed, for the refusal log's exact-match check:
|
|
396
|
+
// the provider key the route authenticates with, and the seat credential the
|
|
397
|
+
// bridge carries. Snapshot them HERE — `withholdRuntimeCredential` removes
|
|
398
|
+
// the token from the child copy below, and after that there is nothing left
|
|
399
|
+
// to compare a refusal against.
|
|
400
|
+
const credentials = spawnCredentials(ctx, [provider.apiKeyEnv]);
|
|
386
401
|
|
|
387
402
|
// Per-seat pi home: models.json holds the provider by env reference.
|
|
388
403
|
const home = seatHome(ctx);
|
|
@@ -437,11 +452,13 @@ export default {
|
|
|
437
452
|
env: childEnv,
|
|
438
453
|
payload: servers.length ? JSON.stringify(servers) : undefined,
|
|
439
454
|
timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
455
|
+
credentials,
|
|
440
456
|
spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
|
|
441
457
|
});
|
|
442
458
|
// Empty text with a clean exit is a silent turn; the run loop treats it
|
|
443
|
-
// as NO_REPLY-shaped and re-delivers on its own rules.
|
|
444
|
-
|
|
459
|
+
// as NO_REPLY-shaped and re-delivers on its own rules. Empty text that
|
|
460
|
+
// came with a refusal is named instead — see upstream-refusal.js.
|
|
461
|
+
return { text: reply.text, newSessionId: sessionId, upstream: reply.upstream ?? null };
|
|
445
462
|
} finally {
|
|
446
463
|
// Best effort: the bridge has already read what it needs by the time the
|
|
447
464
|
// turn ends, and a token file that outlives its turn is a token file that
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TASK-096 — name a refused model route, instead of reporting the silence it
|
|
3
|
+
* causes.
|
|
4
|
+
*
|
|
5
|
+
* Measured 2026-09-22 against pi 0.84.1 with a route answering 429 (the shape
|
|
6
|
+
* that made a seat look broken for two and a half days): pi runs its own retry
|
|
7
|
+
* ladder, reports the refusal on `message_end`/`auto_retry_end` JSON events, and
|
|
8
|
+
* EXITS 0 with no assistant text. `extractReply` in the pi adapter collected only
|
|
9
|
+
* `type: "error"` events — of which this produces none — so the wrapper logged
|
|
10
|
+
* `no wrapper-post (empty output)` and the cause sat unread in the stream.
|
|
11
|
+
*
|
|
12
|
+
* Two rules govern the text that reaches a seat log:
|
|
13
|
+
*
|
|
14
|
+
* 1. KEEP-LIST, not strip-list (Vera 70876, Wren 70877). A strip-list removes
|
|
15
|
+
* only the shapes we thought of, and a 401 can echo the key back. So a body
|
|
16
|
+
* contributes one NAMED field or nothing at all.
|
|
17
|
+
* 2. Exact-match refusal for credentials. If the kept text contains a value
|
|
18
|
+
* this spawn was handed, the text is dropped — a known value, not a guessed
|
|
19
|
+
* shape (Wren 70877).
|
|
20
|
+
*
|
|
21
|
+
* Two limits follow from rule 2. They are the accepted cost of refusing guessed
|
|
22
|
+
* shapes, named here so they are not rediscovered as defects later (Vera 71082):
|
|
23
|
+
*
|
|
24
|
+
* a. The match is exact and against THIS spawn's values only, so a credential
|
|
25
|
+
* that arrives transformed — base64 inside a proxy error, percent-encoded in
|
|
26
|
+
* a URL echo — does not match and the text is kept. Matching transformed
|
|
27
|
+
* shapes instead would reintroduce the class rule 1 exists to avoid.
|
|
28
|
+
* b. The keep-list can still surface someone ELSE's secret. A third party's key
|
|
29
|
+
* relayed in an upstream error is not detectable here, because the one
|
|
30
|
+
* inventory this module has is the set of values it was itself handed.
|
|
31
|
+
*
|
|
32
|
+
* Scope: this reader is wired into the pi adapter only. codex and claude are NOT
|
|
33
|
+
* silent the way pi was: the claude adapter rejects on a non-zero exit and
|
|
34
|
+
* deliberately reports stdout with it, because a `-p` run writes quota
|
|
35
|
+
* conditions there; the codex adapter rejects on `turnFailedMessage` even at
|
|
36
|
+
* exit 0. Their gap is the opposite one — the text they raise is the raw tail,
|
|
37
|
+
* unscrubbed by the rules above, and carries no HTTP status for the run loop to
|
|
38
|
+
* key a refusal on. Extending this module to them is adapter work (TASK-103),
|
|
39
|
+
* not a line in this comment.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** The kept detail is truncated, ellipsis included, to this many characters. */
|
|
43
|
+
export const MAX_REFUSAL_DETAIL = 256;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The status an adapter prefixes to its error text: `"429: {…}"` -> `429`.
|
|
47
|
+
* Null for anything that is not a 4xx/5xx, so a message that merely starts with
|
|
48
|
+
* a number ("4040 tokens over budget") is not read as a status.
|
|
49
|
+
*
|
|
50
|
+
* The separator pi uses is NOT fixed — measured 2026-09-22, the same pi 0.84.1
|
|
51
|
+
* emits `"429: {"message":…}"` for a JSON body and `"502 <html>…"` for an
|
|
52
|
+
* HTML one. Both forms are matched here; a parser that required the colon would
|
|
53
|
+
* have kept `502 <html>…` as the whole "body" and then dropped it as non-JSON,
|
|
54
|
+
* reporting the status alone with no sign that the body was never read.
|
|
55
|
+
*/
|
|
56
|
+
const STATUS_PREFIX = /^\s*(\d{3})(?!\d)\s*:?\s*/;
|
|
57
|
+
|
|
58
|
+
export const statusFromError = (value) => {
|
|
59
|
+
const match = STATUS_PREFIX.exec(String(value ?? ''));
|
|
60
|
+
if (!match) return null;
|
|
61
|
+
const status = Number(match[1]);
|
|
62
|
+
return status >= 400 && status <= 599 ? status : null;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The body the adapter carried after its status prefix, or the whole text when
|
|
67
|
+
* there is no status to strip. `"429: {"message":"a: b"}"` -> `{"message":"a: b"}`;
|
|
68
|
+
* `"502 <html>"` -> `<html>`.
|
|
69
|
+
*/
|
|
70
|
+
export const bodyFromError = (value) => {
|
|
71
|
+
const text = String(value ?? '');
|
|
72
|
+
if (statusFromError(text) === null) return text.trim();
|
|
73
|
+
return text.replace(STATUS_PREFIX, '').trim();
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Replace credential values this spawn was handed with a marker.
|
|
78
|
+
*
|
|
79
|
+
* This is the exact-match half, for text that is kept for its whole shape rather
|
|
80
|
+
* than filtered to one field — an adapter's stderr tail, say, whose diagnostic
|
|
81
|
+
* value is in the lines around the secret. Values shorter than eight characters
|
|
82
|
+
* are ignored: redacting a one-character "credential" would corrupt ordinary
|
|
83
|
+
* prose, and no issued credential is that short.
|
|
84
|
+
*/
|
|
85
|
+
export const redactKnownCredentials = (text, credentials = []) => {
|
|
86
|
+
let out = String(text ?? '');
|
|
87
|
+
for (const value of credentials) {
|
|
88
|
+
if (typeof value !== 'string' || value.length < 8) continue;
|
|
89
|
+
out = out.split(value).join('[redacted]');
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The keep-list. Given the raw body an adapter captured, keep at most one named
|
|
96
|
+
* field of it and only when that field is a string:
|
|
97
|
+
*
|
|
98
|
+
* `error.message` first (the OpenAI-shaped error), then a top-level `message`
|
|
99
|
+
* (which is the shape LiteLLM's budget refusal actually uses — the motivating
|
|
100
|
+
* case; a keep-list that kept only `error.message` would name the status and
|
|
101
|
+
* still hide the budget line that started this row).
|
|
102
|
+
*
|
|
103
|
+
* Everything else — HTML, a non-JSON body, a JSON body with neither field, a
|
|
104
|
+
* body echoing a known credential — contributes nothing. Returns null rather
|
|
105
|
+
* than a partial guess.
|
|
106
|
+
*/
|
|
107
|
+
export const keptRefusalDetail = (raw, { credentials = [] } = {}) => {
|
|
108
|
+
let parsed;
|
|
109
|
+
try {
|
|
110
|
+
parsed = JSON.parse(String(raw ?? ''));
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
115
|
+
|
|
116
|
+
const candidate = parsed?.error?.message ?? parsed?.message;
|
|
117
|
+
if (typeof candidate !== 'string') return null;
|
|
118
|
+
|
|
119
|
+
const text = candidate.replace(/\s+/g, ' ').trim();
|
|
120
|
+
if (!text) return null;
|
|
121
|
+
|
|
122
|
+
const redacted = redactKnownCredentials(text, credentials);
|
|
123
|
+
// `[redacted]` appearing is the signal that the body echoed something this
|
|
124
|
+
// spawn was handed. The status is still worth keeping; the text is not.
|
|
125
|
+
if (redacted !== text) return null;
|
|
126
|
+
|
|
127
|
+
return text.length > MAX_REFUSAL_DETAIL
|
|
128
|
+
? `${text.slice(0, MAX_REFUSAL_DETAIL - 1)}…`
|
|
129
|
+
: text;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Read a pi JSON stream and return the refusal it ended on, or null.
|
|
134
|
+
*
|
|
135
|
+
* `auto_retry_end` is the terminal word — it names the status and the body after
|
|
136
|
+
* the ladder has finished — so it wins over the per-attempt `message_end`
|
|
137
|
+
* events that precede it. A run that refused on its first attempt and never
|
|
138
|
+
* retried still reports through `message_end`.
|
|
139
|
+
*/
|
|
140
|
+
export const readUpstreamRefusal = (stdout, { credentials = [] } = {}) => {
|
|
141
|
+
let status = null;
|
|
142
|
+
let raw = null;
|
|
143
|
+
|
|
144
|
+
for (const line of String(stdout ?? '').split('\n')) {
|
|
145
|
+
if (!line.trim()) continue;
|
|
146
|
+
let event;
|
|
147
|
+
try {
|
|
148
|
+
event = JSON.parse(line);
|
|
149
|
+
} catch {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (event?.type === 'auto_retry_end' && event.success === false) {
|
|
154
|
+
const candidate = event.finalError ?? event.errorMessage;
|
|
155
|
+
const attemptStatus = statusFromError(candidate);
|
|
156
|
+
if (attemptStatus) {
|
|
157
|
+
status = attemptStatus;
|
|
158
|
+
raw = bodyFromError(candidate);
|
|
159
|
+
}
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const message = event?.message;
|
|
164
|
+
if (
|
|
165
|
+
event?.type === 'message_end'
|
|
166
|
+
&& message?.role === 'assistant'
|
|
167
|
+
&& message.stopReason === 'error'
|
|
168
|
+
&& status === null
|
|
169
|
+
) {
|
|
170
|
+
const attemptStatus = statusFromError(message.errorMessage);
|
|
171
|
+
if (attemptStatus) {
|
|
172
|
+
status = attemptStatus;
|
|
173
|
+
raw = bodyFromError(message.errorMessage);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (status === null) return null;
|
|
179
|
+
return { status, detail: keptRefusalDetail(raw, { credentials }) };
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The seat-log clause. The status is always present; the detail only when the
|
|
184
|
+
* keep-list kept one. `grep 'upstream refused'` finds every refusal, which is
|
|
185
|
+
* the property the old `no wrapper-post (empty output)` line did not have.
|
|
186
|
+
*/
|
|
187
|
+
export const describeUpstreamRefusal = (refusal) => {
|
|
188
|
+
if (!refusal || typeof refusal.status !== 'number') return null;
|
|
189
|
+
return refusal.detail
|
|
190
|
+
? `upstream refused ${refusal.status}: ${refusal.detail}`
|
|
191
|
+
: `upstream refused ${refusal.status}`;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The credential values this spawn was handed, for the exact-match check in
|
|
196
|
+
* `redactKnownCredentials`. `envNames` are the adapter's own provider keys; the
|
|
197
|
+
* caller supplies them because only the adapter knows which key its route uses.
|
|
198
|
+
*/
|
|
199
|
+
export const spawnCredentials = (ctx = {}, envNames = []) => {
|
|
200
|
+
const env = ctx.env || process.env || {};
|
|
201
|
+
return [
|
|
202
|
+
ctx.runtimeToken,
|
|
203
|
+
...envNames.map((name) => env[name]),
|
|
204
|
+
].filter((value) => typeof value === 'string' && value.length >= 8);
|
|
205
|
+
};
|