@mrclrchtr/supi-review 2.6.1 → 2.8.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/README.md +24 -3
- package/node_modules/@mrclrchtr/supi-core/README.md +1 -1
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/settings/scoped-settings-list.ts +1 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-schema.ts +14 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-submenus.ts +31 -14
- package/node_modules/@mrclrchtr/supi-core/src/settings.ts +1 -0
- package/package.json +2 -2
- package/src/config.ts +55 -0
- package/src/model.ts +33 -0
- package/src/review.ts +45 -29
- package/src/tool/agent-review-tools.ts +20 -11
- package/src/tool/agent-review-workflow.ts +41 -23
- package/src/tool/brief-runner.ts +25 -24
- package/src/tool/child-failure-diagnostics.ts +145 -0
- package/src/tool/child-lifecycle-trace.ts +329 -0
- package/src/tool/review-handlers.ts +9 -30
- package/src/tool/review-runner.ts +25 -43
- package/src/tool/runner-helpers.ts +0 -14
- package/src/tool/session-lifecycle.ts +105 -10
- package/src/types.ts +51 -37
- package/src/ui/format-content.ts +47 -38
- package/src/ui/renderer.ts +67 -45
- package/src/ui/review-tool-renderer.ts +32 -1
- package/src/tool/review-debug.ts +0 -124
package/src/tool/brief-runner.ts
CHANGED
|
@@ -11,7 +11,8 @@ import type {
|
|
|
11
11
|
BriefSynthesisRunResult,
|
|
12
12
|
SynthesizedReviewBrief,
|
|
13
13
|
} from "../types.ts";
|
|
14
|
-
import {
|
|
14
|
+
import { createEarlyCancellationDiagnostics } from "./child-failure-diagnostics.ts";
|
|
15
|
+
import { buildProgressTokens } from "./runner-helpers.ts";
|
|
15
16
|
import { reviewBriefSchema } from "./schemas.ts";
|
|
16
17
|
import { type LifecycleCtx, runWithLifecycle } from "./session-lifecycle.ts";
|
|
17
18
|
|
|
@@ -88,24 +89,19 @@ function emitBriefProgress(
|
|
|
88
89
|
|
|
89
90
|
/** Finalize after retries and compaction recovery, never at the earlier `agent_end` boundary. */
|
|
90
91
|
function handleAgentSettled(options: {
|
|
91
|
-
session: AgentSession;
|
|
92
92
|
brief: SynthesizedReviewBrief | undefined;
|
|
93
93
|
state: { settled: boolean; aborting: boolean };
|
|
94
94
|
cleanup: (result: BriefSynthesisRunResult) => BriefSynthesisRunResult;
|
|
95
|
+
getFailureDiagnostics: LifecycleCtx<BriefSynthesisRunResult>["getFailureDiagnostics"];
|
|
95
96
|
}): BriefSynthesisRunResult | undefined {
|
|
96
|
-
const {
|
|
97
|
-
if (state.settled || state.aborting)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if (brief) {
|
|
101
|
-
return cleanup({ kind: "success", brief });
|
|
102
|
-
}
|
|
103
|
-
const lastText = extractLastAssistantText(session.messages);
|
|
97
|
+
const { brief, state, cleanup, getFailureDiagnostics } = options;
|
|
98
|
+
if (state.settled || state.aborting) return undefined;
|
|
99
|
+
if (brief) return cleanup({ kind: "success", brief });
|
|
100
|
+
|
|
104
101
|
return cleanup({
|
|
105
102
|
kind: "failed",
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
: "Brief synthesizer did not produce any output.",
|
|
103
|
+
failureCode: "missing-structured-output",
|
|
104
|
+
diagnostics: getFailureDiagnostics(),
|
|
109
105
|
});
|
|
110
106
|
}
|
|
111
107
|
|
|
@@ -114,7 +110,7 @@ export async function runBriefSynthesis(
|
|
|
114
110
|
invocation: BriefSynthesisInvocation,
|
|
115
111
|
): Promise<BriefSynthesisRunResult> {
|
|
116
112
|
if (invocation.signal?.aborted) {
|
|
117
|
-
return { kind: "canceled" };
|
|
113
|
+
return { kind: "canceled", diagnostics: createEarlyCancellationDiagnostics() };
|
|
118
114
|
}
|
|
119
115
|
|
|
120
116
|
const resultHolder: { value: SynthesizedReviewBrief | undefined } = { value: undefined };
|
|
@@ -123,11 +119,8 @@ export async function runBriefSynthesis(
|
|
|
123
119
|
let session: AgentSession;
|
|
124
120
|
try {
|
|
125
121
|
session = await createBriefSession(invocation, submitBriefTool);
|
|
126
|
-
} catch
|
|
127
|
-
return {
|
|
128
|
-
kind: "failed",
|
|
129
|
-
reason: `Failed to create brief synthesis session: ${error instanceof Error ? error.message : String(error)}`,
|
|
130
|
-
};
|
|
122
|
+
} catch {
|
|
123
|
+
return { kind: "failed", failureCode: "session-creation-failed" };
|
|
131
124
|
}
|
|
132
125
|
|
|
133
126
|
return runWithLifecycle<BriefSynthesisRunResult>({
|
|
@@ -155,10 +148,10 @@ export async function runBriefSynthesis(
|
|
|
155
148
|
break;
|
|
156
149
|
case "agent_settled": {
|
|
157
150
|
const result = handleAgentSettled({
|
|
158
|
-
session,
|
|
159
151
|
brief: resultHolder.value,
|
|
160
152
|
state: ctx.state,
|
|
161
153
|
cleanup: ctx.cleanup,
|
|
154
|
+
getFailureDiagnostics: ctx.getFailureDiagnostics,
|
|
162
155
|
});
|
|
163
156
|
if (result) {
|
|
164
157
|
ctx.resolve(result);
|
|
@@ -169,11 +162,19 @@ export async function runBriefSynthesis(
|
|
|
169
162
|
break;
|
|
170
163
|
}
|
|
171
164
|
},
|
|
172
|
-
canceledResult: () => ({
|
|
173
|
-
|
|
165
|
+
canceledResult: (ctx) => ({
|
|
166
|
+
kind: "canceled" as const,
|
|
167
|
+
diagnostics: ctx.getFailureDiagnostics(),
|
|
168
|
+
}),
|
|
169
|
+
failedResult: (failureCode, ctx) => ({
|
|
174
170
|
kind: "failed" as const,
|
|
175
|
-
|
|
171
|
+
failureCode,
|
|
172
|
+
diagnostics: ctx.getFailureDiagnostics(),
|
|
173
|
+
}),
|
|
174
|
+
timeoutResult: (ms, ctx) => ({
|
|
175
|
+
kind: "timeout" as const,
|
|
176
|
+
timeoutMs: ms,
|
|
177
|
+
diagnostics: ctx.getFailureDiagnostics(),
|
|
176
178
|
}),
|
|
177
|
-
timeoutResult: (ms) => ({ kind: "timeout" as const, timeoutMs: ms }),
|
|
178
179
|
});
|
|
179
180
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type {
|
|
3
|
+
ChildFailureCode,
|
|
4
|
+
ChildFailureDiagnostics,
|
|
5
|
+
ChildStage,
|
|
6
|
+
ReviewProgress,
|
|
7
|
+
} from "../types.ts";
|
|
8
|
+
import {
|
|
9
|
+
type ChildLifecycleTrace,
|
|
10
|
+
ChildLifecycleTraceCollector,
|
|
11
|
+
formatChildLifecycleTrace,
|
|
12
|
+
getRegisteredToolNames,
|
|
13
|
+
toSafeAssistantStopReason,
|
|
14
|
+
} from "./child-lifecycle-trace.ts";
|
|
15
|
+
import { buildProgressTokens } from "./runner-helpers.ts";
|
|
16
|
+
|
|
17
|
+
/** Inputs used to create one safe non-success child-run diagnostic artifact. */
|
|
18
|
+
export interface BuildChildFailureDiagnosticsInput {
|
|
19
|
+
progress: ReviewProgress;
|
|
20
|
+
lifecycleTrace: ChildLifecycleTrace;
|
|
21
|
+
recentActivity: string[];
|
|
22
|
+
session?: AgentSession;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Build diagnostics from allowlisted control metadata only. */
|
|
26
|
+
export function buildChildFailureDiagnostics(
|
|
27
|
+
input: BuildChildFailureDiagnosticsInput,
|
|
28
|
+
): ChildFailureDiagnostics {
|
|
29
|
+
const { session } = input;
|
|
30
|
+
const tokens = session
|
|
31
|
+
? buildProgressTokens(() => session.getSessionStats())
|
|
32
|
+
: input.progress.tokens;
|
|
33
|
+
const lastAssistant = session ? extractLastAssistantMetadata(session) : undefined;
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
lifecycleTrace: input.lifecycleTrace,
|
|
37
|
+
turns: input.progress.turns,
|
|
38
|
+
toolUses: input.progress.toolUses,
|
|
39
|
+
tokens,
|
|
40
|
+
recentActivity: input.recentActivity.length > 0 ? [...input.recentActivity] : undefined,
|
|
41
|
+
lastAssistantStopReason: lastAssistant?.stopReason,
|
|
42
|
+
lastAssistantToolCalls: lastAssistant?.toolCalls,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Create safe diagnostics when a host failure occurred without observed child lifecycle events. */
|
|
47
|
+
export function createUnobservedChildFailureDiagnostics(): ChildFailureDiagnostics {
|
|
48
|
+
return buildChildFailureDiagnostics({
|
|
49
|
+
progress: { turns: 0, toolUses: 0 },
|
|
50
|
+
lifecycleTrace: { entries: [], droppedCount: 0 },
|
|
51
|
+
recentActivity: [],
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Create safe cancellation diagnostics when no child session was started. */
|
|
56
|
+
export function createEarlyCancellationDiagnostics(): ChildFailureDiagnostics {
|
|
57
|
+
const collector = new ChildLifecycleTraceCollector();
|
|
58
|
+
collector.recordHostMarker({ type: "abort_requested", reason: "canceled" });
|
|
59
|
+
return buildChildFailureDiagnostics({
|
|
60
|
+
progress: { turns: 0, toolUses: 0 },
|
|
61
|
+
lifecycleTrace: collector.snapshot(),
|
|
62
|
+
recentActivity: collector.recentActivitySnapshot(),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Generate static parent-facing copy for a host-owned child failure code. */
|
|
67
|
+
export function formatChildFailureCopy(stage: ChildStage, code: ChildFailureCode): string {
|
|
68
|
+
const label = stage === "brief-synthesis" ? "Brief synthesis" : "Reviewer";
|
|
69
|
+
switch (code) {
|
|
70
|
+
case "session-creation-failed":
|
|
71
|
+
return `${label} session could not be created.`;
|
|
72
|
+
case "prompt-rejected":
|
|
73
|
+
return `${label} prompt was rejected before it ran.`;
|
|
74
|
+
case "missing-structured-output":
|
|
75
|
+
return `${label} ended without the required structured output.`;
|
|
76
|
+
case "unexpected-runner-failure":
|
|
77
|
+
return `${label} ended unexpectedly.`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Format the complete retained safe diagnostic artifact for parent-facing text. */
|
|
82
|
+
export function formatChildFailureDiagnostics(diagnostics: ChildFailureDiagnostics): string[] {
|
|
83
|
+
const lines = [
|
|
84
|
+
`- Turns: ${diagnostics.turns}`,
|
|
85
|
+
`- Tool uses: ${diagnostics.toolUses}`,
|
|
86
|
+
diagnostics.tokens
|
|
87
|
+
? `- Tokens: ${diagnostics.tokens.input} in / ${diagnostics.tokens.output} out / ${diagnostics.tokens.total} total`
|
|
88
|
+
: undefined,
|
|
89
|
+
diagnostics.recentActivity && diagnostics.recentActivity.length > 0
|
|
90
|
+
? `- Recent activity: ${diagnostics.recentActivity.join(" → ")}`
|
|
91
|
+
: undefined,
|
|
92
|
+
diagnostics.lastAssistantStopReason
|
|
93
|
+
? `- Last assistant stop: ${diagnostics.lastAssistantStopReason}`
|
|
94
|
+
: undefined,
|
|
95
|
+
diagnostics.lastAssistantToolCalls && diagnostics.lastAssistantToolCalls.length > 0
|
|
96
|
+
? `- Last assistant tools: ${diagnostics.lastAssistantToolCalls.join(", ")}`
|
|
97
|
+
: undefined,
|
|
98
|
+
`- ${formatChildLifecycleTrace(diagnostics.lifecycleTrace)}`,
|
|
99
|
+
];
|
|
100
|
+
return lines.filter((line): line is string => !!line);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface LastAssistantMetadata {
|
|
104
|
+
stopReason?: string;
|
|
105
|
+
toolCalls?: string[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function extractLastAssistantMetadata(session: AgentSession): LastAssistantMetadata | undefined {
|
|
109
|
+
try {
|
|
110
|
+
const messages = session.messages as unknown;
|
|
111
|
+
if (!Array.isArray(messages)) return undefined;
|
|
112
|
+
|
|
113
|
+
const registeredToolNames = getRegisteredToolNames(session);
|
|
114
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
115
|
+
const message = messages[index] as Record<string, unknown> | undefined;
|
|
116
|
+
if (message?.role !== "assistant") continue;
|
|
117
|
+
|
|
118
|
+
const stopReason = toSafeAssistantStopReason(message.stopReason);
|
|
119
|
+
const toolCalls = extractAssistantToolCalls(message.content, registeredToolNames);
|
|
120
|
+
return {
|
|
121
|
+
stopReason,
|
|
122
|
+
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
} catch {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function extractAssistantToolCalls(content: unknown, registeredToolNames: Set<string>): string[] {
|
|
132
|
+
if (!Array.isArray(content)) return [];
|
|
133
|
+
|
|
134
|
+
return content
|
|
135
|
+
.map((part) => {
|
|
136
|
+
if (typeof part !== "object" || !part) return undefined;
|
|
137
|
+
const tool = part as { type?: unknown; name?: unknown };
|
|
138
|
+
return tool.type === "toolCall" &&
|
|
139
|
+
typeof tool.name === "string" &&
|
|
140
|
+
registeredToolNames.has(tool.name)
|
|
141
|
+
? tool.name
|
|
142
|
+
: undefined;
|
|
143
|
+
})
|
|
144
|
+
.filter((name): name is string => !!name);
|
|
145
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** Maximum number of observed Child Lifecycle Trace entries retained per child run. */
|
|
4
|
+
export const CHILD_LIFECYCLE_TRACE_MAX = 32;
|
|
5
|
+
|
|
6
|
+
/** Maximum number of compact Recent Activity entries retained per child run. */
|
|
7
|
+
export const RECENT_ACTIVITY_MAX = 10;
|
|
8
|
+
|
|
9
|
+
/** Pi-defined assistant stop reasons safe to retain in diagnostics. */
|
|
10
|
+
export type SafeAssistantStopReason = "stop" | "length" | "toolUse" | "error" | "aborted";
|
|
11
|
+
|
|
12
|
+
type CompactionReason = "manual" | "threshold" | "overflow";
|
|
13
|
+
|
|
14
|
+
const SAFE_ASSISTANT_STOP_REASONS = new Set<SafeAssistantStopReason>([
|
|
15
|
+
"stop",
|
|
16
|
+
"length",
|
|
17
|
+
"toolUse",
|
|
18
|
+
"error",
|
|
19
|
+
"aborted",
|
|
20
|
+
]);
|
|
21
|
+
const COMPACTION_REASONS = new Set<CompactionReason>(["manual", "threshold", "overflow"]);
|
|
22
|
+
|
|
23
|
+
/** Return a stop reason only when it is one of Pi's defined lifecycle values. */
|
|
24
|
+
export function toSafeAssistantStopReason(value: unknown): SafeAssistantStopReason | undefined {
|
|
25
|
+
return typeof value === "string" &&
|
|
26
|
+
SAFE_ASSISTANT_STOP_REASONS.has(value as SafeAssistantStopReason)
|
|
27
|
+
? (value as SafeAssistantStopReason)
|
|
28
|
+
: undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Read the child session's active registered tool names without exposing failures. */
|
|
32
|
+
export function getRegisteredToolNames(session: AgentSession): Set<string> {
|
|
33
|
+
try {
|
|
34
|
+
const names = session.getActiveToolNames() as unknown;
|
|
35
|
+
return Array.isArray(names)
|
|
36
|
+
? new Set(names.filter((name): name is string => typeof name === "string"))
|
|
37
|
+
: new Set();
|
|
38
|
+
} catch {
|
|
39
|
+
return new Set();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One allowlisted lifecycle transition retained for a managed child session. */
|
|
44
|
+
export type ChildLifecycleTraceEntry =
|
|
45
|
+
| { type: "agent_start" }
|
|
46
|
+
| { type: "agent_end"; willRetry: boolean }
|
|
47
|
+
| { type: "agent_settled" }
|
|
48
|
+
| { type: "compaction_start"; reason: CompactionReason }
|
|
49
|
+
| {
|
|
50
|
+
type: "compaction_end";
|
|
51
|
+
reason: CompactionReason;
|
|
52
|
+
aborted: boolean;
|
|
53
|
+
willRetry: boolean;
|
|
54
|
+
hasResult: boolean;
|
|
55
|
+
hasError: boolean;
|
|
56
|
+
}
|
|
57
|
+
| { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number }
|
|
58
|
+
| { type: "auto_retry_end"; success: boolean; attempt: number; hasFinalError: boolean }
|
|
59
|
+
| {
|
|
60
|
+
type: "summarization_retry_scheduled";
|
|
61
|
+
attempt: number;
|
|
62
|
+
maxAttempts: number;
|
|
63
|
+
delayMs: number;
|
|
64
|
+
}
|
|
65
|
+
| {
|
|
66
|
+
type: "summarization_retry_attempt_start";
|
|
67
|
+
source: "branchSummary" | "compaction";
|
|
68
|
+
reason?: CompactionReason;
|
|
69
|
+
}
|
|
70
|
+
| { type: "summarization_retry_finished" }
|
|
71
|
+
| { type: "queue_update"; steeringCount: number; followUpCount: number }
|
|
72
|
+
| ChildLifecycleHostMarker;
|
|
73
|
+
|
|
74
|
+
/** Minimal runner-control transitions that explain host-originated termination. */
|
|
75
|
+
export type ChildLifecycleHostMarker =
|
|
76
|
+
| { type: "steer_requested"; reason: "submit" | "timeout" }
|
|
77
|
+
| { type: "timeout_expired" }
|
|
78
|
+
| { type: "abort_requested"; reason: "canceled" | "timeout" }
|
|
79
|
+
| { type: "prompt_rejected" };
|
|
80
|
+
|
|
81
|
+
/** Bounded observed tail of lifecycle transitions for one managed child session. */
|
|
82
|
+
export interface ChildLifecycleTrace {
|
|
83
|
+
entries: ChildLifecycleTraceEntry[];
|
|
84
|
+
droppedCount: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Retains only allowlisted lifecycle metadata for one managed child session.
|
|
89
|
+
*
|
|
90
|
+
* Entries are copied into a bounded observed tail so raw SDK events and their
|
|
91
|
+
* potentially sensitive payloads never escape the runner.
|
|
92
|
+
*/
|
|
93
|
+
export class ChildLifecycleTraceCollector {
|
|
94
|
+
private readonly entries: ChildLifecycleTraceEntry[] = [];
|
|
95
|
+
private readonly recentActivity: string[] = [];
|
|
96
|
+
private droppedCount = 0;
|
|
97
|
+
|
|
98
|
+
constructor(private readonly registeredToolNames: ReadonlySet<string> = new Set()) {}
|
|
99
|
+
|
|
100
|
+
/** Record one Pi session event when it belongs to the explicit allowlist. */
|
|
101
|
+
observe(event: AgentSessionEvent): void {
|
|
102
|
+
let entry: ChildLifecycleTraceEntry | undefined;
|
|
103
|
+
let activity: string | undefined;
|
|
104
|
+
try {
|
|
105
|
+
entry = mapLifecycleEvent(event);
|
|
106
|
+
activity = summarizeRecentActivity(event, this.registeredToolNames);
|
|
107
|
+
} catch {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (entry) this.push(entry);
|
|
112
|
+
if (activity) this.pushRecentActivity(activity);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Record an allowlisted runner-control transition. */
|
|
116
|
+
recordHostMarker(marker: ChildLifecycleHostMarker): void {
|
|
117
|
+
switch (marker.type) {
|
|
118
|
+
case "steer_requested":
|
|
119
|
+
if (marker.reason === "submit" || marker.reason === "timeout") {
|
|
120
|
+
this.push({ type: "steer_requested", reason: marker.reason });
|
|
121
|
+
}
|
|
122
|
+
break;
|
|
123
|
+
case "abort_requested":
|
|
124
|
+
if (marker.reason === "canceled" || marker.reason === "timeout") {
|
|
125
|
+
this.push({ type: "abort_requested", reason: marker.reason });
|
|
126
|
+
}
|
|
127
|
+
break;
|
|
128
|
+
case "timeout_expired":
|
|
129
|
+
this.push({ type: "timeout_expired" });
|
|
130
|
+
break;
|
|
131
|
+
case "prompt_rejected":
|
|
132
|
+
this.push({ type: "prompt_rejected" });
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Return an immutable-by-convention copy suitable for a failure artifact. */
|
|
138
|
+
snapshot(): ChildLifecycleTrace {
|
|
139
|
+
return {
|
|
140
|
+
entries: this.entries.map((entry) => ({ ...entry })),
|
|
141
|
+
droppedCount: this.droppedCount,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Return the separate, presentation-only Recent Activity lane. */
|
|
146
|
+
recentActivitySnapshot(): string[] {
|
|
147
|
+
return [...this.recentActivity];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private push(entry: ChildLifecycleTraceEntry): void {
|
|
151
|
+
this.entries.push(entry);
|
|
152
|
+
if (this.entries.length <= CHILD_LIFECYCLE_TRACE_MAX) return;
|
|
153
|
+
|
|
154
|
+
this.entries.shift();
|
|
155
|
+
this.droppedCount++;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private pushRecentActivity(activity: string): void {
|
|
159
|
+
this.recentActivity.push(activity);
|
|
160
|
+
if (this.recentActivity.length > RECENT_ACTIVITY_MAX) this.recentActivity.shift();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Format the complete retained Child Lifecycle Trace for parent-facing diagnostics. */
|
|
165
|
+
export function formatChildLifecycleTrace(trace: ChildLifecycleTrace): string {
|
|
166
|
+
const tail =
|
|
167
|
+
trace.droppedCount > 0
|
|
168
|
+
? `incomplete observed tail; ${trace.droppedCount} older ${trace.droppedCount === 1 ? "entry" : "entries"} dropped`
|
|
169
|
+
: "observed tail";
|
|
170
|
+
const entries = trace.entries.map(formatChildLifecycleTraceEntry);
|
|
171
|
+
const body = entries.length > 0 ? entries.join(" → ") : "(no observed lifecycle entries)";
|
|
172
|
+
return `Child Lifecycle Trace (${tail}): ${body}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function formatChildLifecycleTraceEntry(entry: ChildLifecycleTraceEntry): string {
|
|
176
|
+
switch (entry.type) {
|
|
177
|
+
case "agent_start":
|
|
178
|
+
case "agent_settled":
|
|
179
|
+
case "summarization_retry_finished":
|
|
180
|
+
case "timeout_expired":
|
|
181
|
+
case "prompt_rejected":
|
|
182
|
+
return entry.type;
|
|
183
|
+
case "agent_end":
|
|
184
|
+
return `agent_end(willRetry=${entry.willRetry})`;
|
|
185
|
+
case "compaction_start":
|
|
186
|
+
return `compaction_start(reason=${entry.reason})`;
|
|
187
|
+
case "compaction_end":
|
|
188
|
+
return `compaction_end(reason=${entry.reason}, aborted=${entry.aborted}, willRetry=${entry.willRetry}, hasResult=${entry.hasResult}, hasError=${entry.hasError})`;
|
|
189
|
+
case "auto_retry_start":
|
|
190
|
+
return `auto_retry_start(attempt=${entry.attempt}/${entry.maxAttempts}, delayMs=${entry.delayMs})`;
|
|
191
|
+
case "auto_retry_end":
|
|
192
|
+
return `auto_retry_end(success=${entry.success}, attempt=${entry.attempt}, hasFinalError=${entry.hasFinalError})`;
|
|
193
|
+
case "summarization_retry_scheduled":
|
|
194
|
+
return `summarization_retry_scheduled(attempt=${entry.attempt}/${entry.maxAttempts}, delayMs=${entry.delayMs})`;
|
|
195
|
+
case "summarization_retry_attempt_start":
|
|
196
|
+
return entry.reason
|
|
197
|
+
? `summarization_retry_attempt_start(source=${entry.source}, reason=${entry.reason})`
|
|
198
|
+
: `summarization_retry_attempt_start(source=${entry.source})`;
|
|
199
|
+
case "queue_update":
|
|
200
|
+
return `queue_update(steering=${entry.steeringCount}, followUp=${entry.followUpCount})`;
|
|
201
|
+
case "steer_requested":
|
|
202
|
+
return `steer_requested(reason=${entry.reason})`;
|
|
203
|
+
case "abort_requested":
|
|
204
|
+
return `abort_requested(reason=${entry.reason})`;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function summarizeRecentActivity(
|
|
209
|
+
event: AgentSessionEvent,
|
|
210
|
+
registeredToolNames: ReadonlySet<string>,
|
|
211
|
+
): string | undefined {
|
|
212
|
+
switch (event.type) {
|
|
213
|
+
case "message_end": {
|
|
214
|
+
const message = event.message as { role?: unknown; stopReason?: unknown };
|
|
215
|
+
if (message.role !== "assistant") return undefined;
|
|
216
|
+
const stopReason = toSafeAssistantStopReason(message.stopReason);
|
|
217
|
+
return `assistant:end${stopReason ? `:${stopReason}` : ""}`;
|
|
218
|
+
}
|
|
219
|
+
case "turn_start":
|
|
220
|
+
return "turn:start";
|
|
221
|
+
case "turn_end":
|
|
222
|
+
return "turn:end";
|
|
223
|
+
case "tool_execution_start":
|
|
224
|
+
return typeof event.toolName === "string" && registeredToolNames.has(event.toolName)
|
|
225
|
+
? `tool:start:${event.toolName}`
|
|
226
|
+
: undefined;
|
|
227
|
+
case "tool_execution_end":
|
|
228
|
+
return typeof event.toolName === "string" &&
|
|
229
|
+
typeof event.isError === "boolean" &&
|
|
230
|
+
registeredToolNames.has(event.toolName)
|
|
231
|
+
? `tool:end:${event.toolName}${event.isError ? ":error" : ""}`
|
|
232
|
+
: undefined;
|
|
233
|
+
default:
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isCompactionReason(value: unknown): value is CompactionReason {
|
|
239
|
+
return typeof value === "string" && COMPACTION_REASONS.has(value as CompactionReason);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function isSafeCount(value: unknown): value is number {
|
|
243
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one explicit allowlist keeps copied event fields auditable
|
|
247
|
+
function mapLifecycleEvent(event: AgentSessionEvent): ChildLifecycleTraceEntry | undefined {
|
|
248
|
+
switch (event.type) {
|
|
249
|
+
case "agent_start":
|
|
250
|
+
return { type: "agent_start" };
|
|
251
|
+
case "agent_end":
|
|
252
|
+
return typeof event.willRetry === "boolean"
|
|
253
|
+
? { type: "agent_end", willRetry: event.willRetry }
|
|
254
|
+
: undefined;
|
|
255
|
+
case "agent_settled":
|
|
256
|
+
return { type: "agent_settled" };
|
|
257
|
+
case "compaction_start":
|
|
258
|
+
return isCompactionReason(event.reason)
|
|
259
|
+
? { type: "compaction_start", reason: event.reason }
|
|
260
|
+
: undefined;
|
|
261
|
+
case "compaction_end":
|
|
262
|
+
return isCompactionReason(event.reason) &&
|
|
263
|
+
typeof event.aborted === "boolean" &&
|
|
264
|
+
typeof event.willRetry === "boolean"
|
|
265
|
+
? {
|
|
266
|
+
type: "compaction_end",
|
|
267
|
+
reason: event.reason,
|
|
268
|
+
aborted: event.aborted,
|
|
269
|
+
willRetry: event.willRetry,
|
|
270
|
+
hasResult: event.result !== undefined,
|
|
271
|
+
hasError: event.errorMessage !== undefined,
|
|
272
|
+
}
|
|
273
|
+
: undefined;
|
|
274
|
+
case "auto_retry_start":
|
|
275
|
+
return isSafeCount(event.attempt) &&
|
|
276
|
+
isSafeCount(event.maxAttempts) &&
|
|
277
|
+
isSafeCount(event.delayMs)
|
|
278
|
+
? {
|
|
279
|
+
type: "auto_retry_start",
|
|
280
|
+
attempt: event.attempt,
|
|
281
|
+
maxAttempts: event.maxAttempts,
|
|
282
|
+
delayMs: event.delayMs,
|
|
283
|
+
}
|
|
284
|
+
: undefined;
|
|
285
|
+
case "auto_retry_end":
|
|
286
|
+
return typeof event.success === "boolean" && isSafeCount(event.attempt)
|
|
287
|
+
? {
|
|
288
|
+
type: "auto_retry_end",
|
|
289
|
+
success: event.success,
|
|
290
|
+
attempt: event.attempt,
|
|
291
|
+
hasFinalError: event.finalError !== undefined,
|
|
292
|
+
}
|
|
293
|
+
: undefined;
|
|
294
|
+
case "summarization_retry_scheduled":
|
|
295
|
+
return isSafeCount(event.attempt) &&
|
|
296
|
+
isSafeCount(event.maxAttempts) &&
|
|
297
|
+
isSafeCount(event.delayMs)
|
|
298
|
+
? {
|
|
299
|
+
type: "summarization_retry_scheduled",
|
|
300
|
+
attempt: event.attempt,
|
|
301
|
+
maxAttempts: event.maxAttempts,
|
|
302
|
+
delayMs: event.delayMs,
|
|
303
|
+
}
|
|
304
|
+
: undefined;
|
|
305
|
+
case "summarization_retry_attempt_start":
|
|
306
|
+
if (event.source === "branchSummary") {
|
|
307
|
+
return { type: "summarization_retry_attempt_start", source: "branchSummary" };
|
|
308
|
+
}
|
|
309
|
+
return event.source === "compaction" && isCompactionReason(event.reason)
|
|
310
|
+
? {
|
|
311
|
+
type: "summarization_retry_attempt_start",
|
|
312
|
+
source: "compaction",
|
|
313
|
+
reason: event.reason,
|
|
314
|
+
}
|
|
315
|
+
: undefined;
|
|
316
|
+
case "summarization_retry_finished":
|
|
317
|
+
return { type: "summarization_retry_finished" };
|
|
318
|
+
case "queue_update":
|
|
319
|
+
return Array.isArray(event.steering) && Array.isArray(event.followUp)
|
|
320
|
+
? {
|
|
321
|
+
type: "queue_update",
|
|
322
|
+
steeringCount: event.steering.length,
|
|
323
|
+
followUpCount: event.followUp.length,
|
|
324
|
+
}
|
|
325
|
+
: undefined;
|
|
326
|
+
default:
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
@@ -4,17 +4,13 @@ import {
|
|
|
4
4
|
defineTool,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import type {
|
|
7
|
+
ChildFailureDiagnostics,
|
|
7
8
|
RawReviewResult,
|
|
8
9
|
ReviewInvocation,
|
|
9
10
|
ReviewOutputEvent,
|
|
10
11
|
ReviewProgress,
|
|
11
12
|
} from "../types.ts";
|
|
12
|
-
import {
|
|
13
|
-
buildFailureDebug,
|
|
14
|
-
extractLastAssistantDebug,
|
|
15
|
-
pushRecentEvent,
|
|
16
|
-
summarizeSessionEvent,
|
|
17
|
-
} from "./review-debug.ts";
|
|
13
|
+
import type { ChildLifecycleHostMarker } from "./child-lifecycle-trace.ts";
|
|
18
14
|
import { buildProgressTokens } from "./runner-helpers.ts";
|
|
19
15
|
import { reviewOutputSchema } from "./schemas.ts";
|
|
20
16
|
|
|
@@ -37,7 +33,8 @@ export interface RunnerContext {
|
|
|
37
33
|
submitSteered: boolean;
|
|
38
34
|
timeoutSteered: boolean;
|
|
39
35
|
graceTurnsRemaining: number | undefined;
|
|
40
|
-
|
|
36
|
+
getFailureDiagnostics: () => ChildFailureDiagnostics;
|
|
37
|
+
recordHostMarker: (marker: ChildLifecycleHostMarker) => void;
|
|
41
38
|
/** Accumulated per-tool-label execution counts. */
|
|
42
39
|
toolCounts: Record<string, number>;
|
|
43
40
|
/** Set of distinct file paths inspected via read_snapshot_diff / read_snapshot_file. */
|
|
@@ -233,6 +230,7 @@ export function handleMessageEnd(
|
|
|
233
230
|
if (msg.role !== "assistant" || msg.stopReason !== "stop") return;
|
|
234
231
|
|
|
235
232
|
ctx.submitSteered = true;
|
|
233
|
+
ctx.recordHostMarker({ type: "steer_requested", reason: "submit" });
|
|
236
234
|
ctx.session.steer(STEER_SUBMIT_MESSAGE).catch(() => {});
|
|
237
235
|
}
|
|
238
236
|
|
|
@@ -256,28 +254,19 @@ export function handleAgentSettled(ctx: RunnerContext): void {
|
|
|
256
254
|
return;
|
|
257
255
|
}
|
|
258
256
|
|
|
259
|
-
const lastText = extractLastAssistantDebug(ctx.session)?.text;
|
|
260
257
|
ctx.resolve(
|
|
261
258
|
ctx.cleanup({
|
|
262
259
|
kind: "failed",
|
|
263
|
-
|
|
264
|
-
? `Reviewer did not call submit_review. Assistant said: ${truncateText(lastText, 400)}`
|
|
265
|
-
: "Reviewer did not produce any output.",
|
|
260
|
+
failureCode: "missing-structured-output",
|
|
266
261
|
snapshot: ctx.invocation.snapshot,
|
|
267
262
|
brief: ctx.invocation.brief,
|
|
268
263
|
modelId: ctx.invocation.model.canonicalId,
|
|
269
|
-
|
|
270
|
-
progress: ctx.progress,
|
|
271
|
-
session: ctx.session,
|
|
272
|
-
recentEvents: ctx.debug.recentEvents,
|
|
273
|
-
}),
|
|
264
|
+
diagnostics: ctx.getFailureDiagnostics(),
|
|
274
265
|
}),
|
|
275
266
|
);
|
|
276
267
|
}
|
|
277
268
|
|
|
278
269
|
export function handleSessionEvent(event: AgentSessionEvent, ctx: RunnerContext): void {
|
|
279
|
-
pushRecentEvent(ctx.debug.recentEvents, summarizeSessionEvent(event));
|
|
280
|
-
|
|
281
270
|
switch (event.type) {
|
|
282
271
|
case "turn_end":
|
|
283
272
|
handleTurnEnd(ctx);
|
|
@@ -300,35 +289,25 @@ export function handleSessionEvent(event: AgentSessionEvent, ctx: RunnerContext)
|
|
|
300
289
|
}
|
|
301
290
|
}
|
|
302
291
|
|
|
303
|
-
function truncateText(text: string, maxLen: number): string {
|
|
304
|
-
if (text.length <= maxLen) return text;
|
|
305
|
-
return `${text.slice(0, maxLen)}... (${text.length - maxLen} more chars)`;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
292
|
// ---------------------------------------------------------------------------
|
|
309
293
|
// Hard-abort helper (called from handleTurnEnd when grace turns expire)
|
|
310
294
|
// ---------------------------------------------------------------------------
|
|
311
295
|
|
|
312
296
|
function doFinalAbort(ctx: RunnerContext): void {
|
|
313
297
|
emitProgress(ctx);
|
|
298
|
+
ctx.recordHostMarker({ type: "abort_requested", reason: "timeout" });
|
|
314
299
|
void ctx.session
|
|
315
300
|
.abort()
|
|
316
301
|
.catch(() => {})
|
|
317
302
|
.finally(() => {
|
|
318
|
-
const partialText = extractLastAssistantDebug(ctx.session)?.text;
|
|
319
303
|
ctx.resolve(
|
|
320
304
|
ctx.cleanup({
|
|
321
305
|
kind: "timeout" as const,
|
|
322
306
|
snapshot: ctx.invocation.snapshot,
|
|
323
307
|
timeoutMs: ctx.invocation.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
324
|
-
partialOutput: partialText,
|
|
325
308
|
brief: ctx.invocation.brief,
|
|
326
309
|
modelId: ctx.invocation.model.canonicalId,
|
|
327
|
-
|
|
328
|
-
progress: ctx.progress,
|
|
329
|
-
session: ctx.session,
|
|
330
|
-
recentEvents: ctx.debug.recentEvents,
|
|
331
|
-
}),
|
|
310
|
+
diagnostics: ctx.getFailureDiagnostics(),
|
|
332
311
|
}),
|
|
333
312
|
);
|
|
334
313
|
});
|