@bermudi/pi-delegate 0.1.11 → 0.1.12
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/README.md +57 -4
- package/concurrency.ts +55 -16
- package/config.ts +584 -50
- package/delegate.ts +8 -1
- package/dispatch.ts +410 -38
- package/extension.ts +14 -0
- package/format.ts +50 -4
- package/host.ts +6 -1
- package/isolated-workspace.ts +857 -0
- package/lifecycle.ts +50 -16
- package/manual.ts +4 -3
- package/package.json +1 -1
- package/pool.ts +23 -1
- package/provider-extensions.ts +11 -2
- package/render-branches.ts +30 -0
- package/render-result.ts +8 -5
- package/runner.ts +3 -1
- package/schema.ts +56 -49
- package/settings.ts +202 -84
- package/shared-write-safety.ts +273 -0
- package/task-resolution.ts +87 -16
- package/telemetry.ts +135 -68
- package/ticket-format.ts +13 -5
- package/tickets.ts +23 -11
- package/types.ts +79 -0
package/config.ts
CHANGED
|
@@ -1,13 +1,164 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import * as fs from "node:fs";
|
|
2
3
|
import * as os from "node:os";
|
|
3
4
|
import * as path from "node:path";
|
|
5
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
4
6
|
import {
|
|
5
7
|
MAX_ASYNC_TICKETS,
|
|
6
8
|
MAX_CONCURRENCY,
|
|
7
9
|
OUTPUT_SPILL_TAIL_CHARS,
|
|
8
10
|
OUTPUT_SPILL_THRESHOLD_CHARS,
|
|
11
|
+
VALID_THINKING,
|
|
9
12
|
} from "./constants.ts";
|
|
10
13
|
|
|
14
|
+
export interface AgentOverride {
|
|
15
|
+
model?: string;
|
|
16
|
+
thinking?: ThinkingLevel;
|
|
17
|
+
tools?: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DELEGATE_CONFIG_SOURCE = "delegate.json";
|
|
21
|
+
|
|
22
|
+
/** Validate one agentOverrides entry. A malformed entry is dropped whole
|
|
23
|
+
* (warn) rather than partially applied — a half-parsed override that
|
|
24
|
+
* silently changes only `thinking` is worse than a loud no-op. */
|
|
25
|
+
function normalizeAgentOverride(
|
|
26
|
+
raw: unknown,
|
|
27
|
+
agentName: string,
|
|
28
|
+
): AgentOverride | null {
|
|
29
|
+
if (!isRecord(raw)) {
|
|
30
|
+
console.warn(
|
|
31
|
+
`[delegate] ignoring malformed agentOverrides entry for agent '${agentName}' in ${DELEGATE_CONFIG_SOURCE}: expected an object.`,
|
|
32
|
+
);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const result: AgentOverride = {};
|
|
37
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
38
|
+
if (key === "model") {
|
|
39
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
40
|
+
console.warn(
|
|
41
|
+
`[delegate] ignoring malformed agentOverrides entry for agent '${agentName}' in ${DELEGATE_CONFIG_SOURCE}: model must be a nonempty string.`,
|
|
42
|
+
);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
result.model = value.trim();
|
|
46
|
+
} else if (key === "thinking") {
|
|
47
|
+
if (typeof value !== "string" || !VALID_THINKING.has(value)) {
|
|
48
|
+
console.warn(
|
|
49
|
+
`[delegate] ignoring malformed agentOverrides entry for agent '${agentName}' in ${DELEGATE_CONFIG_SOURCE}: thinking must be a supported level.`,
|
|
50
|
+
);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
result.thinking = value as ThinkingLevel;
|
|
54
|
+
} else if (key === "tools") {
|
|
55
|
+
if (
|
|
56
|
+
!Array.isArray(value) ||
|
|
57
|
+
value.some((tool) => typeof tool !== "string")
|
|
58
|
+
) {
|
|
59
|
+
console.warn(
|
|
60
|
+
`[delegate] ignoring malformed agentOverrides entry for agent '${agentName}' in ${DELEGATE_CONFIG_SOURCE}: tools must be a string array.`,
|
|
61
|
+
);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
result.tools = [...value];
|
|
65
|
+
} else if (key === "skills") {
|
|
66
|
+
console.warn(
|
|
67
|
+
`[delegate] ignoring unsupported skills override for agent '${agentName}' in ${DELEGATE_CONFIG_SOURCE}: per-agent skill filtering is not supported.`,
|
|
68
|
+
);
|
|
69
|
+
return null;
|
|
70
|
+
} else {
|
|
71
|
+
console.warn(
|
|
72
|
+
`[delegate] ignoring malformed agentOverrides entry for agent '${agentName}' in ${DELEGATE_CONFIG_SOURCE}: unknown field '${key}'.`,
|
|
73
|
+
);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Normalize the `agentOverrides` map (agent name → override). Keys are
|
|
81
|
+
* trimmed; collisions after trimming keep the first entry and warn. Returns
|
|
82
|
+
* undefined when the block is absent. */
|
|
83
|
+
function normalizeAgentOverrides(
|
|
84
|
+
raw: unknown,
|
|
85
|
+
): Record<string, AgentOverride> | undefined {
|
|
86
|
+
if (raw === undefined) return undefined;
|
|
87
|
+
if (!isRecord(raw)) {
|
|
88
|
+
console.warn(
|
|
89
|
+
`[delegate] ignoring malformed agentOverrides in ${DELEGATE_CONFIG_SOURCE}: expected an object.`,
|
|
90
|
+
);
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Null-prototype map so config keys such as `constructor` and `__proto__`
|
|
95
|
+
// cannot resolve to Object.prototype members during normalization.
|
|
96
|
+
const out = Object.create(null) as Record<string, AgentOverride>;
|
|
97
|
+
const seenNames = new Map<string, string>();
|
|
98
|
+
for (const [agentName, value] of Object.entries(raw)) {
|
|
99
|
+
const normalizedAgentName = agentName.trim();
|
|
100
|
+
if (normalizedAgentName.length === 0) {
|
|
101
|
+
console.warn(
|
|
102
|
+
`[delegate] ignoring malformed agentOverrides entry in ${DELEGATE_CONFIG_SOURCE}: agent name must be nonempty.`,
|
|
103
|
+
);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const previousName = seenNames.get(normalizedAgentName);
|
|
107
|
+
if (previousName !== undefined) {
|
|
108
|
+
console.warn(
|
|
109
|
+
`[delegate] ignoring duplicate agentOverrides entry in ${DELEGATE_CONFIG_SOURCE}: agent keys '${previousName}' and '${agentName}' both normalize to '${normalizedAgentName}'.`,
|
|
110
|
+
);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
seenNames.set(normalizedAgentName, agentName);
|
|
114
|
+
const override = normalizeAgentOverride(value, normalizedAgentName);
|
|
115
|
+
if (override) out[normalizedAgentName] = override;
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Normalize `agentOverridesByParentModel` (`provider/model-id` → agent →
|
|
121
|
+
* override). Model keys are trimmed, not lowercased: they must match the
|
|
122
|
+
* parent's exact `provider/model-id`. Returns undefined when absent. */
|
|
123
|
+
function normalizeAgentOverridesByParentModel(
|
|
124
|
+
raw: unknown,
|
|
125
|
+
): Record<string, Record<string, AgentOverride>> | undefined {
|
|
126
|
+
if (raw === undefined) return undefined;
|
|
127
|
+
if (!isRecord(raw)) {
|
|
128
|
+
console.warn(
|
|
129
|
+
`[delegate] ignoring malformed agentOverridesByParentModel in ${DELEGATE_CONFIG_SOURCE}: expected an object.`,
|
|
130
|
+
);
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const out = Object.create(null) as Record<
|
|
135
|
+
string,
|
|
136
|
+
Record<string, AgentOverride>
|
|
137
|
+
>;
|
|
138
|
+
const seenModels = new Map<string, string>();
|
|
139
|
+
for (const [parentModel, overrides] of Object.entries(raw)) {
|
|
140
|
+
const normalizedParentModel = parentModel.trim();
|
|
141
|
+
if (normalizedParentModel.length === 0) {
|
|
142
|
+
console.warn(
|
|
143
|
+
`[delegate] ignoring malformed parent-model override in ${DELEGATE_CONFIG_SOURCE}: model key must be nonempty.`,
|
|
144
|
+
);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const previousModel = seenModels.get(normalizedParentModel);
|
|
148
|
+
if (previousModel !== undefined) {
|
|
149
|
+
console.warn(
|
|
150
|
+
`[delegate] ignoring duplicate parent-model override in ${DELEGATE_CONFIG_SOURCE}: model keys '${previousModel}' and '${parentModel}' both normalize to '${normalizedParentModel}'.`,
|
|
151
|
+
);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
seenModels.set(normalizedParentModel, parentModel);
|
|
155
|
+
const inner = normalizeAgentOverrides(overrides);
|
|
156
|
+
out[normalizedParentModel] =
|
|
157
|
+
inner ?? (Object.create(null) as Record<string, AgentOverride>);
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
11
162
|
export interface TelemetryConfig {
|
|
12
163
|
/** Whether to record delegate calls to the local SQLite store. Default true. */
|
|
13
164
|
enabled?: boolean;
|
|
@@ -23,6 +174,172 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
23
174
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
24
175
|
}
|
|
25
176
|
|
|
177
|
+
function isPositiveInteger(value: unknown): value is number {
|
|
178
|
+
return (
|
|
179
|
+
typeof value === "number" &&
|
|
180
|
+
Number.isFinite(value) &&
|
|
181
|
+
Number.isInteger(value) &&
|
|
182
|
+
value > 0
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isNonNegativeInteger(value: unknown): value is number {
|
|
187
|
+
return (
|
|
188
|
+
typeof value === "number" &&
|
|
189
|
+
Number.isFinite(value) &&
|
|
190
|
+
Number.isInteger(value) &&
|
|
191
|
+
value >= 0
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Validate numeric and nested-object fields before they reach the config
|
|
196
|
+
* singleton. Malformed values in `delegate.json` must not replace a valid
|
|
197
|
+
* snapshot with a half-parsed object that later produces `NaN` concurrency
|
|
198
|
+
* limits and deadlocks the global semaphore. */
|
|
199
|
+
function validateNumericAndNestedFields(
|
|
200
|
+
raw: Record<string, unknown>,
|
|
201
|
+
): string | null {
|
|
202
|
+
if (
|
|
203
|
+
"allowUnsafeSharedWrites" in raw &&
|
|
204
|
+
typeof raw.allowUnsafeSharedWrites !== "boolean"
|
|
205
|
+
) {
|
|
206
|
+
return `allowUnsafeSharedWrites must be a boolean; got ${JSON.stringify(
|
|
207
|
+
raw.allowUnsafeSharedWrites,
|
|
208
|
+
)}`;
|
|
209
|
+
}
|
|
210
|
+
if ("agent" in raw && raw.agent !== undefined && !isRecord(raw.agent)) {
|
|
211
|
+
return "agent must be an object";
|
|
212
|
+
}
|
|
213
|
+
if (
|
|
214
|
+
"concurrency" in raw &&
|
|
215
|
+
raw.concurrency !== undefined &&
|
|
216
|
+
!isRecord(raw.concurrency)
|
|
217
|
+
) {
|
|
218
|
+
return "concurrency must be an object";
|
|
219
|
+
}
|
|
220
|
+
if ("retry" in raw && raw.retry !== undefined && !isRecord(raw.retry)) {
|
|
221
|
+
return "retry must be an object";
|
|
222
|
+
}
|
|
223
|
+
if ("output" in raw && raw.output !== undefined && !isRecord(raw.output)) {
|
|
224
|
+
return "output must be an object";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (
|
|
228
|
+
"maxConcurrent" in raw &&
|
|
229
|
+
raw.maxConcurrent !== undefined &&
|
|
230
|
+
!isPositiveInteger(raw.maxConcurrent)
|
|
231
|
+
) {
|
|
232
|
+
return `maxConcurrent must be a positive integer; got ${JSON.stringify(
|
|
233
|
+
raw.maxConcurrent,
|
|
234
|
+
)}`;
|
|
235
|
+
}
|
|
236
|
+
if (
|
|
237
|
+
"maxAsyncTickets" in raw &&
|
|
238
|
+
raw.maxAsyncTickets !== undefined &&
|
|
239
|
+
!isPositiveInteger(raw.maxAsyncTickets)
|
|
240
|
+
) {
|
|
241
|
+
return `maxAsyncTickets must be a positive integer; got ${JSON.stringify(
|
|
242
|
+
raw.maxAsyncTickets,
|
|
243
|
+
)}`;
|
|
244
|
+
}
|
|
245
|
+
if (
|
|
246
|
+
"stallTimeoutMs" in raw &&
|
|
247
|
+
raw.stallTimeoutMs !== undefined &&
|
|
248
|
+
!isNonNegativeInteger(raw.stallTimeoutMs)
|
|
249
|
+
) {
|
|
250
|
+
return `stallTimeoutMs must be a non-negative integer; got ${JSON.stringify(
|
|
251
|
+
raw.stallTimeoutMs,
|
|
252
|
+
)}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const concurrency = raw.concurrency;
|
|
256
|
+
if (concurrency) {
|
|
257
|
+
const c = concurrency as Record<string, unknown>;
|
|
258
|
+
if ("default" in c && !isPositiveInteger(c.default)) {
|
|
259
|
+
return `concurrency.default must be a positive integer; got ${JSON.stringify(
|
|
260
|
+
c.default,
|
|
261
|
+
)}`;
|
|
262
|
+
}
|
|
263
|
+
if ("providers" in c) {
|
|
264
|
+
const providers = c.providers;
|
|
265
|
+
if (!isRecord(providers)) {
|
|
266
|
+
return "concurrency.providers must be an object";
|
|
267
|
+
}
|
|
268
|
+
for (const [key, value] of Object.entries(providers)) {
|
|
269
|
+
if (!isPositiveInteger(value)) {
|
|
270
|
+
return `concurrency.providers.${key} must be a positive integer; got ${JSON.stringify(
|
|
271
|
+
value,
|
|
272
|
+
)}`;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if ("models" in c) {
|
|
277
|
+
const models = c.models;
|
|
278
|
+
if (!isRecord(models)) {
|
|
279
|
+
return "concurrency.models must be an object";
|
|
280
|
+
}
|
|
281
|
+
for (const [key, value] of Object.entries(models)) {
|
|
282
|
+
if (!isPositiveInteger(value)) {
|
|
283
|
+
return `concurrency.models.${key} must be a positive integer; got ${JSON.stringify(
|
|
284
|
+
value,
|
|
285
|
+
)}`;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const retry = raw.retry;
|
|
292
|
+
if (retry) {
|
|
293
|
+
const r = retry as Record<string, unknown>;
|
|
294
|
+
if (
|
|
295
|
+
"wholeTaskMaxRetries" in r &&
|
|
296
|
+
!isNonNegativeInteger(r.wholeTaskMaxRetries)
|
|
297
|
+
) {
|
|
298
|
+
return `retry.wholeTaskMaxRetries must be a non-negative integer; got ${JSON.stringify(
|
|
299
|
+
r.wholeTaskMaxRetries,
|
|
300
|
+
)}`;
|
|
301
|
+
}
|
|
302
|
+
if (
|
|
303
|
+
"wholeTaskBaseDelayMs" in r &&
|
|
304
|
+
!isNonNegativeInteger(r.wholeTaskBaseDelayMs)
|
|
305
|
+
) {
|
|
306
|
+
return `retry.wholeTaskBaseDelayMs must be a non-negative integer; got ${JSON.stringify(
|
|
307
|
+
r.wholeTaskBaseDelayMs,
|
|
308
|
+
)}`;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const output = raw.output;
|
|
313
|
+
if (output) {
|
|
314
|
+
const o = output as Record<string, unknown>;
|
|
315
|
+
if (
|
|
316
|
+
"spillThresholdChars" in o &&
|
|
317
|
+
!isPositiveInteger(o.spillThresholdChars)
|
|
318
|
+
) {
|
|
319
|
+
return `output.spillThresholdChars must be a positive integer; got ${JSON.stringify(
|
|
320
|
+
o.spillThresholdChars,
|
|
321
|
+
)}`;
|
|
322
|
+
}
|
|
323
|
+
if ("spillTailChars" in o && !isNonNegativeInteger(o.spillTailChars)) {
|
|
324
|
+
return `output.spillTailChars must be a non-negative integer; got ${JSON.stringify(
|
|
325
|
+
o.spillTailChars,
|
|
326
|
+
)}`;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Return a safe positive integer from a possibly malformed config value. */
|
|
334
|
+
function safePositiveInteger(value: unknown, defaultValue: number): number {
|
|
335
|
+
return isPositiveInteger(value) ? (value as number) : defaultValue;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Return a safe non-negative integer from a possibly malformed config value. */
|
|
339
|
+
function safeNonNegativeInteger(value: unknown, defaultValue: number): number {
|
|
340
|
+
return isNonNegativeInteger(value) ? (value as number) : defaultValue;
|
|
341
|
+
}
|
|
342
|
+
|
|
26
343
|
/**
|
|
27
344
|
* Validate the user-editable telemetry block at its boundary. An explicitly
|
|
28
345
|
* malformed block disables telemetry rather than silently turning it on with
|
|
@@ -61,6 +378,14 @@ export interface DelegateConfig {
|
|
|
61
378
|
/** Per-agent-type model overrides. Keys are agent names or "default". */
|
|
62
379
|
[agentType: string]: string | null | undefined;
|
|
63
380
|
};
|
|
381
|
+
/** Per-agent model/thinking/tools overrides (agent name → override), from
|
|
382
|
+
* delegate.json. Applies to every non-`default` agent, including the
|
|
383
|
+
* built-ins (`scout`/`coder`/`reviewer`) and custom agents. This is the
|
|
384
|
+
* modern form; the legacy `agent` map above still feeds custom agents. */
|
|
385
|
+
agentOverrides?: Record<string, AgentOverride>;
|
|
386
|
+
/** Parent-model-scoped overrides keyed by the parent's exact
|
|
387
|
+
* `provider/model-id`. Win over `agentOverrides` on a key match. */
|
|
388
|
+
agentOverridesByParentModel?: Record<string, Record<string, AgentOverride>>;
|
|
64
389
|
/** Per-model and per-provider concurrency limits. */
|
|
65
390
|
concurrency: {
|
|
66
391
|
/** Default concurrency limit for unspecified models. */
|
|
@@ -76,6 +401,9 @@ export interface DelegateConfig {
|
|
|
76
401
|
maxAsyncTickets?: number;
|
|
77
402
|
/** Maximum inactivity before cooperative stall cancellation is requested (0 disables). */
|
|
78
403
|
stallTimeoutMs?: number;
|
|
404
|
+
/** Operator-only escape hatch for overlapping shared writers. Not exposed
|
|
405
|
+
* through the model-facing tool schema. Default false. */
|
|
406
|
+
allowUnsafeSharedWrites?: boolean;
|
|
79
407
|
/** Whole-task transient-error retry settings. */
|
|
80
408
|
retry?: {
|
|
81
409
|
/** Max whole-task retries after the initial attempt (0 = no retry). */
|
|
@@ -99,8 +427,12 @@ export interface DelegateConfig {
|
|
|
99
427
|
};
|
|
100
428
|
}
|
|
101
429
|
|
|
102
|
-
|
|
103
|
-
|
|
430
|
+
/** Resolved lazily (not at module scope) so the path follows the live
|
|
431
|
+
* `os.homedir()` — the test suite swaps homedir via `mock.module`, and an
|
|
432
|
+
* eagerly-bound path would keep reading the developer's real config. */
|
|
433
|
+
function delegateConfigPath(): string {
|
|
434
|
+
return path.join(os.homedir(), ".pi", "agent", "delegate.json");
|
|
435
|
+
}
|
|
104
436
|
|
|
105
437
|
function normalizeProviderExtensions(
|
|
106
438
|
raw: unknown,
|
|
@@ -158,6 +490,7 @@ const DEFAULT_DELEGATE_CONFIG: DelegateConfig = {
|
|
|
158
490
|
concurrency: { default: MAX_CONCURRENCY },
|
|
159
491
|
maxConcurrent: MAX_CONCURRENCY,
|
|
160
492
|
stallTimeoutMs: 15 * 60 * 1000,
|
|
493
|
+
allowUnsafeSharedWrites: false,
|
|
161
494
|
retry: {
|
|
162
495
|
wholeTaskMaxRetries: 3,
|
|
163
496
|
wholeTaskBaseDelayMs: 1_000,
|
|
@@ -181,6 +514,72 @@ let __delegateConfig: DelegateConfig = {
|
|
|
181
514
|
};
|
|
182
515
|
let stallTimeoutOverrideForTesting: number | undefined;
|
|
183
516
|
|
|
517
|
+
type ReadConfigResult =
|
|
518
|
+
| { status: "ok"; config: DelegateConfig }
|
|
519
|
+
| { status: "missing" }
|
|
520
|
+
| { status: "error"; error: unknown };
|
|
521
|
+
|
|
522
|
+
/** Read and normalize delegate.json from disk. Distinguishes a missing file
|
|
523
|
+
* (deliberate deletion) from parse/read errors so reload can keep the prior
|
|
524
|
+
* valid snapshot instead of silently installing defaults. */
|
|
525
|
+
function readDelegateConfigFromDisk(): ReadConfigResult {
|
|
526
|
+
try {
|
|
527
|
+
const raw = fs.readFileSync(delegateConfigPath(), "utf-8");
|
|
528
|
+
const parsed = JSON.parse(raw);
|
|
529
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
530
|
+
return {
|
|
531
|
+
status: "error",
|
|
532
|
+
error: new Error("top-level value is not an object"),
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
const numericError = validateNumericAndNestedFields(
|
|
536
|
+
parsed as Record<string, unknown>,
|
|
537
|
+
);
|
|
538
|
+
if (numericError) {
|
|
539
|
+
return {
|
|
540
|
+
status: "error",
|
|
541
|
+
error: new Error(numericError),
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
// Merge with defaults so new fields are always present
|
|
545
|
+
return {
|
|
546
|
+
status: "ok",
|
|
547
|
+
config: {
|
|
548
|
+
...DEFAULT_DELEGATE_CONFIG,
|
|
549
|
+
...parsed,
|
|
550
|
+
agent: { ...DEFAULT_DELEGATE_CONFIG.agent, ...(parsed.agent ?? {}) },
|
|
551
|
+
concurrency: {
|
|
552
|
+
...DEFAULT_DELEGATE_CONFIG.concurrency,
|
|
553
|
+
...(parsed.concurrency ?? {}),
|
|
554
|
+
},
|
|
555
|
+
retry: { ...DEFAULT_DELEGATE_CONFIG.retry, ...(parsed.retry ?? {}) },
|
|
556
|
+
providerExtensions: normalizeProviderExtensions(
|
|
557
|
+
parsed.providerExtensions,
|
|
558
|
+
),
|
|
559
|
+
agentOverrides: normalizeAgentOverrides(parsed.agentOverrides),
|
|
560
|
+
agentOverridesByParentModel: normalizeAgentOverridesByParentModel(
|
|
561
|
+
parsed.agentOverridesByParentModel,
|
|
562
|
+
),
|
|
563
|
+
telemetry: normalizeTelemetryConfig(parsed.telemetry),
|
|
564
|
+
output: {
|
|
565
|
+
...DEFAULT_DELEGATE_CONFIG.output,
|
|
566
|
+
...(parsed.output ?? {}),
|
|
567
|
+
},
|
|
568
|
+
} as DelegateConfig,
|
|
569
|
+
};
|
|
570
|
+
} catch (error) {
|
|
571
|
+
if (
|
|
572
|
+
error &&
|
|
573
|
+
typeof error === "object" &&
|
|
574
|
+
"code" in error &&
|
|
575
|
+
(error as NodeJS.ErrnoException).code === "ENOENT"
|
|
576
|
+
) {
|
|
577
|
+
return { status: "missing" };
|
|
578
|
+
}
|
|
579
|
+
return { status: "error", error };
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
184
583
|
/** Read delegate config from disk. Returns defaults if file missing or corrupt.
|
|
185
584
|
*
|
|
186
585
|
* The returned `providerExtensions` is the *user-only* view — exactly what the
|
|
@@ -191,30 +590,63 @@ let stallTimeoutOverrideForTesting: number | undefined;
|
|
|
191
590
|
* distinguish "the user listed this" from "this is a shipped default" by
|
|
192
591
|
* config presence rather than string identity. */
|
|
193
592
|
export function loadDelegateConfig(): DelegateConfig {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
agent: { ...DEFAULT_DELEGATE_CONFIG.agent, ...(parsed.agent ?? {}) },
|
|
204
|
-
concurrency: {
|
|
205
|
-
...DEFAULT_DELEGATE_CONFIG.concurrency,
|
|
206
|
-
...(parsed.concurrency ?? {}),
|
|
207
|
-
},
|
|
208
|
-
retry: { ...DEFAULT_DELEGATE_CONFIG.retry, ...(parsed.retry ?? {}) },
|
|
209
|
-
providerExtensions: normalizeProviderExtensions(
|
|
210
|
-
parsed.providerExtensions,
|
|
211
|
-
),
|
|
212
|
-
telemetry: normalizeTelemetryConfig(parsed.telemetry),
|
|
213
|
-
output: { ...DEFAULT_DELEGATE_CONFIG.output, ...(parsed.output ?? {}) },
|
|
214
|
-
} as DelegateConfig;
|
|
215
|
-
} catch {
|
|
593
|
+
const result = readDelegateConfigFromDisk();
|
|
594
|
+
if (result.status === "error") {
|
|
595
|
+
console.warn(
|
|
596
|
+
`[delegate] could not load ${DELEGATE_CONFIG_SOURCE}: ${
|
|
597
|
+
result.error instanceof Error
|
|
598
|
+
? result.error.message
|
|
599
|
+
: String(result.error)
|
|
600
|
+
}; using defaults.`,
|
|
601
|
+
);
|
|
216
602
|
return structuredClone(DEFAULT_DELEGATE_CONFIG);
|
|
217
603
|
}
|
|
604
|
+
if (result.status === "missing")
|
|
605
|
+
return structuredClone(DEFAULT_DELEGATE_CONFIG);
|
|
606
|
+
return result.config;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Clone a config while retaining the null-prototype override maps.
|
|
610
|
+
*
|
|
611
|
+
* `structuredClone` deliberately preserves data, not object prototypes. That
|
|
612
|
+
* is normally useful, but these maps are a trust boundary: an inherited
|
|
613
|
+
* `constructor` or `toString` must not look like a configured agent.
|
|
614
|
+
*/
|
|
615
|
+
function cloneDelegateConfig(config: DelegateConfig): DelegateConfig {
|
|
616
|
+
const clone = structuredClone(config);
|
|
617
|
+
|
|
618
|
+
if (clone.agentOverrides) {
|
|
619
|
+
clone.agentOverrides = cloneNullPrototypeMap(clone.agentOverrides);
|
|
620
|
+
}
|
|
621
|
+
if (clone.agentOverridesByParentModel) {
|
|
622
|
+
const parentModels = cloneNullPrototypeMap(
|
|
623
|
+
clone.agentOverridesByParentModel,
|
|
624
|
+
);
|
|
625
|
+
for (const [parentModel, overrides] of Object.entries(parentModels)) {
|
|
626
|
+
parentModels[parentModel] = cloneNullPrototypeMap(overrides);
|
|
627
|
+
}
|
|
628
|
+
clone.agentOverridesByParentModel = parentModels;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
return clone;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function cloneNullPrototypeMap<T>(map: Record<string, T>): Record<string, T> {
|
|
635
|
+
const clone = Object.create(null) as Record<string, T>;
|
|
636
|
+
for (const [key, value] of Object.entries(map)) {
|
|
637
|
+
clone[key] = value;
|
|
638
|
+
}
|
|
639
|
+
return clone;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Return an immutable snapshot of the current delegate configuration.
|
|
643
|
+
*
|
|
644
|
+
* Async tickets and long-lived task runners capture this at dispatch time so
|
|
645
|
+
* a later `delegate.json` edit cannot retroactively change an in-flight
|
|
646
|
+
* batch's retry limits, stall timeout, output-spill bounds, or provider
|
|
647
|
+
* extension allowlist. */
|
|
648
|
+
export function getDelegateConfigSnapshot(): DelegateConfig {
|
|
649
|
+
return cloneDelegateConfig(__delegateConfig);
|
|
218
650
|
}
|
|
219
651
|
|
|
220
652
|
/** Initialize module config from disk. Called once at extension load. */
|
|
@@ -257,9 +689,21 @@ export function _setStallTimeoutForTesting(
|
|
|
257
689
|
export function _setDelegateConfigForTesting(
|
|
258
690
|
config: Partial<DelegateConfig> = {},
|
|
259
691
|
): void {
|
|
692
|
+
const numericError = validateNumericAndNestedFields(
|
|
693
|
+
config as Record<string, unknown>,
|
|
694
|
+
);
|
|
695
|
+
if (numericError) {
|
|
696
|
+
throw new Error(`[delegate] _setDelegateConfigForTesting: ${numericError}`);
|
|
697
|
+
}
|
|
260
698
|
__delegateConfig = {
|
|
261
699
|
...DEFAULT_DELEGATE_CONFIG,
|
|
262
700
|
...config,
|
|
701
|
+
maxConcurrent:
|
|
702
|
+
config.maxConcurrent ?? DEFAULT_DELEGATE_CONFIG.maxConcurrent,
|
|
703
|
+
maxAsyncTickets:
|
|
704
|
+
config.maxAsyncTickets ?? DEFAULT_DELEGATE_CONFIG.maxAsyncTickets,
|
|
705
|
+
stallTimeoutMs:
|
|
706
|
+
config.stallTimeoutMs ?? DEFAULT_DELEGATE_CONFIG.stallTimeoutMs,
|
|
263
707
|
agent: {
|
|
264
708
|
...DEFAULT_DELEGATE_CONFIG.agent,
|
|
265
709
|
...(config.agent ?? {}),
|
|
@@ -273,6 +717,10 @@ export function _setDelegateConfigForTesting(
|
|
|
273
717
|
...(config.retry ?? {}),
|
|
274
718
|
},
|
|
275
719
|
providerExtensions: normalizeProviderExtensions(config.providerExtensions),
|
|
720
|
+
agentOverrides: normalizeAgentOverrides(config.agentOverrides),
|
|
721
|
+
agentOverridesByParentModel: normalizeAgentOverridesByParentModel(
|
|
722
|
+
config.agentOverridesByParentModel,
|
|
723
|
+
),
|
|
276
724
|
telemetry: normalizeTelemetryConfig(config.telemetry),
|
|
277
725
|
output: {
|
|
278
726
|
...DEFAULT_DELEGATE_CONFIG.output,
|
|
@@ -316,6 +764,33 @@ export function getSubagentProviderExtensionsForProvider(
|
|
|
316
764
|
: [];
|
|
317
765
|
}
|
|
318
766
|
|
|
767
|
+
/** Stable signature for the provider-scoped extension allowlist that applies
|
|
768
|
+
* to a given model provider. Used as a pool compatibility key so a
|
|
769
|
+
* `delegate.json` edit that revokes or changes an allowlisted extension does
|
|
770
|
+
* not silently reuse a pooled session whose runtime already loaded the old
|
|
771
|
+
* extension code. */
|
|
772
|
+
export function getProviderExtensionSignature(
|
|
773
|
+
provider: string | undefined,
|
|
774
|
+
config: DelegateConfig = __delegateConfig,
|
|
775
|
+
): string {
|
|
776
|
+
const sources = getSubagentProviderExtensionSourcesForProvider(
|
|
777
|
+
provider,
|
|
778
|
+
config,
|
|
779
|
+
);
|
|
780
|
+
if (sources.length === 0) return "";
|
|
781
|
+
// Preserve both ordering and provenance. Extension order may affect their
|
|
782
|
+
// initialization, and a user re-listing a shipped source changes it from
|
|
783
|
+
// best-effort to required. Treating either change as pool-compatible could
|
|
784
|
+
// reuse an extension-free session without performing the required checks.
|
|
785
|
+
// The signature crosses the session-pool boundary. Keep it opaque so a
|
|
786
|
+
// credential-bearing Git source can never become model-visible through a
|
|
787
|
+
// later pool mismatch. SHA-256 also keeps collision resistance appropriate
|
|
788
|
+
// for a key that decides whether already-loaded executable code may be reused.
|
|
789
|
+
return `sha256:${createHash("sha256")
|
|
790
|
+
.update(JSON.stringify(sources), "utf8")
|
|
791
|
+
.digest("hex")}`;
|
|
792
|
+
}
|
|
793
|
+
|
|
319
794
|
/** A provider-extension source together with how it entered the config. */
|
|
320
795
|
export interface ProviderExtensionSource {
|
|
321
796
|
/** The normalized source string, as the package manager consumes it. */
|
|
@@ -370,6 +845,46 @@ export function getSubagentProviderExtensionSourcesForProvider(
|
|
|
370
845
|
}));
|
|
371
846
|
}
|
|
372
847
|
|
|
848
|
+
/** Get the effective per-agent overrides from delegate.json
|
|
849
|
+
* (agent name → override), or undefined when unconfigured. */
|
|
850
|
+
export function getAgentOverrides(
|
|
851
|
+
config: DelegateConfig = __delegateConfig,
|
|
852
|
+
): Record<string, AgentOverride> | undefined {
|
|
853
|
+
return config.agentOverrides;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** Get the parent-model-scoped overrides (`provider/model-id` → agent →
|
|
857
|
+
* override), or undefined when unconfigured. */
|
|
858
|
+
export function getAgentOverridesByParentModel(
|
|
859
|
+
config: DelegateConfig = __delegateConfig,
|
|
860
|
+
): Record<string, Record<string, AgentOverride>> | undefined {
|
|
861
|
+
return config.agentOverridesByParentModel;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** Re-read delegate.json from disk into the config singleton.
|
|
865
|
+
*
|
|
866
|
+
* Called at the start of every tool execution so user edits to delegate.json
|
|
867
|
+
* become visible between delegate calls without restarting pi. On parse or
|
|
868
|
+
* read errors the existing snapshot is kept and a warning is emitted; a
|
|
869
|
+
* deliberately deleted file falls back to defaults. */
|
|
870
|
+
export function reloadDelegateConfig(): void {
|
|
871
|
+
const result = readDelegateConfigFromDisk();
|
|
872
|
+
if (result.status === "error") {
|
|
873
|
+
console.warn(
|
|
874
|
+
`[delegate] could not reload ${DELEGATE_CONFIG_SOURCE}; keeping current config: ${
|
|
875
|
+
result.error instanceof Error
|
|
876
|
+
? result.error.message
|
|
877
|
+
: String(result.error)
|
|
878
|
+
}`,
|
|
879
|
+
);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
__delegateConfig =
|
|
883
|
+
result.status === "missing"
|
|
884
|
+
? structuredClone(DEFAULT_DELEGATE_CONFIG)
|
|
885
|
+
: result.config;
|
|
886
|
+
}
|
|
887
|
+
|
|
373
888
|
// ── Config Getters ───────────────────────────────────────────────────────
|
|
374
889
|
|
|
375
890
|
/**
|
|
@@ -385,23 +900,33 @@ export function getConcurrencyLimit(
|
|
|
385
900
|
): number {
|
|
386
901
|
// 1. Per-model
|
|
387
902
|
const perModel = config.concurrency.models?.[modelKey];
|
|
388
|
-
if (perModel
|
|
903
|
+
if (isPositiveInteger(perModel)) return perModel;
|
|
389
904
|
// 2. Per-provider
|
|
390
|
-
const provider = modelKey.split("/")[0];
|
|
905
|
+
const provider = modelKey.split("/")[0] ?? modelKey;
|
|
391
906
|
const perProvider = config.concurrency.providers?.[provider];
|
|
392
|
-
if (perProvider
|
|
907
|
+
if (isPositiveInteger(perProvider)) return perProvider;
|
|
393
908
|
// 3. Default
|
|
394
|
-
return config.concurrency.default
|
|
909
|
+
return isPositiveInteger(config.concurrency.default)
|
|
910
|
+
? config.concurrency.default
|
|
911
|
+
: MAX_CONCURRENCY;
|
|
395
912
|
}
|
|
396
913
|
|
|
397
914
|
/** Get the effective max async tickets limit. */
|
|
398
|
-
export function getMaxAsyncTickets(
|
|
399
|
-
|
|
915
|
+
export function getMaxAsyncTickets(
|
|
916
|
+
config: DelegateConfig = __delegateConfig,
|
|
917
|
+
): number {
|
|
918
|
+
return isPositiveInteger(config.maxAsyncTickets)
|
|
919
|
+
? config.maxAsyncTickets
|
|
920
|
+
: MAX_ASYNC_TICKETS;
|
|
400
921
|
}
|
|
401
922
|
|
|
402
923
|
/** Get the hard ceiling on total concurrent agents. */
|
|
403
|
-
export function getMaxConcurrent(
|
|
404
|
-
|
|
924
|
+
export function getMaxConcurrent(
|
|
925
|
+
config: DelegateConfig = __delegateConfig,
|
|
926
|
+
): number {
|
|
927
|
+
return isPositiveInteger(config.maxConcurrent)
|
|
928
|
+
? config.maxConcurrent
|
|
929
|
+
: MAX_CONCURRENCY;
|
|
405
930
|
}
|
|
406
931
|
|
|
407
932
|
/** Maximum inactivity before cooperative stall cancellation is requested.
|
|
@@ -410,36 +935,45 @@ export function getStallTimeoutMs(
|
|
|
410
935
|
config: DelegateConfig = __delegateConfig,
|
|
411
936
|
): number {
|
|
412
937
|
const configured = stallTimeoutOverrideForTesting ?? config.stallTimeoutMs;
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
configured >= 0
|
|
417
|
-
) {
|
|
418
|
-
return configured;
|
|
419
|
-
}
|
|
420
|
-
return DEFAULT_DELEGATE_CONFIG.stallTimeoutMs!;
|
|
938
|
+
return isNonNegativeInteger(configured)
|
|
939
|
+
? configured
|
|
940
|
+
: DEFAULT_DELEGATE_CONFIG.stallTimeoutMs!;
|
|
421
941
|
}
|
|
422
942
|
|
|
423
943
|
/** Get the max whole-task retries after the initial attempt. */
|
|
424
|
-
export function getWholeTaskMaxRetries(
|
|
425
|
-
|
|
944
|
+
export function getWholeTaskMaxRetries(
|
|
945
|
+
config: DelegateConfig = __delegateConfig,
|
|
946
|
+
): number {
|
|
947
|
+
return isNonNegativeInteger(config.retry?.wholeTaskMaxRetries)
|
|
948
|
+
? config.retry!.wholeTaskMaxRetries
|
|
949
|
+
: 3;
|
|
426
950
|
}
|
|
427
951
|
|
|
428
952
|
/** Get the base delay (ms) for whole-task retry exponential backoff. */
|
|
429
|
-
export function getWholeTaskBaseDelayMs(
|
|
430
|
-
|
|
953
|
+
export function getWholeTaskBaseDelayMs(
|
|
954
|
+
config: DelegateConfig = __delegateConfig,
|
|
955
|
+
): number {
|
|
956
|
+
return isNonNegativeInteger(config.retry?.wholeTaskBaseDelayMs)
|
|
957
|
+
? config.retry!.wholeTaskBaseDelayMs
|
|
958
|
+
: 1_000;
|
|
431
959
|
}
|
|
432
960
|
|
|
433
961
|
/** Get the output-spill threshold (chars). Over this, final output is spilled. */
|
|
434
|
-
export function getOutputSpillThreshold(
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
)
|
|
962
|
+
export function getOutputSpillThreshold(
|
|
963
|
+
config: DelegateConfig = __delegateConfig,
|
|
964
|
+
): number {
|
|
965
|
+
return isPositiveInteger(config.output?.spillThresholdChars)
|
|
966
|
+
? config.output!.spillThresholdChars
|
|
967
|
+
: OUTPUT_SPILL_THRESHOLD_CHARS;
|
|
438
968
|
}
|
|
439
969
|
|
|
440
970
|
/** Get the output-spill tail length (chars) kept in-context when spilled. */
|
|
441
|
-
export function getOutputSpillTail(
|
|
442
|
-
|
|
971
|
+
export function getOutputSpillTail(
|
|
972
|
+
config: DelegateConfig = __delegateConfig,
|
|
973
|
+
): number {
|
|
974
|
+
return isNonNegativeInteger(config.output?.spillTailChars)
|
|
975
|
+
? config.output!.spillTailChars
|
|
976
|
+
: OUTPUT_SPILL_TAIL_CHARS;
|
|
443
977
|
}
|
|
444
978
|
|
|
445
979
|
/**
|