gentle-pi 3.2.0 → 3.3.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/assets/orchestrator-delegation.md +13 -8
- package/assets/orchestrator.md +2 -2
- package/docs/gentle-shell.md +40 -17
- package/docs/readme-reference.md +41 -7
- package/docs/review-integration.md +25 -11
- package/extensions/gentle-agents.ts +85 -17
- package/extensions/gentle-ai.ts +179 -12
- package/extensions/gentle-shell.ts +408 -38
- package/extensions/gentle-todo.ts +19 -1
- package/lib/agents-view.ts +41 -14
- package/lib/agents-widget.ts +84 -13
- package/lib/command-palette-catalog.ts +1 -0
- package/lib/double-esc-cancel-policy.ts +138 -0
- package/lib/inprocess-reviewer.ts +260 -0
- package/lib/model-routing-authority.ts +1 -1
- package/lib/native-review-cli.ts +23 -0
- package/lib/odd-runtime-delegation-gate.ts +88 -0
- package/lib/review-host-relay.ts +262 -94
- package/lib/review-integration-v2.ts +110 -26
- package/lib/shell-bar.ts +158 -29
- package/lib/shell-card.ts +19 -9
- package/lib/shell-changes-view.ts +43 -5
- package/lib/shell-changes.ts +92 -5
- package/lib/shell-hover.ts +39 -0
- package/lib/shell-prompt.ts +10 -1
- package/lib/shell-sidebar-layout.ts +111 -15
- package/lib/shell-sidebar.ts +16 -0
- package/lib/shell-todo.ts +7 -1
- package/lib/shell-usage-view.ts +98 -10
- package/lib/shell-usage.ts +226 -10
- package/package.json +2 -1
- package/runtime/native-review-cli.mjs +23 -0
- package/runtime/review-integration-v2.mjs +110 -26
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
- package/scripts/mirror-odd-routing.mjs +242 -0
- package/scripts/verify-package-files.mjs +3 -3
- package/tests/agents-grouping.test.ts +75 -18
- package/tests/agents-view.test.ts +28 -18
- package/tests/agents-widget.test.ts +100 -12
- package/tests/command-palette.test.ts +1 -0
- package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
- package/tests/double-esc-cancel-policy.test.ts +194 -0
- package/tests/gentle-agents.test.ts +528 -5
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +69 -5
- package/tests/gentle-shell.test.ts +903 -25
- package/tests/gentle-todo.test.ts +17 -4
- package/tests/inprocess-reviewer.test.ts +368 -0
- package/tests/maintainer/provider-relay.maintest.ts +101 -143
- package/tests/native-review-capability-contract.test.ts +32 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
- package/tests/odd-routing-contract.test.ts +57 -0
- package/tests/odd-runtime-delegation-gate.test.ts +212 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/package-manifest.test.ts +6 -6
- package/tests/review-controller-native-routing.test.ts +60 -1
- package/tests/review-host-relay-routing.test.ts +77 -0
- package/tests/review-host-relay.test.ts +285 -239
- package/tests/review-integration-v2-forward.test.ts +61 -0
- package/tests/review-integration-v2.test.ts +116 -1
- package/tests/review-relay-transport-agent.test.ts +83 -0
- package/tests/runtime-harness.mjs +11 -0
- package/tests/session-changes-shell.test.ts +27 -0
- package/tests/session-worktree-registry.test.ts +41 -0
- package/tests/shell-bar.test.ts +224 -6
- package/tests/shell-card.test.ts +5 -3
- package/tests/shell-changes-view.test.ts +47 -0
- package/tests/shell-changes.test.ts +177 -0
- package/tests/shell-hover.test.ts +19 -0
- package/tests/shell-prompt.test.ts +20 -0
- package/tests/shell-sidebar-fullscreen.test.ts +59 -0
- package/tests/shell-sidebar-layout.test.ts +243 -5
- package/tests/shell-sidebar.test.ts +25 -1
- package/tests/shell-todo.test.ts +36 -0
- package/tests/shell-usage-view.test.ts +123 -3
- package/tests/shell-usage.test.ts +254 -6
- package/lib/opaque-pi-reviewer-adapter.ts +0 -284
- package/tests/opaque-pi-reviewer-adapter.test.ts +0 -266
package/lib/agents-view.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Key, matchesKey, truncateToWidth, visibleWidth, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
2
2
|
import { measureAgentsViewLayout, type AgentsViewLayout } from "./agents-view-layout.ts";
|
|
3
|
+
import { HOVER_ROLE, paintHoverable } from "./shell-hover.ts";
|
|
3
4
|
import { emptyThread, isFinished, TASK_STATUS, type TaskRecord, type TaskStore, type TaskThread, type ThreadItem } from "./agents-protocol.ts";
|
|
4
5
|
import { renderThreadItem, type AgentsThreadTheme } from "./agents-thread-view.ts";
|
|
5
6
|
import { formatElapsed } from "./agents-widget.ts";
|
|
@@ -42,7 +43,7 @@ const ROLE = {
|
|
|
42
43
|
FRAME: "border",
|
|
43
44
|
TITLE: "customMessageLabel",
|
|
44
45
|
SELECTED: "accent",
|
|
45
|
-
HOVER:
|
|
46
|
+
HOVER: HOVER_ROLE,
|
|
46
47
|
NAME: "text",
|
|
47
48
|
NAME_IDLE: "muted",
|
|
48
49
|
META: "dim",
|
|
@@ -69,7 +70,7 @@ const GLYPH_ROLE: Record<string, string> = {
|
|
|
69
70
|
[TASK_STATUS.CANCELLED]: "dim",
|
|
70
71
|
[TASK_STATUS.TIMED_OUT]: "error",
|
|
71
72
|
};
|
|
72
|
-
|
|
73
|
+
const MAX_FINISHED_PER_SESSION = 200;
|
|
73
74
|
const SCOPE_LABEL: Record<ViewScope, string> = { [VIEW_SCOPE.SESSION]: "this session", [VIEW_SCOPE.ALL]: "all sessions" };
|
|
74
75
|
const SCOPE_KEY: Record<ViewScope, string> = { [VIEW_SCOPE.SESSION]: "all sessions", [VIEW_SCOPE.ALL]: "this session" };
|
|
75
76
|
const EMPTY_LIST = "no tasks yet";
|
|
@@ -428,7 +429,8 @@ export class AgentsView {
|
|
|
428
429
|
|
|
429
430
|
private counts(): string {
|
|
430
431
|
const active = this.tasks.filter((task) => !isFinished(task.status)).length;
|
|
431
|
-
|
|
432
|
+
const finished = this.tasks.length - active;
|
|
433
|
+
return `${active} active · ${finished} finished`;
|
|
432
434
|
}
|
|
433
435
|
|
|
434
436
|
private actionableTask(): TaskRecord | undefined {
|
|
@@ -470,8 +472,12 @@ export class AgentsView {
|
|
|
470
472
|
}
|
|
471
473
|
|
|
472
474
|
private inScope(task: TaskRecord, _now: number): boolean {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
+
// A local task (this session's own, active or finished history) always
|
|
476
|
+
// belongs; refreshTasks() is the single authority on which finished ones
|
|
477
|
+
// survive its own retention cap. A remote (presence) task never keeps
|
|
478
|
+
// history -- it drops out of scope the moment it finishes.
|
|
479
|
+
if (task.parentSessionId === this.deps.sessionId) return true;
|
|
480
|
+
return !isFinished(task.status) && this.scope === VIEW_SCOPE.ALL && this.remoteThreads.has(task.id);
|
|
475
481
|
}
|
|
476
482
|
|
|
477
483
|
private directoryRoot(): boolean {
|
|
@@ -534,8 +540,19 @@ export class AgentsView {
|
|
|
534
540
|
}
|
|
535
541
|
|
|
536
542
|
private refreshTasks(): void {
|
|
537
|
-
|
|
538
|
-
|
|
543
|
+
// Finished history bypasses isLocalTask: that dep exists to keep an
|
|
544
|
+
// unrelated task resolveTask pulled in on demand (e.g. `subagent_result`
|
|
545
|
+
// for an id from another session) out of the live panel, never to hide
|
|
546
|
+
// this session's own finished subagents -- those belong here whether
|
|
547
|
+
// they were restored on resume or just finished live.
|
|
548
|
+
const local = this.deps.store.list().filter((task) => task.parentSessionId === this.deps.sessionId
|
|
549
|
+
&& (isFinished(task.status) || (this.deps.isLocalTask?.(task) ?? true)));
|
|
550
|
+
const active = local.filter((task) => !isFinished(task.status));
|
|
551
|
+
const finished = local
|
|
552
|
+
.filter((task) => isFinished(task.status))
|
|
553
|
+
.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
|
|
554
|
+
.slice(0, MAX_FINISHED_PER_SESSION);
|
|
555
|
+
this.tasks = [...active, ...finished];
|
|
539
556
|
if (this.scope === VIEW_SCOPE.ALL) this.tasks.push(...this.peers.flatMap((group) => group.tasks));
|
|
540
557
|
const groups = this.sessionGroups();
|
|
541
558
|
const present = new Set(groups.map((group) => group.id));
|
|
@@ -564,7 +581,15 @@ export class AgentsView {
|
|
|
564
581
|
group.tasks.push(task);
|
|
565
582
|
}
|
|
566
583
|
for (const group of groups.values()) {
|
|
567
|
-
|
|
584
|
+
// Active tasks first (newest created), then finished ones as history
|
|
585
|
+
// (newest ended first) -- a task never reorders within its own group
|
|
586
|
+
// just because it finished.
|
|
587
|
+
group.tasks.sort((a, b) => {
|
|
588
|
+
const finishedRank = Number(isFinished(a.status)) - Number(isFinished(b.status));
|
|
589
|
+
if (finishedRank !== 0) return finishedRank;
|
|
590
|
+
if (isFinished(a.status)) return (b.endedAt ?? 0) - (a.endedAt ?? 0);
|
|
591
|
+
return b.createdAt - a.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
592
|
+
});
|
|
568
593
|
}
|
|
569
594
|
if (this.scope === VIEW_SCOPE.ALL) {
|
|
570
595
|
const localId = `session:${this.deps.sessionId}`;
|
|
@@ -637,16 +662,18 @@ export class AgentsView {
|
|
|
637
662
|
const theme = this.deps.theme;
|
|
638
663
|
this.taskRegion(row).render(this.pointerLayout?.listWidth ?? 1);
|
|
639
664
|
const selected = entry.id === this.selectedRow()?.id;
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
const
|
|
665
|
+
// Selection always outranks hover; the shared hover painter only
|
|
666
|
+
// applies once selection is ruled out.
|
|
667
|
+
const hovered = !selected && entry.id === this.hoveredId;
|
|
668
|
+
const markerRole = selected ? ROLE.SELECTED : hovered ? ROLE.HOVER : undefined;
|
|
669
|
+
const marker = markerRole ? theme.fg(markerRole, selected ? "▸" : "▹") : " ";
|
|
643
670
|
if (entry.kind === "heading") {
|
|
644
671
|
const state = this.isExpanded(entry.group) ? "▾" : "▸";
|
|
645
|
-
return `${marker}${theme
|
|
672
|
+
return `${marker}${paintHoverable(theme, state, hovered, ROLE.SELECTED)} ${paintHoverable(theme, this.groupHeading(entry.group), hovered, selected ? ROLE.SELECTED : ROLE.NAME_IDLE)}`;
|
|
646
673
|
}
|
|
647
674
|
const task = entry.task;
|
|
648
675
|
const glyph = theme.fg(GLYPH_ROLE[task.status] ?? ROLE.META, GLYPH[task.status] ?? "?");
|
|
649
|
-
const name = theme
|
|
676
|
+
const name = paintHoverable(theme, `Subagent ${task.agent}`, hovered, selected ? ROLE.SELECTED : ROLE.NAME_IDLE);
|
|
650
677
|
const time = task.startedAt === null ? "" : theme.fg(ROLE.META, formatElapsed((task.endedAt ?? this.deps.now()) - task.startedAt));
|
|
651
678
|
return `${marker} ${theme.fg(ROLE.META, "└")} ${glyph} ${name} ${time}`;
|
|
652
679
|
}
|
|
@@ -756,7 +783,7 @@ export class AgentsView {
|
|
|
756
783
|
actions.push({ x: 2 + visibleWidth(text), width: label.length, region: control.region });
|
|
757
784
|
control.region.render(label.length);
|
|
758
785
|
const hovered = (control.region === this.followRegion && this.hoveredControl === "follow") || (control.region === this.openRegion && this.hoveredControl === "open");
|
|
759
|
-
text +=
|
|
786
|
+
text += control.enabled ? paintHoverable(this.deps.theme, label, hovered, ROLE.KEY) : this.deps.theme.fg(ROLE.META, label);
|
|
760
787
|
}
|
|
761
788
|
if (required > width) {
|
|
762
789
|
const more = this.actionRegion("more", () => {
|
package/lib/agents-widget.ts
CHANGED
|
@@ -29,7 +29,28 @@ interface Columns {
|
|
|
29
29
|
name: number;
|
|
30
30
|
task: number;
|
|
31
31
|
meta: number;
|
|
32
|
-
|
|
32
|
+
// Columnar rows give each surviving metadata field its own fixed,
|
|
33
|
+
// right-aligned column sized to the widest value among shown tasks, so
|
|
34
|
+
// model·effort, tokens, cost, and elapsed line up vertically across rows.
|
|
35
|
+
// A narrow card may have dropped some of those columns and still be
|
|
36
|
+
// columnar; false means the clipped single-string fallback.
|
|
37
|
+
columnar: boolean;
|
|
38
|
+
// Populated only when columnar is true.
|
|
39
|
+
metaWidths?: MetaColumnWidths;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface MetaFields {
|
|
43
|
+
exec: string;
|
|
44
|
+
tokens: string;
|
|
45
|
+
cost: string;
|
|
46
|
+
elapsed: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface MetaColumnWidths {
|
|
50
|
+
exec: number;
|
|
51
|
+
tokens: number;
|
|
52
|
+
cost: number;
|
|
53
|
+
elapsed: number;
|
|
33
54
|
}
|
|
34
55
|
|
|
35
56
|
const LOOK: Record<TaskStatus, StatusLook> = {
|
|
@@ -150,11 +171,48 @@ function executionLabel(task: TaskRecord, width = Infinity): string {
|
|
|
150
171
|
return clip(model, width - visibleWidth(suffix)) + suffix;
|
|
151
172
|
}
|
|
152
173
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
174
|
+
// Narrow (non-columnar) rows keep the single-string contract: a queued
|
|
175
|
+
// task shows the bare word, everything else shows its exec label.
|
|
176
|
+
function narrowMetaText(task: TaskRecord): string {
|
|
177
|
+
return task.status === TASK_STATUS.QUEUED ? "queued" : executionLabel(task);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Queued rows carry no model, token, or cost data yet, so every field but
|
|
181
|
+
// elapsed stays blank — and elapsed itself becomes the literal word "queued"
|
|
182
|
+
// rather than the empty string startedAt === null would otherwise produce.
|
|
183
|
+
function metaFields(task: TaskRecord, now: number): MetaFields {
|
|
184
|
+
if (task.status === TASK_STATUS.QUEUED) return { exec: "", tokens: "", cost: "", elapsed: "queued" };
|
|
185
|
+
return {
|
|
186
|
+
exec: executionLabel(task),
|
|
187
|
+
tokens: task.tokens > 0 ? formatTokens(task.tokens) : "",
|
|
188
|
+
cost: task.cost > 0 ? `$${task.cost.toFixed(2)}` : "",
|
|
189
|
+
elapsed: elapsed(task, now),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function metaColumnWidths(tasks: readonly TaskRecord[], now: number): MetaColumnWidths {
|
|
194
|
+
const fields = tasks.map((task) => metaFields(task, now));
|
|
195
|
+
const widest = (pick: (field: MetaFields) => string) => Math.max(0, ...fields.map((field) => visibleWidth(pick(field))));
|
|
196
|
+
return { exec: widest((field) => field.exec), tokens: widest((field) => field.tokens), cost: widest((field) => field.cost), elapsed: widest((field) => field.elapsed) };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// A column whose widest value is empty across every shown task (e.g. no task
|
|
200
|
+
// carries a cost yet) is dropped entirely, together with its separator —
|
|
201
|
+
// exactly like the old joined string dropped an empty field, but decided once
|
|
202
|
+
// for the whole card rather than per row, so the remaining columns still align.
|
|
203
|
+
function metaTotalWidth(widths: MetaColumnWidths): number {
|
|
204
|
+
const active = [widths.exec, widths.tokens, widths.cost, widths.elapsed].filter((width) => width > 0);
|
|
205
|
+
return active.reduce((sum, width) => sum + width, 0) + Math.max(0, active.length - 1) * visibleWidth(" · ");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function metaRowText(task: TaskRecord, now: number, widths: MetaColumnWidths): string {
|
|
209
|
+
const fields = metaFields(task, now);
|
|
210
|
+
const parts: string[] = [];
|
|
211
|
+
if (widths.exec > 0) parts.push(fields.exec.padStart(widths.exec));
|
|
212
|
+
if (widths.tokens > 0) parts.push(fields.tokens.padStart(widths.tokens));
|
|
213
|
+
if (widths.cost > 0) parts.push(fields.cost.padStart(widths.cost));
|
|
214
|
+
if (widths.elapsed > 0) parts.push(fields.elapsed.padStart(widths.elapsed));
|
|
215
|
+
return parts.join(" · ");
|
|
158
216
|
}
|
|
159
217
|
|
|
160
218
|
function taskText(task: TaskRecord): string {
|
|
@@ -163,27 +221,40 @@ function taskText(task: TaskRecord): string {
|
|
|
163
221
|
return task.label;
|
|
164
222
|
}
|
|
165
223
|
|
|
166
|
-
// Narrow cards
|
|
224
|
+
// Narrow cards degrade per column, decided once for the whole card so the
|
|
225
|
+
// surviving columns keep lining up: the task text goes first, then the
|
|
226
|
+
// model·effort label, then tokens, then cost. Elapsed is the one value the
|
|
227
|
+
// reader cannot rebuild from anything else on screen, so it is the last to go
|
|
228
|
+
// (gentle-shell#1143). Only when even elapsed alone does not fit does the row
|
|
229
|
+
// fall back to the clipped single-string label.
|
|
167
230
|
function columns(tasks: readonly TaskRecord[], inner: number, now: number): Columns {
|
|
168
231
|
const name = Math.max(0, Math.min(NAME_MAX, inner - 3, Math.max(...tasks.map((task) => visibleWidth(task.agent)))));
|
|
169
232
|
const fixed = 1 + GLYPH_GAP.length + name + COLUMN_GAP.length;
|
|
170
|
-
const
|
|
171
|
-
const full =
|
|
233
|
+
const metaWidths = metaColumnWidths(tasks, now);
|
|
234
|
+
const full = metaTotalWidth(metaWidths);
|
|
172
235
|
const task = inner - fixed - full - COLUMN_GAP.length;
|
|
173
|
-
if (task >= TASK_MIN) return { inner, name, meta: full, task,
|
|
174
|
-
|
|
236
|
+
if (task >= TASK_MIN) return { inner, name, meta: full, task, columnar: true, metaWidths };
|
|
237
|
+
let widths = metaWidths;
|
|
238
|
+
for (const drop of ["exec", "tokens", "cost"] as const) {
|
|
239
|
+
if (metaTotalWidth(widths) <= inner - fixed) break;
|
|
240
|
+
widths = { ...widths, [drop]: 0 };
|
|
241
|
+
}
|
|
242
|
+
const meta = metaTotalWidth(widths);
|
|
243
|
+
if (meta > 0 && meta <= inner - fixed) return { inner, name, meta, task: 0, columnar: true, metaWidths: widths };
|
|
244
|
+
const narrow = Math.max(0, ...tasks.map((task) => visibleWidth(narrowMetaText(task))));
|
|
245
|
+
return { inner, name, meta: Math.max(0, Math.min(inner - fixed, narrow)), task: 0, columnar: false };
|
|
175
246
|
}
|
|
176
247
|
|
|
177
248
|
function row(task: TaskRecord, theme: CardTheme, cols: Columns, now: number, allowMetadataRow: boolean): string[] {
|
|
178
249
|
const look = LOOK[task.status];
|
|
179
250
|
const name = clip(task.agent, cols.name);
|
|
180
251
|
const head = `${theme.fg(look.role, look.glyph)}${GLYPH_GAP}${theme.fg(NAME_ROLE, name)}${" ".repeat(cols.name - visibleWidth(name))}`;
|
|
181
|
-
const metadata = cols.
|
|
252
|
+
const metadata = cols.columnar && cols.metaWidths ? metaRowText(task, now, cols.metaWidths) : task.status === TASK_STATUS.QUEUED ? clip("queued", cols.meta) : executionLabel(task, cols.meta);
|
|
182
253
|
const tail = theme.fg(META_ROLE, " ".repeat(Math.max(0, cols.meta - visibleWidth(metadata))) + metadata);
|
|
183
254
|
if (cols.inner < 3) return [theme.fg(look.role, clip(look.glyph, cols.inner))];
|
|
184
255
|
// The scrollable sidebar can preserve identity and execution metadata on
|
|
185
256
|
// separate rows. The height-capped above-editor widget keeps its row budget.
|
|
186
|
-
if (allowMetadataRow && cols.task === 0 && task.status !== TASK_STATUS.QUEUED && visibleWidth(executionLabel(task)) > cols.meta) {
|
|
257
|
+
if (allowMetadataRow && !cols.columnar && cols.task === 0 && task.status !== TASK_STATUS.QUEUED && visibleWidth(executionLabel(task)) > cols.meta) {
|
|
187
258
|
return [head, theme.fg(META_ROLE, executionLabel(task, cols.inner))];
|
|
188
259
|
}
|
|
189
260
|
if (cols.task === 0) return [`${head}${" ".repeat(Math.max(0, cols.inner - visibleWidth(head) - visibleWidth(tail)))}${tail}`];
|
|
@@ -25,6 +25,7 @@ export const COMMAND_PALETTE_CATALOG: readonly CommandPaletteCatalogGroup[] = [
|
|
|
25
25
|
{ command: "gentle:persona", label: "Switch persona" },
|
|
26
26
|
{ command: "gentle:review-mode", label: "Review mode (receipt-driven development)" },
|
|
27
27
|
{ command: "gentle:background-subagents", label: "Background subagents" },
|
|
28
|
+
{ command: "gentle:double-esc-cancel", label: "Require double Esc to cancel" },
|
|
28
29
|
{ command: "gentle:telemetry", label: "Telemetry" },
|
|
29
30
|
{ command: "gentle:banner", label: "Startup banner" },
|
|
30
31
|
{ command: "gentle:banner-color", label: "Banner color" },
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { gentlePiConfigHome } from "./agent-home.ts";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Double-esc-cancel policy — global file > env > default off (issue #1163)
|
|
7
|
+
//
|
|
8
|
+
// Deliberately global-only (no project-file layer, unlike
|
|
9
|
+
// background-subagents): this preference changes what a keypress does while
|
|
10
|
+
// the agent is working, and a per-project override for a personal habit like
|
|
11
|
+
// this would make the same key do two different things depending on which
|
|
12
|
+
// repo happens to be open. Otherwise mirrors the resolution shape of
|
|
13
|
+
// lib/background-subagents-policy.ts, minus that project-file layer.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export type DoubleEscCancelPolicy = "on" | "off";
|
|
17
|
+
|
|
18
|
+
/** Which of the three sources decided the effective policy. */
|
|
19
|
+
export type DoubleEscCancelSource = "global_file" | "environment" | "default";
|
|
20
|
+
|
|
21
|
+
export interface DoubleEscCancelResolution {
|
|
22
|
+
policy: DoubleEscCancelPolicy;
|
|
23
|
+
source: DoubleEscCancelSource;
|
|
24
|
+
/** The deciding file was present but failed the strict decode. */
|
|
25
|
+
malformed: boolean;
|
|
26
|
+
globalFile: string;
|
|
27
|
+
globalFileExists: boolean;
|
|
28
|
+
/** The raw env value, reported even when it is unrecognized and inert. */
|
|
29
|
+
envValue: string | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface LoadDoubleEscCancelOptions {
|
|
33
|
+
/** Override the config home directory (used in tests to avoid touching ~/.pi). */
|
|
34
|
+
gentlePiConfigHome?: string;
|
|
35
|
+
/** Override the environment lookup (used in tests). */
|
|
36
|
+
env?: Record<string, string | undefined>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const DOUBLE_ESC_CANCEL_SCHEMA = "gentle-pi.double-esc-cancel/v1";
|
|
40
|
+
export const DOUBLE_ESC_CANCEL_FILE = "double-esc-cancel.json";
|
|
41
|
+
|
|
42
|
+
// Pi's own idle double-Esc (empty editor -> /tree or /fork, interactive-mode.js
|
|
43
|
+
// `lastEscapeTime`) uses a 500ms window. Canceling a running turn throws away
|
|
44
|
+
// in-flight work and is harder to undo than switching prompts, so this
|
|
45
|
+
// confirmation deliberately gets double that time to land the second press.
|
|
46
|
+
export const DOUBLE_ESC_CANCEL_WINDOW_MS = 1000;
|
|
47
|
+
|
|
48
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
49
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Strict decode of {"schema":"gentle-pi.double-esc-cancel/v1","policy":"on"|"off"}.
|
|
54
|
+
* Any malformed shape (bad JSON, wrong schema, unknown keys, invalid policy)
|
|
55
|
+
* returns undefined so the caller fails closed to "off".
|
|
56
|
+
*/
|
|
57
|
+
export function parseDoubleEscCancelPolicyFile(raw: string): DoubleEscCancelPolicy | undefined {
|
|
58
|
+
let parsed: unknown;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(raw);
|
|
61
|
+
} catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
if (!isRecord(parsed)) return undefined;
|
|
65
|
+
if (parsed.schema !== DOUBLE_ESC_CANCEL_SCHEMA) return undefined;
|
|
66
|
+
if (parsed.policy !== "on" && parsed.policy !== "off") return undefined;
|
|
67
|
+
if (Object.keys(parsed).length !== 2) return undefined;
|
|
68
|
+
return parsed.policy;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the double-esc-cancel policy AND the source that decided it.
|
|
73
|
+
*
|
|
74
|
+
* Resolution order (first hit wins):
|
|
75
|
+
* 1. Global file `${configHome}/double-esc-cancel.json`
|
|
76
|
+
* (configHome honors GENTLE_PI_CONFIG_HOME, default ~/.pi/gentle-ai)
|
|
77
|
+
* 2. Env var GENTLE_PI_DOUBLE_ESC_CANCEL ("on" | "off")
|
|
78
|
+
* 3. Default "off"
|
|
79
|
+
*
|
|
80
|
+
* A present-but-malformed file fails closed to "off" instead of falling
|
|
81
|
+
* through to the env var, and stays attributed to that file: "off decided
|
|
82
|
+
* by a broken global file" and "off by default" are different situations,
|
|
83
|
+
* and only the first one is a mistake to fix.
|
|
84
|
+
*/
|
|
85
|
+
export function resolveDoubleEscCancelPolicy(
|
|
86
|
+
options: LoadDoubleEscCancelOptions = {},
|
|
87
|
+
): DoubleEscCancelResolution {
|
|
88
|
+
const env = options.env ?? process.env;
|
|
89
|
+
const envValue = env.GENTLE_PI_DOUBLE_ESC_CANCEL;
|
|
90
|
+
let globalFile = "";
|
|
91
|
+
try {
|
|
92
|
+
const configHome = options.gentlePiConfigHome ?? gentlePiConfigHome(env);
|
|
93
|
+
globalFile = join(configHome, DOUBLE_ESC_CANCEL_FILE);
|
|
94
|
+
const globalFileExists = existsSync(globalFile);
|
|
95
|
+
if (globalFileExists) {
|
|
96
|
+
let decoded: DoubleEscCancelPolicy | undefined;
|
|
97
|
+
try {
|
|
98
|
+
decoded = parseDoubleEscCancelPolicyFile(readFileSync(globalFile, "utf8"));
|
|
99
|
+
} catch {
|
|
100
|
+
// Unreadable is indistinguishable from unusable at this layer, and
|
|
101
|
+
// both must fail closed on the file that claimed the decision.
|
|
102
|
+
decoded = undefined;
|
|
103
|
+
}
|
|
104
|
+
return decoded === undefined
|
|
105
|
+
? { policy: "off", source: "global_file", malformed: true, globalFile, globalFileExists, envValue }
|
|
106
|
+
: { policy: decoded, source: "global_file", malformed: false, globalFile, globalFileExists, envValue };
|
|
107
|
+
}
|
|
108
|
+
if (envValue === "on" || envValue === "off") {
|
|
109
|
+
return { policy: envValue, source: "environment", malformed: false, globalFile, globalFileExists, envValue };
|
|
110
|
+
}
|
|
111
|
+
return { policy: "off", source: "default", malformed: false, globalFile, globalFileExists, envValue };
|
|
112
|
+
} catch {
|
|
113
|
+
return { policy: "off", source: "default", malformed: false, globalFile, globalFileExists: false, envValue };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The effective policy alone, for callers that do not report a source.
|
|
119
|
+
* It delegates so the loader and the resolver can never disagree.
|
|
120
|
+
*/
|
|
121
|
+
export function loadDoubleEscCancelPolicy(options: LoadDoubleEscCancelOptions = {}): DoubleEscCancelPolicy {
|
|
122
|
+
return resolveDoubleEscCancelPolicy(options).policy;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Write the global policy file, creating the config home when needed.
|
|
127
|
+
* Used by both `/gentle:double-esc-cancel enable` and `... disable`.
|
|
128
|
+
*/
|
|
129
|
+
export function writeDoubleEscCancelPolicy(
|
|
130
|
+
policy: DoubleEscCancelPolicy,
|
|
131
|
+
options: { gentlePiConfigHome?: string } = {},
|
|
132
|
+
): string {
|
|
133
|
+
const configHome = options.gentlePiConfigHome ?? gentlePiConfigHome();
|
|
134
|
+
const path = join(configHome, DOUBLE_ESC_CANCEL_FILE);
|
|
135
|
+
mkdirSync(configHome, { recursive: true });
|
|
136
|
+
writeFileSync(path, `${JSON.stringify({ schema: DOUBLE_ESC_CANCEL_SCHEMA, policy }, null, 2)}\n`);
|
|
137
|
+
return path;
|
|
138
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// In-process reviewer completion (gentle-ai#4611; gentle-pi#311 P1).
|
|
2
|
+
//
|
|
3
|
+
// The relay child this module replaced ran a locked-down `pi --print` process
|
|
4
|
+
// with extension discovery disabled, which dropped extension-registered
|
|
5
|
+
// providers ("Model not found") and stripped env-provided API keys; its env
|
|
6
|
+
// allowlist required per-provider manual configuration and Go roles had no
|
|
7
|
+
// parity with it. This module is that transport's replacement for a single
|
|
8
|
+
// reviewer completion: it resolves
|
|
9
|
+
// the caller's "provider/id" selection through pi's live model registry,
|
|
10
|
+
// authenticates through the registry's own resolver, and completes exactly
|
|
11
|
+
// one frozen prompt as a single user message — no systemPrompt, no tools, no
|
|
12
|
+
// session, no extension hooks.
|
|
13
|
+
//
|
|
14
|
+
// Every I/O seam is injected (`registry`, `complete`, `now`), so this module
|
|
15
|
+
// runs under tests with no network and no pi process. gentle-pi#311 P2 wires
|
|
16
|
+
// this into the lens relay (lib/review-host-relay.ts); P3 wires the provider
|
|
17
|
+
// role vectors. This file stays a pure completion, never invoked from here.
|
|
18
|
+
|
|
19
|
+
import type { Api, AssistantMessage, Context, Model, ProviderHeaders, SimpleStreamOptions, TextContent, ThinkingLevel } from "@earendil-works/pi-ai";
|
|
20
|
+
import type { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
21
|
+
import { SAFE_MODEL_ID_PATTERN } from "./model-routing-authority.ts";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Registry seam — a structural subset of pi's live ModelRegistry
|
|
25
|
+
// (@earendil-works/pi-coding-agent core/model-registry.ts). Only `find` and
|
|
26
|
+
// `getApiKeyAndHeaders` are needed here; the real registry's resolved auth
|
|
27
|
+
// carries extra optional fields (`baseUrl`, `env`) that this narrower shape
|
|
28
|
+
// simply ignores.
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
export interface InProcessReviewerRegistry {
|
|
32
|
+
find(provider: string, modelId: string): Model<Api> | undefined;
|
|
33
|
+
getApiKeyAndHeaders(model: Model<Api>): Promise<
|
|
34
|
+
| { readonly ok: true; readonly apiKey?: string; readonly headers?: ProviderHeaders }
|
|
35
|
+
| { readonly ok: false; readonly error: string }
|
|
36
|
+
>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const INPROCESS_REVIEWER_FAILURE = {
|
|
40
|
+
SELECTION_INVALID: "selection-invalid",
|
|
41
|
+
MODEL_NOT_FOUND: "model-not-found",
|
|
42
|
+
AUTH_UNAVAILABLE: "auth-unavailable",
|
|
43
|
+
THINKING_INVALID: "thinking-invalid",
|
|
44
|
+
TOOL_CALL_ATTEMPTED: "tool-call-attempted",
|
|
45
|
+
EMPTY_OUTPUT: "empty-output",
|
|
46
|
+
OUTPUT_TOO_LARGE: "output-too-large",
|
|
47
|
+
TIMED_OUT: "timed-out",
|
|
48
|
+
ABORTED: "aborted",
|
|
49
|
+
PROVIDER_FAILED: "provider-failed",
|
|
50
|
+
} as const;
|
|
51
|
+
export type InProcessReviewerFailureCode = (typeof INPROCESS_REVIEWER_FAILURE)[keyof typeof INPROCESS_REVIEWER_FAILURE];
|
|
52
|
+
|
|
53
|
+
export interface InProcessReviewerRequest {
|
|
54
|
+
/** "provider/id" from models.json routing; already validated by SAFE_MODEL_ID_PATTERN upstream, re-validated here. */
|
|
55
|
+
readonly selection: string;
|
|
56
|
+
/** Routing thinking label: off | minimal | low | medium | high | xhigh | max. Omitted is treated as "off". */
|
|
57
|
+
readonly thinking?: string;
|
|
58
|
+
/** Frozen Go-materialized prompt bytes, submitted verbatim as the one user message. */
|
|
59
|
+
readonly prompt: Buffer;
|
|
60
|
+
readonly timeoutMs: number;
|
|
61
|
+
readonly signal?: AbortSignal;
|
|
62
|
+
/** e.g. "review-risk" — only used to name the routing config key in refusal messages. */
|
|
63
|
+
readonly routingKey: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type InProcessReviewerOutcome =
|
|
67
|
+
| { readonly kind: "text"; readonly text: string; readonly reviewerModel: string }
|
|
68
|
+
| { readonly kind: "refused"; readonly code: InProcessReviewerFailureCode; readonly message: string; readonly evidence?: Record<string, unknown> };
|
|
69
|
+
|
|
70
|
+
export interface InProcessReviewerDeps {
|
|
71
|
+
readonly registry: InProcessReviewerRegistry;
|
|
72
|
+
readonly complete: typeof completeSimple;
|
|
73
|
+
/** Test seam for the single user message's timestamp; defaults to Date.now. */
|
|
74
|
+
readonly now?: () => number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// No existing bound covers the reviewer's completion text: the child this
|
|
78
|
+
// module replaced returned its extracted text unbounded. 4 MiB matches this
|
|
79
|
+
// repo's established convention for this class of bound (lib/session-changes.ts
|
|
80
|
+
// MAX_SESSION_BYTES, lib/provider-contract-bundle.ts MAX_FILE_BYTES).
|
|
81
|
+
export const INPROCESS_REVIEWER_OUTPUT_MAX_BYTES = 4 * 1024 * 1024;
|
|
82
|
+
|
|
83
|
+
// pi-ai's `SimpleStreamOptions.reasoning` accepts every routing label except
|
|
84
|
+
// "off" (`ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh" |
|
|
85
|
+
// "max"`), and pi-ai itself clamps a level to what the selected model
|
|
86
|
+
// supports through its `thinkingLevelMap`. The label is therefore forwarded
|
|
87
|
+
// verbatim: the routing config owns the choice and the library owns the
|
|
88
|
+
// per-provider mapping, so this module never second-guesses either.
|
|
89
|
+
const THINKING_LABELS = new Set<ThinkingLevel>(["minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
90
|
+
const ERROR_EXCERPT_MAX_CHARS = 512;
|
|
91
|
+
|
|
92
|
+
function parseSelection(selection: string): { provider: string; modelId: string } | undefined {
|
|
93
|
+
if (typeof selection !== "string" || selection.length === 0 || !SAFE_MODEL_ID_PATTERN.test(selection)) return undefined;
|
|
94
|
+
const separatorIndex = selection.indexOf("/");
|
|
95
|
+
if (separatorIndex <= 0 || separatorIndex === selection.length - 1) return undefined;
|
|
96
|
+
return { provider: selection.slice(0, separatorIndex), modelId: selection.slice(separatorIndex + 1) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type ReasoningResolution = { readonly ok: true; readonly reasoning?: ThinkingLevel } | { readonly ok: false };
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* "off" (or an omitted label) omits `reasoning` entirely; every other pi-ai
|
|
103
|
+
* level is forwarded verbatim; an unrecognized label is a typed refusal. A model with
|
|
104
|
+
* `reasoning === false` never receives the field, regardless of the label —
|
|
105
|
+
* checked last so an unknown label is still refused even for a non-reasoning
|
|
106
|
+
* model, instead of silently passing validation because it would be dropped
|
|
107
|
+
* anyway.
|
|
108
|
+
*/
|
|
109
|
+
function resolveReasoning(thinking: string | undefined, model: Model<Api>): ReasoningResolution {
|
|
110
|
+
if (thinking === undefined || thinking === "off") return { ok: true };
|
|
111
|
+
if (!THINKING_LABELS.has(thinking as ThinkingLevel)) return { ok: false };
|
|
112
|
+
return model.reasoning === false ? { ok: true } : { ok: true, reasoning: thinking as ThinkingLevel };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function sanitizeErrorExcerpt(error: unknown): string {
|
|
116
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
117
|
+
const collapsed = raw.replace(/\s+/g, " ").trim();
|
|
118
|
+
return collapsed.length <= ERROR_EXCERPT_MAX_CHARS ? collapsed : `${collapsed.slice(0, ERROR_EXCERPT_MAX_CHARS - 1)}…`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function refuse(code: InProcessReviewerFailureCode, message: string, evidence?: Record<string, unknown>): InProcessReviewerOutcome {
|
|
122
|
+
return { kind: "refused", code, message, ...(evidence === undefined ? {} : { evidence }) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isTextContent(part: { type?: unknown }): part is TextContent {
|
|
126
|
+
return part.type === "text";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Runs one reviewer completion in-process: resolve the model, authenticate,
|
|
131
|
+
* map the routing thinking label, and complete exactly one frozen prompt as
|
|
132
|
+
* a single user message. Never retries, never falls back to another model,
|
|
133
|
+
* never reads process.env — every seam is injected through `deps`.
|
|
134
|
+
*/
|
|
135
|
+
export async function runInProcessReviewer(request: InProcessReviewerRequest, deps: InProcessReviewerDeps): Promise<InProcessReviewerOutcome> {
|
|
136
|
+
const parsed = parseSelection(request.selection);
|
|
137
|
+
if (parsed === undefined) {
|
|
138
|
+
return refuse(
|
|
139
|
+
INPROCESS_REVIEWER_FAILURE.SELECTION_INVALID,
|
|
140
|
+
`Invalid model selection ${JSON.stringify(request.selection)} for ${request.routingKey}; expected the "provider/id" shape.`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const model = deps.registry.find(parsed.provider, parsed.modelId);
|
|
145
|
+
if (model === undefined) {
|
|
146
|
+
return refuse(
|
|
147
|
+
INPROCESS_REVIEWER_FAILURE.MODEL_NOT_FOUND,
|
|
148
|
+
`No model matches selection ${JSON.stringify(request.selection)} configured for ${request.routingKey}; assign ${request.routingKey} a model that the interactive pi's model list actually shows.`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const auth = await deps.registry.getApiKeyAndHeaders(model);
|
|
153
|
+
// Negation narrowing (`!auth.ok`) does not eliminate the `ok: true` arm of
|
|
154
|
+
// this discriminated union under this project's `strict: false` tsconfig;
|
|
155
|
+
// an explicit `=== false` comparison narrows correctly in both directions.
|
|
156
|
+
if (auth.ok === false) {
|
|
157
|
+
return refuse(
|
|
158
|
+
INPROCESS_REVIEWER_FAILURE.AUTH_UNAVAILABLE,
|
|
159
|
+
`No credentials available for provider ${JSON.stringify(parsed.provider)} (used by ${request.routingKey}): ${auth.error}`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const reasoning = resolveReasoning(request.thinking, model);
|
|
164
|
+
if (reasoning.ok === false) {
|
|
165
|
+
return refuse(
|
|
166
|
+
INPROCESS_REVIEWER_FAILURE.THINKING_INVALID,
|
|
167
|
+
`Unknown thinking level ${JSON.stringify(request.thinking)} for ${request.routingKey}; use one of off, minimal, low, medium, high, xhigh, max.`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// The caller's own signal (if any) and a floor timeout race together:
|
|
172
|
+
// whichever fires first aborts the completion. The catch branch below
|
|
173
|
+
// tells them apart by which underlying signal actually fired, never by
|
|
174
|
+
// inspecting the thrown error's shape, which providers are free to vary.
|
|
175
|
+
const timeoutSignal = AbortSignal.timeout(request.timeoutMs);
|
|
176
|
+
const combinedSignal = request.signal === undefined ? timeoutSignal : AbortSignal.any([request.signal, timeoutSignal]);
|
|
177
|
+
|
|
178
|
+
const context: Context = {
|
|
179
|
+
messages: [
|
|
180
|
+
{
|
|
181
|
+
role: "user",
|
|
182
|
+
content: [{ type: "text", text: request.prompt.toString("utf8") }],
|
|
183
|
+
timestamp: (deps.now ?? Date.now)(),
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
};
|
|
187
|
+
const options: SimpleStreamOptions = {
|
|
188
|
+
signal: combinedSignal,
|
|
189
|
+
timeoutMs: request.timeoutMs,
|
|
190
|
+
...(auth.apiKey === undefined ? {} : { apiKey: auth.apiKey }),
|
|
191
|
+
...(auth.headers === undefined ? {} : { headers: auth.headers }),
|
|
192
|
+
...(reasoning.reasoning === undefined ? {} : { reasoning: reasoning.reasoning }),
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// An abort is classified by which signal actually fired, never by the
|
|
196
|
+
// error's shape or the message's text, and the same classification serves
|
|
197
|
+
// both settlement paths: a provider may reject on abort, or — the pi-ai
|
|
198
|
+
// provider convention — resolve an AssistantMessage with `stopReason:
|
|
199
|
+
// "aborted"` carrying whatever text streamed before the cut. Either way a
|
|
200
|
+
// fired signal is a timeout or a caller abort, never empty or usable output.
|
|
201
|
+
const abortRefusal = (): InProcessReviewerOutcome | undefined => {
|
|
202
|
+
if (timeoutSignal.aborted) {
|
|
203
|
+
return refuse(INPROCESS_REVIEWER_FAILURE.TIMED_OUT, `Reviewer completion for ${request.routingKey} exceeded its ${request.timeoutMs}ms bound.`);
|
|
204
|
+
}
|
|
205
|
+
if (request.signal?.aborted === true) {
|
|
206
|
+
return refuse(INPROCESS_REVIEWER_FAILURE.ABORTED, `Reviewer completion for ${request.routingKey} was aborted by the caller.`);
|
|
207
|
+
}
|
|
208
|
+
return undefined;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
let assistant: AssistantMessage;
|
|
212
|
+
try {
|
|
213
|
+
assistant = await deps.complete(model, context, options);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
return abortRefusal() ?? refuse(INPROCESS_REVIEWER_FAILURE.PROVIDER_FAILED, `Reviewer completion failed for ${request.routingKey}: ${sanitizeErrorExcerpt(error)}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const resolvedAbort = abortRefusal();
|
|
219
|
+
if (resolvedAbort !== undefined) return resolvedAbort;
|
|
220
|
+
if (assistant.stopReason === "aborted") {
|
|
221
|
+
// No signal of ours fired, so the provider cut the completion on its
|
|
222
|
+
// own: that is a provider failure, and its partial text is not a review.
|
|
223
|
+
return refuse(
|
|
224
|
+
INPROCESS_REVIEWER_FAILURE.PROVIDER_FAILED,
|
|
225
|
+
`Reviewer completion failed for ${request.routingKey}: the provider reported an aborted completion (${assistant.errorMessage ?? "no provider message"}).`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (assistant.stopReason === "error") {
|
|
230
|
+
return refuse(
|
|
231
|
+
INPROCESS_REVIEWER_FAILURE.PROVIDER_FAILED,
|
|
232
|
+
`Reviewer completion failed for ${request.routingKey}: ${assistant.errorMessage ?? "unknown provider error"}`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
if (assistant.content.some((part) => part.type === "toolCall")) {
|
|
236
|
+
return refuse(
|
|
237
|
+
INPROCESS_REVIEWER_FAILURE.TOOL_CALL_ATTEMPTED,
|
|
238
|
+
`Reviewer attempted a tool call for ${request.routingKey}; the in-process reviewer completion must answer in text only.`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const text = assistant.content.filter(isTextContent).map((part) => part.text).join("");
|
|
243
|
+
if (text.length === 0) {
|
|
244
|
+
return refuse(
|
|
245
|
+
INPROCESS_REVIEWER_FAILURE.EMPTY_OUTPUT,
|
|
246
|
+
`Reviewer produced no text for ${request.routingKey} (stopReason: ${assistant.stopReason}).`,
|
|
247
|
+
{ stopReason: assistant.stopReason, ...(assistant.errorMessage === undefined ? {} : { errorMessage: assistant.errorMessage }) },
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const textBytes = Buffer.byteLength(text, "utf8");
|
|
252
|
+
if (textBytes > INPROCESS_REVIEWER_OUTPUT_MAX_BYTES) {
|
|
253
|
+
return refuse(
|
|
254
|
+
INPROCESS_REVIEWER_FAILURE.OUTPUT_TOO_LARGE,
|
|
255
|
+
`Reviewer output for ${request.routingKey} exceeds the ${INPROCESS_REVIEWER_OUTPUT_MAX_BYTES}-byte bound (received ${textBytes} bytes).`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return { kind: "text", text, reviewerModel: `${model.provider}/${model.id}` };
|
|
260
|
+
}
|