gentle-pi 3.2.1 → 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/docs/gentle-shell.md +27 -14
- package/docs/readme-reference.md +41 -7
- package/docs/review-integration.md +22 -17
- package/extensions/gentle-agents.ts +85 -17
- package/extensions/gentle-ai.ts +123 -21
- package/extensions/gentle-shell.ts +379 -31
- 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/native-review-cli.ts +14 -0
- package/lib/odd-runtime-delegation-gate.ts +88 -0
- package/lib/review-host-relay.ts +246 -171
- package/lib/review-integration-v2.ts +110 -26
- package/lib/shell-bar.ts +150 -75
- 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/package.json +1 -1
- package/runtime/native-review-cli.mjs +14 -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/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 +795 -23
- 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 +19 -1
- 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-host-relay-routing.test.ts +77 -0
- package/tests/review-host-relay.test.ts +277 -300
- 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 +22 -24
- 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 +192 -124
- 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 +120 -1
- package/lib/opaque-pi-reviewer-adapter.ts +0 -404
- package/tests/opaque-pi-reviewer-adapter.test.ts +0 -410
package/lib/review-host-relay.ts
CHANGED
|
@@ -7,14 +7,18 @@
|
|
|
7
7
|
//
|
|
8
8
|
// 1. Run the exact provider-issued capture binding with `--agent pi
|
|
9
9
|
// --materialize` and take stdout as opaque prompt BYTES, verbatim.
|
|
10
|
-
// 2.
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
10
|
+
// 2. Run that prompt through one in-process reviewer completion
|
|
11
|
+
// (lib/inprocess-reviewer.ts#runInProcessReviewer): resolve the lens's
|
|
12
|
+
// "provider/id" selection through the live model registry, authenticate
|
|
13
|
+
// through the registry's own resolver, and complete the frozen prompt as
|
|
14
|
+
// a single user message. There is no child process, no extension
|
|
15
|
+
// allowlist, and no ambient default model — a missing registry or a
|
|
16
|
+
// routing entry with no model is a typed refusal before materialize ever
|
|
17
|
+
// runs (gentle-ai#4611; gentle-pi#311 P2).
|
|
18
|
+
// 3. Submit the completion's text untouched through the provider-owned
|
|
19
|
+
// `submission` form carried by the collect input: execute its exact
|
|
20
|
+
// operation and argument tokens with only the tempfile path substituted
|
|
21
|
+
// into the declared {{value}} slot (BOM-less: the buffer is written
|
|
18
22
|
// byte-for-byte). The host never synthesizes or filters the completing
|
|
19
23
|
// form; a materialize slot without a provider submission is a typed
|
|
20
24
|
// contract mismatch, never a rebuilt invocation.
|
|
@@ -25,28 +29,22 @@
|
|
|
25
29
|
// never from transcript inference. The relay never parses or rebuilds
|
|
26
30
|
// binding, evidence, prompt, schema, budgets, or admission.
|
|
27
31
|
|
|
28
|
-
import { spawn } from "node:child_process";
|
|
29
32
|
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
33
|
+
import { spawn } from "node:child_process";
|
|
30
34
|
import { tmpdir } from "node:os";
|
|
31
35
|
import { isAbsolute, join } from "node:path";
|
|
36
|
+
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
32
37
|
import { resolveGentleAiBinary } from "./gentle-ai-binary.ts";
|
|
33
|
-
import { SAFE_MODEL_ID_PATTERN } from "./model-routing-authority.ts";
|
|
34
|
-
import { existsSync } from "node:fs";
|
|
35
|
-
import { delimiter as pathDelimiter } from "node:path";
|
|
36
38
|
import {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
type
|
|
41
|
-
type
|
|
42
|
-
} from "./
|
|
39
|
+
INPROCESS_REVIEWER_FAILURE,
|
|
40
|
+
runInProcessReviewer,
|
|
41
|
+
type InProcessReviewerFailureCode,
|
|
42
|
+
type InProcessReviewerOutcome,
|
|
43
|
+
type InProcessReviewerRegistry,
|
|
44
|
+
} from "./inprocess-reviewer.ts";
|
|
43
45
|
import { REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION, REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3 } from "./review-integration-v2.ts";
|
|
44
46
|
import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "./review-relay-contract.ts";
|
|
45
47
|
|
|
46
|
-
// Compatibility export for existing relay consumers. The pure adapter owns the
|
|
47
|
-
// fixed Pi process boundary and its locked-down argv.
|
|
48
|
-
export { OPAQUE_PI_REVIEWER_ARGV as REVIEW_HOST_RELAY_PI_ARGV } from "./opaque-pi-reviewer-adapter.ts";
|
|
49
|
-
|
|
50
48
|
export const REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE =
|
|
51
49
|
"provider relay requires a gentle-ai build with the pi host relay surface";
|
|
52
50
|
|
|
@@ -56,19 +54,40 @@ export const REVIEW_HOST_RELAY_FAILURE = {
|
|
|
56
54
|
SUBMISSION_CONTRACT_MISMATCH: "submission-contract-mismatch",
|
|
57
55
|
MATERIALIZE_FAILED: "materialize-failed",
|
|
58
56
|
EMPTY_PROMPT: "empty-prompt",
|
|
57
|
+
// gentle-pi#311 P2: these four names predate the in-process completion,
|
|
58
|
+
// when a killed or crashed child process was the only way a reviewer
|
|
59
|
+
// failed. They stay exactly as they are — including their "pi" wording —
|
|
60
|
+
// because other test lanes (the maintainer provider-relay matrix, the
|
|
61
|
+
// restart-parity harness) construct `ReviewHostRelayError` literals with
|
|
62
|
+
// them directly and are out of this change's scope (gentle-pi#311 P4).
|
|
63
|
+
// PI_TIMED_OUT and PI_FAILED are still reachable from production: a
|
|
64
|
+
// reviewer completion that exceeds its bound or fails for any reason not
|
|
65
|
+
// covered by a more specific REVIEWER_* code below reuses them, since the
|
|
66
|
+
// semantics (a deterministic bound; a generic failure) carried over
|
|
67
|
+
// unchanged. PI_LAUNCH_FAILED and PI_EMPTY_OUTPUT are no longer produced
|
|
68
|
+
// by this module — there is no child to fail to launch, and empty output
|
|
69
|
+
// is now REVIEWER_EMPTY_OUTPUT with different evidence — but the codes
|
|
70
|
+
// stay defined for the lanes above.
|
|
59
71
|
PI_LAUNCH_FAILED: "pi-launch-failed",
|
|
60
72
|
PI_FAILED: "pi-failed",
|
|
61
|
-
// gentle-pi#367: a reviewer killed by the relay bound is not a crash. It
|
|
62
|
-
// is the one failure class that a byte-identical relaunch cannot survive,
|
|
63
|
-
// so it carries its own kind, its own elapsed/limit evidence, and its own
|
|
64
|
-
// continuation instead of hiding inside `pi-failed`.
|
|
65
73
|
PI_TIMED_OUT: "pi-timed-out",
|
|
66
74
|
PI_EMPTY_OUTPUT: "pi-empty-output",
|
|
67
|
-
// gentle-shell#1158 / #1136: a caller-owned
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
// never a mid-review transport
|
|
75
|
+
// gentle-shell#1158 / #1136 (superseded by gentle-pi#311 P2): a caller-owned
|
|
76
|
+
// reviewer selection that cannot possibly complete — no model registry, or
|
|
77
|
+
// a routing entry with no configured model — is a configuration failure,
|
|
78
|
+
// refused typed before anything runs, never a mid-review transport
|
|
79
|
+
// mystery. The in-process path has no ambient default model to fall back
|
|
80
|
+
// to, so a missing selection is refused here rather than launched anyway.
|
|
71
81
|
REVIEWER_CONFIG_INVALID: "reviewer-config-invalid",
|
|
82
|
+
// gentle-pi#311 P2 — in-process completion outcomes with no equivalent
|
|
83
|
+
// above (lib/inprocess-reviewer.ts#INPROCESS_REVIEWER_FAILURE).
|
|
84
|
+
REVIEWER_MODEL_NOT_FOUND: "reviewer-model-not-found",
|
|
85
|
+
REVIEWER_AUTH_UNAVAILABLE: "reviewer-auth-unavailable",
|
|
86
|
+
REVIEWER_THINKING_INVALID: "reviewer-thinking-invalid",
|
|
87
|
+
REVIEWER_TOOL_CALL: "reviewer-tool-call-attempted",
|
|
88
|
+
REVIEWER_EMPTY_OUTPUT: "reviewer-empty-output",
|
|
89
|
+
REVIEWER_OUTPUT_TOO_LARGE: "reviewer-output-too-large",
|
|
90
|
+
REVIEWER_ABORTED: "reviewer-aborted",
|
|
72
91
|
SUBMISSION_REFUSED: "submission-refused",
|
|
73
92
|
} as const;
|
|
74
93
|
export type ReviewHostRelayFailureKind = (typeof REVIEW_HOST_RELAY_FAILURE)[keyof typeof REVIEW_HOST_RELAY_FAILURE];
|
|
@@ -86,10 +105,11 @@ export class ReviewHostRelayError extends Error {
|
|
|
86
105
|
readonly exitCode: number | null;
|
|
87
106
|
readonly stderr: string;
|
|
88
107
|
readonly timedOut: boolean;
|
|
89
|
-
// Wall time the
|
|
90
|
-
// was measured against. Both are null only when
|
|
91
|
-
// Without them a transport failure cannot be told apart
|
|
92
|
-
// is what forced the gentle-pi#367 reporter to measure
|
|
108
|
+
// Wall time the aborted, timed-out, or failed reviewer completion actually
|
|
109
|
+
// consumed, and the bound it was measured against. Both are null only when
|
|
110
|
+
// no completion ran. Without them a transport failure cannot be told apart
|
|
111
|
+
// from a crash, which is what forced the gentle-pi#367 reporter to measure
|
|
112
|
+
// the relay by hand.
|
|
93
113
|
readonly elapsedMs: number | null;
|
|
94
114
|
readonly timeoutMs: number | null;
|
|
95
115
|
// "none" until the submission invocation launches; a launched submission
|
|
@@ -99,9 +119,9 @@ export class ReviewHostRelayError extends Error {
|
|
|
99
119
|
// "none" again: the provider states that the lens slot was not consumed
|
|
100
120
|
// (gentle-pi#522 / #524).
|
|
101
121
|
readonly mutationOutcome: "none" | "unknown";
|
|
102
|
-
/** What the reviewer
|
|
103
|
-
readonly reviewerEvidence:
|
|
104
|
-
constructor(kind: ReviewHostRelayFailureKind, stage: ReviewHostRelayStage, message: string, details?: { exitCode?: number | null; stderr?: string; timedOut?: boolean; elapsedMs?: number; timeoutMs?: number; mutationOutcome?: "none" | "unknown"; reviewerEvidence?:
|
|
122
|
+
/** What the in-process reviewer outcome carried as structured evidence (e.g. the completion's stopReason on an empty-output refusal). */
|
|
123
|
+
readonly reviewerEvidence: Record<string, unknown> | undefined;
|
|
124
|
+
constructor(kind: ReviewHostRelayFailureKind, stage: ReviewHostRelayStage, message: string, details?: { exitCode?: number | null; stderr?: string; timedOut?: boolean; elapsedMs?: number; timeoutMs?: number; mutationOutcome?: "none" | "unknown"; reviewerEvidence?: Record<string, unknown> }) {
|
|
105
125
|
super(message);
|
|
106
126
|
this.name = "ReviewHostRelayError";
|
|
107
127
|
this.kind = kind;
|
|
@@ -199,6 +219,16 @@ export interface ReviewHostRelaySlot {
|
|
|
199
219
|
readonly lens?: string;
|
|
200
220
|
readonly order?: string;
|
|
201
221
|
readonly subjectHash?: string;
|
|
222
|
+
/**
|
|
223
|
+
* Overrides the routing config key the reviewer selection resolves
|
|
224
|
+
* through (gentle-pi#311 P3). A lens slot leaves this unset and resolves
|
|
225
|
+
* through `lens` instead; a v9 host-mediated refuter/targeted-validator
|
|
226
|
+
* slot sets it to its fixed `review-refuter` / `review-validator` key,
|
|
227
|
+
* since those roles carry no per-slot lens identity.
|
|
228
|
+
*/
|
|
229
|
+
readonly routingKey?: string;
|
|
230
|
+
/** The provider-declared collect input name (e.g. `provider_refuter`), carried for diagnostics only. */
|
|
231
|
+
readonly name?: string;
|
|
202
232
|
}
|
|
203
233
|
|
|
204
234
|
function argumentValue(input: ReviewCollectInputV3, name: string): string | undefined {
|
|
@@ -260,6 +290,40 @@ export function reviewProviderRoleVectorSlots(inputs: readonly ReviewCollectInpu
|
|
|
260
290
|
}));
|
|
261
291
|
}
|
|
262
292
|
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// Host-mediated provider role slots (gentle-pi#311 P3; provider contract
|
|
295
|
+
// v9) — the same two role capture operations above, but rendered exactly
|
|
296
|
+
// like a lens materialize slot: binding tokens plus `--agent=pi
|
|
297
|
+
// --materialize=true` (never `--execute`) and a provider-owned submission
|
|
298
|
+
// descriptor. These slots run through the SAME relay machinery a lens slot
|
|
299
|
+
// does (`prepareReviewHostRelaySlot` / `submitReviewHostRelayPreparedResult`)
|
|
300
|
+
// — there is no second relay. The only role-specific parts are the fixed
|
|
301
|
+
// `routingKey` (there is no per-slot lens identity to read one from) and the
|
|
302
|
+
// input's own schema, which already names refuter vs targeted-validator in
|
|
303
|
+
// every refusal that carries the request.
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
const REVIEW_HOST_MEDIATED_ROLE_ROUTING_KEY: Record<ReviewProviderRoleVectorSlot["captureOperation"], "review-refuter" | "review-validator"> = {
|
|
307
|
+
[REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION.CAPTURE_REFUTER]: "review-refuter",
|
|
308
|
+
[REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION.CAPTURE_VALIDATION]: "review-validator",
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
export function isReviewHostMediatedRoleCollectInput(input: ReviewCollectInputV3): boolean {
|
|
312
|
+
return (REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS as readonly string[]).includes(input.captureOperation)
|
|
313
|
+
&& argumentValue(input, "materialize") === "true"
|
|
314
|
+
&& argumentValue(input, "agent") === "pi"
|
|
315
|
+
&& input.submission !== undefined;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function reviewHostMediatedRoleSlots(inputs: readonly ReviewCollectInputV3[]): readonly ReviewHostRelaySlot[] {
|
|
319
|
+
return inputs.filter((input) => isReviewHostMediatedRoleCollectInput(input)).map((input) => ({
|
|
320
|
+
captureArgumentTokens: input.arguments.map((argument) => renderToken(argument)),
|
|
321
|
+
submission: input.submission!,
|
|
322
|
+
routingKey: REVIEW_HOST_MEDIATED_ROLE_ROUTING_KEY[input.captureOperation as ReviewProviderRoleVectorSlot["captureOperation"]],
|
|
323
|
+
name: input.name,
|
|
324
|
+
}));
|
|
325
|
+
}
|
|
326
|
+
|
|
263
327
|
// Resolves the provider-owned submission form into an executable binding.
|
|
264
328
|
// Fails closed with a typed contract-mismatch error whenever the completing
|
|
265
329
|
// form is absent or cannot bind exactly one artifact value; the relay never
|
|
@@ -303,25 +367,27 @@ export interface ReviewHostRelayRequest {
|
|
|
303
367
|
readonly submission?: ReviewCaptureSubmissionV1;
|
|
304
368
|
/** Absolute path; defaults to the verified package-local binary. */
|
|
305
369
|
readonly gentleAiExecutable?: string;
|
|
306
|
-
/** User-owned pi launcher; defaults to `pi` on PATH. */
|
|
307
|
-
readonly piExecutable?: string;
|
|
308
370
|
readonly environment?: NodeJS.ProcessEnv;
|
|
309
371
|
readonly gentleAiTimeoutMs?: number;
|
|
310
372
|
/**
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
373
|
+
* The live model registry the reviewer completion resolves its selection
|
|
374
|
+
* and credentials through — structurally, pi's own `ModelRegistry`
|
|
375
|
+
* (`ctx.modelRegistry`). Absent is a typed refusal before materialize ever
|
|
376
|
+
* runs; the relay never falls back to a child process or an ambient
|
|
377
|
+
* default model (gentle-ai#4611; gentle-pi#311 P2).
|
|
315
378
|
*/
|
|
316
|
-
readonly
|
|
379
|
+
readonly reviewerRegistry?: InProcessReviewerRegistry;
|
|
317
380
|
/**
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
* absolute and exist; anything else is refused typed before launch.
|
|
381
|
+
* The lens's user-owned "provider/id" selection, read from the agent model
|
|
382
|
+
* routing config's `review-<lens>` entry. The relay never invents one: a
|
|
383
|
+
* routing entry with no configured model is refused typed before
|
|
384
|
+
* materialize, naming {@link ReviewHostRelayRequest.routingKey}.
|
|
323
385
|
*/
|
|
324
|
-
readonly
|
|
386
|
+
readonly selection?: string;
|
|
387
|
+
/** The routing entry's thinking label, forwarded verbatim to the completion. */
|
|
388
|
+
readonly thinking?: string;
|
|
389
|
+
/** Names the routing config key (e.g. "review-risk") in refusal messages; defaults to a generic label when absent. */
|
|
390
|
+
readonly routingKey?: string;
|
|
325
391
|
/**
|
|
326
392
|
* Overrides the reviewer bound entirely. Production leaves it unset and the
|
|
327
393
|
* relay derives the bound from the materialized prompt bytes and
|
|
@@ -356,7 +422,7 @@ export type ReviewHostRelaySubmissionRunner = (prepared: ReviewHostRelayPrepared
|
|
|
356
422
|
const DEFAULT_GENTLE_AI_TIMEOUT_MS = 120_000;
|
|
357
423
|
|
|
358
424
|
// ---------------------------------------------------------------------------
|
|
359
|
-
// The reviewer
|
|
425
|
+
// The reviewer completion bound (gentle-pi#367).
|
|
360
426
|
//
|
|
361
427
|
// The previous bound was a single hardcoded 600_000 ms reachable only through
|
|
362
428
|
// the test-injectable runner. A field-measured lens legitimately needed 478s
|
|
@@ -378,7 +444,7 @@ const DEFAULT_GENTLE_AI_TIMEOUT_MS = 120_000;
|
|
|
378
444
|
// established numeric-override shape (GENTLE_PI_CANDIDATE_GIT_TIMEOUT_MS,
|
|
379
445
|
// GENTLE_PI_REVIEW_MAX_BUFFER_BYTES): a positive decimal, silently ignored
|
|
380
446
|
// when malformed, and clamped to the same hard ceiling so no configuration can
|
|
381
|
-
// turn a foreground FINALIZE into an unbounded
|
|
447
|
+
// turn a foreground FINALIZE into an unbounded completion.
|
|
382
448
|
// ---------------------------------------------------------------------------
|
|
383
449
|
|
|
384
450
|
export const REVIEW_HOST_RELAY_PI_TIMEOUT_ENV = "GENTLE_PI_REVIEW_RELAY_PI_TIMEOUT_MS";
|
|
@@ -398,11 +464,12 @@ export function resolveReviewHostRelayPiTimeoutMs(promptByteLength: number, envi
|
|
|
398
464
|
return Math.min(scaled, REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS);
|
|
399
465
|
}
|
|
400
466
|
|
|
401
|
-
// The reviewer ran out of time; it did not crash. The message
|
|
402
|
-
// measurements and names the two things that can change the
|
|
403
|
-
// the one thing that cannot is relaunching the identical
|
|
467
|
+
// The reviewer completion ran out of time; it did not crash. The message
|
|
468
|
+
// states both measurements and names the two things that can change the
|
|
469
|
+
// outcome, because the one thing that cannot is relaunching the identical
|
|
470
|
+
// slot.
|
|
404
471
|
export function reviewHostRelayPiTimeoutMessage(elapsedMs: number, timeoutMs: number, promptByteLength: number): string {
|
|
405
|
-
return `
|
|
472
|
+
return `the reviewer completion exceeded the relay bound: aborted after ${elapsedMs}ms against a ${timeoutMs}ms limit for a ${promptByteLength}-byte materialized prompt. `
|
|
406
473
|
+ `Relaunching the same slot unchanged reaches the same wall. Raise ${REVIEW_HOST_RELAY_PI_TIMEOUT_ENV} above the reviewer's real wall time (ceiling ${REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS}ms) or reduce the candidate scope so the materialized prompt is smaller.`;
|
|
407
474
|
}
|
|
408
475
|
|
|
@@ -463,54 +530,48 @@ function collectGentleAiProcess(
|
|
|
463
530
|
});
|
|
464
531
|
}
|
|
465
532
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
REVIEW_HOST_RELAY_FAILURE.
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
if (
|
|
508
|
-
error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.LAUNCH_FAILED
|
|
509
|
-
|| error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.SCRATCH_FAILED
|
|
510
|
-
) {
|
|
511
|
-
return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_LAUNCH_FAILED, "pi", `pi subprocess could not start: ${error.message}`, details);
|
|
533
|
+
// Maps one in-process reviewer refusal onto a typed relay error. TIMED_OUT
|
|
534
|
+
// reuses PI_TIMED_OUT and PROVIDER_FAILED reuses PI_FAILED (identical
|
|
535
|
+
// semantics: a deterministic bound; a generic failure bucket); every other
|
|
536
|
+
// code gets its own REVIEWER_* kind with no prior equivalent.
|
|
537
|
+
function relayReviewerRefusalError(
|
|
538
|
+
outcome: Extract<InProcessReviewerOutcome, { kind: "refused" }>,
|
|
539
|
+
timing: { elapsedMs: number; timeoutMs: number },
|
|
540
|
+
promptByteLength: number,
|
|
541
|
+
): ReviewHostRelayError {
|
|
542
|
+
const details = { ...timing, ...(outcome.evidence === undefined ? {} : { reviewerEvidence: outcome.evidence }) };
|
|
543
|
+
const code: InProcessReviewerFailureCode = outcome.code;
|
|
544
|
+
switch (code) {
|
|
545
|
+
case INPROCESS_REVIEWER_FAILURE.MODEL_NOT_FOUND:
|
|
546
|
+
return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_MODEL_NOT_FOUND, "pi", outcome.message, details);
|
|
547
|
+
case INPROCESS_REVIEWER_FAILURE.AUTH_UNAVAILABLE:
|
|
548
|
+
return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_AUTH_UNAVAILABLE, "pi", outcome.message, details);
|
|
549
|
+
case INPROCESS_REVIEWER_FAILURE.THINKING_INVALID:
|
|
550
|
+
return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_THINKING_INVALID, "pi", outcome.message, details);
|
|
551
|
+
case INPROCESS_REVIEWER_FAILURE.TOOL_CALL_ATTEMPTED:
|
|
552
|
+
return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_TOOL_CALL, "pi", outcome.message, details);
|
|
553
|
+
case INPROCESS_REVIEWER_FAILURE.EMPTY_OUTPUT:
|
|
554
|
+
return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_EMPTY_OUTPUT, "pi", outcome.message, details);
|
|
555
|
+
case INPROCESS_REVIEWER_FAILURE.OUTPUT_TOO_LARGE:
|
|
556
|
+
return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_OUTPUT_TOO_LARGE, "pi", outcome.message, details);
|
|
557
|
+
case INPROCESS_REVIEWER_FAILURE.ABORTED:
|
|
558
|
+
return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_ABORTED, "pi", outcome.message, details);
|
|
559
|
+
case INPROCESS_REVIEWER_FAILURE.SELECTION_INVALID:
|
|
560
|
+
return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_CONFIG_INVALID, "pi", outcome.message, details);
|
|
561
|
+
case INPROCESS_REVIEWER_FAILURE.TIMED_OUT:
|
|
562
|
+
return new ReviewHostRelayError(
|
|
563
|
+
REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT,
|
|
564
|
+
"pi",
|
|
565
|
+
reviewHostRelayPiTimeoutMessage(timing.elapsedMs, timing.timeoutMs, promptByteLength),
|
|
566
|
+
{ ...details, timedOut: true },
|
|
567
|
+
);
|
|
568
|
+
case INPROCESS_REVIEWER_FAILURE.PROVIDER_FAILED:
|
|
569
|
+
return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", outcome.message, details);
|
|
570
|
+
default: {
|
|
571
|
+
const unreachable: never = code;
|
|
572
|
+
return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", `unrecognized reviewer refusal code ${String(unreachable)}`, details);
|
|
573
|
+
}
|
|
512
574
|
}
|
|
513
|
-
return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", "pi subprocess failed", details);
|
|
514
575
|
}
|
|
515
576
|
|
|
516
577
|
function assertTokens(name: string, tokens: readonly string[]): void {
|
|
@@ -520,66 +581,52 @@ function assertTokens(name: string, tokens: readonly string[]): void {
|
|
|
520
581
|
}
|
|
521
582
|
}
|
|
522
583
|
|
|
523
|
-
export const REVIEW_HOST_RELAY_EXTENSIONS_ENV = "GENTLE_PI_REVIEW_RELAY_EXTENSIONS";
|
|
524
|
-
|
|
525
584
|
/**
|
|
526
|
-
*
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
-
*
|
|
585
|
+
* Validates the caller-owned reviewer selection before any process launches: a
|
|
586
|
+
* missing model registry or a routing entry with no configured model is a
|
|
587
|
+
* typed refusal, never a mid-review transport failure and never a fallback to
|
|
588
|
+
* an ambient default model (gentle-ai#4611; gentle-pi#311 P2, superseding
|
|
589
|
+
* gentle-shell#1158 / #1136's child-process launch configuration).
|
|
530
590
|
*/
|
|
531
|
-
|
|
532
|
-
const
|
|
533
|
-
if (
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
function reviewerLaunchArguments(reviewerModel: string | undefined, reviewerExtensionPaths: readonly string[] | undefined): readonly string[] {
|
|
540
|
-
return [
|
|
541
|
-
...(reviewerModel === undefined ? [] : ["--model", reviewerModel]),
|
|
542
|
-
...(reviewerExtensionPaths ?? []).flatMap((path) => ["-e", path]),
|
|
543
|
-
];
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
function validateReviewerLaunchConfiguration(request: ReviewHostRelayRequest): { reviewerModel?: string; reviewerExtensionPaths?: readonly string[] } {
|
|
547
|
-
if (request.reviewerModel !== undefined) {
|
|
548
|
-
if (typeof request.reviewerModel !== "string" || request.reviewerModel.length === 0 || !SAFE_MODEL_ID_PATTERN.test(request.reviewerModel)) {
|
|
549
|
-
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_CONFIG_INVALID, "pi", `Pi host relay reviewer launch configuration is invalid: the caller-owned reviewer selection ${JSON.stringify(request.reviewerModel)} is not a safe model id`);
|
|
550
|
-
}
|
|
591
|
+
function validateReviewerSelectionConfiguration(request: ReviewHostRelayRequest): { reviewerRegistry: InProcessReviewerRegistry; selection: string; thinking?: string; routingKey: string } {
|
|
592
|
+
const routingKey = typeof request.routingKey === "string" && request.routingKey.length > 0 ? request.routingKey : "review capture";
|
|
593
|
+
if (request.reviewerRegistry === undefined) {
|
|
594
|
+
throw new ReviewHostRelayError(
|
|
595
|
+
REVIEW_HOST_RELAY_FAILURE.REVIEWER_CONFIG_INVALID,
|
|
596
|
+
"pi",
|
|
597
|
+
`Pi host relay reviewer launch configuration is invalid: no model registry is available to complete ${routingKey}`,
|
|
598
|
+
);
|
|
551
599
|
}
|
|
552
|
-
if (request.
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_CONFIG_INVALID, "pi", `Pi host relay reviewer launch configuration is invalid: the extension path ${JSON.stringify(path)} is not absolute`);
|
|
559
|
-
}
|
|
560
|
-
if (!existsSync(path)) {
|
|
561
|
-
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_CONFIG_INVALID, "pi", `Pi host relay reviewer launch configuration is invalid: the extension path ${JSON.stringify(path)} does not exist`);
|
|
562
|
-
}
|
|
563
|
-
}
|
|
600
|
+
if (typeof request.selection !== "string" || request.selection.length === 0) {
|
|
601
|
+
throw new ReviewHostRelayError(
|
|
602
|
+
REVIEW_HOST_RELAY_FAILURE.REVIEWER_CONFIG_INVALID,
|
|
603
|
+
"pi",
|
|
604
|
+
`Pi host relay reviewer launch configuration is invalid: no model is configured for ${routingKey}; assign it a model in the agent model routing config`,
|
|
605
|
+
);
|
|
564
606
|
}
|
|
565
607
|
return {
|
|
566
|
-
|
|
567
|
-
|
|
608
|
+
reviewerRegistry: request.reviewerRegistry,
|
|
609
|
+
selection: request.selection,
|
|
610
|
+
...(request.thinking === undefined ? {} : { thinking: request.thinking }),
|
|
611
|
+
routingKey,
|
|
568
612
|
};
|
|
569
613
|
}
|
|
570
614
|
|
|
571
615
|
function snapshotReviewHostRelayRequest(request: ReviewHostRelayRequest): ReviewHostRelayRequest {
|
|
616
|
+
// Structural malformations (empty/blank tokens, a non-absolute executable)
|
|
617
|
+
// are TypeErrors — a programmer mistake, never a typed relay refusal — and
|
|
618
|
+
// are checked before the business-level reviewer selection below.
|
|
572
619
|
assertTokens("capture", request.captureArgumentTokens);
|
|
620
|
+
const gentleAiExecutable = request.gentleAiExecutable ?? resolveGentleAiBinary();
|
|
621
|
+
if (!isAbsolute(gentleAiExecutable)) throw new TypeError("Pi host relay requires an absolute gentle-ai executable path");
|
|
573
622
|
// Caller-owned reviewer selection is validated before any process launches:
|
|
574
623
|
// a broken configuration is a typed refusal, never a mid-review transport
|
|
575
|
-
// failure (gentle-
|
|
576
|
-
const
|
|
624
|
+
// failure (gentle-pi#311 P2).
|
|
625
|
+
const reviewerSelection = validateReviewerSelectionConfiguration(request);
|
|
577
626
|
// The completing form is validated before any process launches: a materialize
|
|
578
627
|
// slot without a provider-owned submission is a typed contract mismatch,
|
|
579
628
|
// never a synthesized invocation.
|
|
580
629
|
resolveReviewHostRelaySubmission(request.submission);
|
|
581
|
-
const gentleAiExecutable = request.gentleAiExecutable ?? resolveGentleAiBinary();
|
|
582
|
-
if (!isAbsolute(gentleAiExecutable)) throw new TypeError("Pi host relay requires an absolute gentle-ai executable path");
|
|
583
630
|
const environment = Object.freeze({ ...(request.environment ?? process.env) }) as NodeJS.ProcessEnv;
|
|
584
631
|
const submission = request.submission === undefined ? undefined : Object.freeze({
|
|
585
632
|
operationToken: request.submission.operationToken,
|
|
@@ -590,7 +637,7 @@ function snapshotReviewHostRelayRequest(request: ReviewHostRelayRequest): Review
|
|
|
590
637
|
...request,
|
|
591
638
|
captureArgumentTokens: Object.freeze([...request.captureArgumentTokens]),
|
|
592
639
|
...(submission === undefined ? {} : { submission }),
|
|
593
|
-
...
|
|
640
|
+
...reviewerSelection,
|
|
594
641
|
gentleAiExecutable,
|
|
595
642
|
environment,
|
|
596
643
|
gentleAiTimeoutMs: request.gentleAiTimeoutMs ?? DEFAULT_GENTLE_AI_TIMEOUT_MS,
|
|
@@ -599,13 +646,14 @@ function snapshotReviewHostRelayRequest(request: ReviewHostRelayRequest): Review
|
|
|
599
646
|
}
|
|
600
647
|
|
|
601
648
|
/**
|
|
602
|
-
* Materializes one provider-bound reviewer prompt and runs
|
|
603
|
-
*
|
|
604
|
-
* finish before the caller performs provider-ordered
|
|
649
|
+
* Materializes one provider-bound reviewer prompt and runs it through one
|
|
650
|
+
* in-process reviewer completion. It does not submit anything, so independent
|
|
651
|
+
* reviewer work can finish before the caller performs provider-ordered
|
|
652
|
+
* admission.
|
|
605
653
|
*/
|
|
606
654
|
export async function prepareReviewHostRelaySlot(
|
|
607
655
|
request: ReviewHostRelayRequest,
|
|
608
|
-
|
|
656
|
+
runReviewer: typeof runInProcessReviewer = runInProcessReviewer,
|
|
609
657
|
): Promise<ReviewHostRelayPreparedResult> {
|
|
610
658
|
// Copy mutable transport configuration before the first async boundary. The
|
|
611
659
|
// supplied AbortSignal intentionally stays live across materialize, reviewer,
|
|
@@ -614,9 +662,14 @@ export async function prepareReviewHostRelaySlot(
|
|
|
614
662
|
|
|
615
663
|
// The provider materializes the opaque prompt and detects whether this relay
|
|
616
664
|
// surface is available. No version sniffing or prompt reconstruction occurs.
|
|
665
|
+
// The materialize subcommand is the provider's own submission operation
|
|
666
|
+
// token (validated present above): "capture-result" for a lens slot,
|
|
667
|
+
// "capture-refuter" or "capture-validation" for a v9 host-mediated role
|
|
668
|
+
// slot — the provider always names the same operation for both the
|
|
669
|
+
// materialize and the submit leg of one slot.
|
|
617
670
|
let materialized: ProcessCapture;
|
|
618
671
|
try {
|
|
619
|
-
materialized = await collectGentleAiProcess(preparedRequest.gentleAiExecutable!, ["review",
|
|
672
|
+
materialized = await collectGentleAiProcess(preparedRequest.gentleAiExecutable!, ["review", preparedRequest.submission!.operationToken, ...preparedRequest.captureArgumentTokens], {
|
|
620
673
|
cwd: preparedRequest.targetCwd!,
|
|
621
674
|
env: { ...preparedRequest.environment!, [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT },
|
|
622
675
|
timeoutMs: preparedRequest.gentleAiTimeoutMs!,
|
|
@@ -663,27 +716,43 @@ export async function prepareReviewHostRelaySlot(
|
|
|
663
716
|
// both the user-owned environment override and the scale-derived bound.
|
|
664
717
|
const piTimeoutMs = preparedRequest.piTimeoutMs ?? resolveReviewHostRelayPiTimeoutMs(promptBytes.length, preparedRequest.environment);
|
|
665
718
|
|
|
666
|
-
// The
|
|
667
|
-
//
|
|
668
|
-
|
|
669
|
-
|
|
719
|
+
// The completion runs in-process through the live model registry: no
|
|
720
|
+
// child, no extension allowlist, no ambient default model. Elapsed time is
|
|
721
|
+
// measured here (there is no killed process to read it from) so a timed-out
|
|
722
|
+
// or aborted refusal still carries the same elapsed/limit evidence a killed
|
|
723
|
+
// child used to.
|
|
724
|
+
const startedAt = Date.now();
|
|
725
|
+
let outcome: InProcessReviewerOutcome;
|
|
670
726
|
try {
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
727
|
+
outcome = await runReviewer(
|
|
728
|
+
{
|
|
729
|
+
selection: preparedRequest.selection!,
|
|
730
|
+
...(preparedRequest.thinking === undefined ? {} : { thinking: preparedRequest.thinking }),
|
|
731
|
+
prompt: promptBytes,
|
|
732
|
+
timeoutMs: piTimeoutMs,
|
|
733
|
+
...(preparedRequest.signal === undefined ? {} : { signal: preparedRequest.signal }),
|
|
734
|
+
routingKey: preparedRequest.routingKey!,
|
|
735
|
+
},
|
|
736
|
+
{ registry: preparedRequest.reviewerRegistry!, complete: completeSimple },
|
|
737
|
+
);
|
|
678
738
|
} catch (error) {
|
|
679
|
-
throw
|
|
739
|
+
throw new ReviewHostRelayError(
|
|
740
|
+
REVIEW_HOST_RELAY_FAILURE.PI_FAILED,
|
|
741
|
+
"pi",
|
|
742
|
+
`the reviewer completion could not run: ${error instanceof Error ? error.message : String(error)}`,
|
|
743
|
+
{ elapsedMs: Date.now() - startedAt, timeoutMs: piTimeoutMs },
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
if (outcome.kind === "refused") {
|
|
747
|
+
throw relayReviewerRefusalError(outcome, { elapsedMs: Date.now() - startedAt, timeoutMs: piTimeoutMs }, promptBytes.length);
|
|
680
748
|
}
|
|
749
|
+
const resultBytes = Buffer.from(outcome.text, "utf8");
|
|
681
750
|
const prepared = Object.freeze({
|
|
682
751
|
request: preparedRequest,
|
|
683
752
|
promptByteLength: promptBytes.length,
|
|
684
|
-
resultByteLength:
|
|
753
|
+
resultByteLength: resultBytes.length,
|
|
685
754
|
});
|
|
686
|
-
preparedResultBytes.set(prepared,
|
|
755
|
+
preparedResultBytes.set(prepared, resultBytes);
|
|
687
756
|
return prepared;
|
|
688
757
|
}
|
|
689
758
|
|
|
@@ -777,9 +846,15 @@ export async function submitReviewHostRelayPreparedResult(prepared: ReviewHostRe
|
|
|
777
846
|
}
|
|
778
847
|
|
|
779
848
|
/**
|
|
780
|
-
* Compatibility one-binding path: materialize →
|
|
781
|
-
* It preserves the established API and its typed
|
|
849
|
+
* Compatibility one-binding path: materialize → in-process reviewer
|
|
850
|
+
* completion → submit. It preserves the established API and its typed
|
|
851
|
+
* failure behavior exactly. `runReviewer` is the same test seam
|
|
852
|
+
* {@link prepareReviewHostRelaySlot} takes, threaded through so a caller never
|
|
853
|
+
* needs to call the two-step path just to inject a fake completion.
|
|
782
854
|
*/
|
|
783
|
-
export async function runReviewHostRelaySlot(
|
|
784
|
-
|
|
855
|
+
export async function runReviewHostRelaySlot(
|
|
856
|
+
request: ReviewHostRelayRequest,
|
|
857
|
+
runReviewer: typeof runInProcessReviewer = runInProcessReviewer,
|
|
858
|
+
): Promise<ReviewHostRelayResult> {
|
|
859
|
+
return await submitReviewHostRelayPreparedResult(await prepareReviewHostRelaySlot(request, runReviewer));
|
|
785
860
|
}
|