@mrclrchtr/supi-review 1.11.2 → 1.12.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.
@@ -0,0 +1,240 @@
1
+ import {
2
+ type AgentSession,
3
+ type AgentSessionEvent,
4
+ defineTool,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import type {
7
+ RawReviewResult,
8
+ ReviewInvocation,
9
+ ReviewOutputEvent,
10
+ ReviewProgress,
11
+ } from "../types.ts";
12
+ import {
13
+ buildFailureDebug,
14
+ extractLastAssistantDebug,
15
+ pushRecentEvent,
16
+ summarizeSessionEvent,
17
+ } from "./review-debug.ts";
18
+ import { buildProgressTokens } from "./runner-helpers.ts";
19
+ import { reviewOutputSchema } from "./schemas.ts";
20
+
21
+ const STEER_SUBMIT_MESSAGE =
22
+ "You stopped without calling submit_review. Call submit_review now with your findings.";
23
+ const DEFAULT_TIMEOUT_MS = 20 * 60 * 1_000;
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Runner-specific context combining lifecycle-managed fields with
27
+ // runner-private mutable state.
28
+ // ---------------------------------------------------------------------------
29
+ export interface RunnerContext {
30
+ progress: ReviewProgress;
31
+ session: AgentSession;
32
+ resolve: (result: RawReviewResult) => void;
33
+ cleanup: (result: RawReviewResult) => RawReviewResult;
34
+ state: { settled: boolean; aborting: boolean };
35
+ resultHolder: { value: ReviewOutputEvent | undefined };
36
+ invocation: ReviewInvocation;
37
+ submitSteered: boolean;
38
+ timeoutSteered: boolean;
39
+ graceTurnsRemaining: number | undefined;
40
+ debug: { recentEvents: string[] };
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Tool and progress helpers
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /** Maps tool names to human-readable activity descriptions. */
48
+ function toolNameToActivity(name: string, phase: "start" | "end"): string {
49
+ if (phase === "end") return "";
50
+ const map: Record<string, string> = {
51
+ read: "reading",
52
+ grep: "searching",
53
+ find: "finding files",
54
+ ls: "listing files",
55
+ submit_review: "submitting review",
56
+ read_snapshot_diff: "reading diff",
57
+ read_snapshot_file: "reading file",
58
+ };
59
+ return map[name] ?? name;
60
+ }
61
+
62
+ export function createSubmitReviewTool(resultHolder: {
63
+ value: ReviewOutputEvent | undefined;
64
+ }): ReturnType<typeof defineTool> {
65
+ return defineTool({
66
+ name: "submit_review",
67
+ label: "Submit Review",
68
+ description:
69
+ "Submit the final structured review result after you finish investigating the changes.",
70
+ parameters: reviewOutputSchema,
71
+ execute: async (_toolCallId, args) => {
72
+ resultHolder.value = args as ReviewOutputEvent;
73
+ return {
74
+ content: [{ type: "text" as const, text: "Review submitted successfully." }],
75
+ details: args,
76
+ terminate: true,
77
+ };
78
+ },
79
+ });
80
+ }
81
+
82
+ export function emitProgress(ctx: RunnerContext): void {
83
+ ctx.progress.tokens = buildProgressTokens(() => ctx.session.getSessionStats());
84
+ ctx.invocation.onProgress?.({ ...ctx.progress, activities: [...ctx.progress.activities] });
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Event handlers
89
+ // ---------------------------------------------------------------------------
90
+
91
+ export function handleTurnEnd(ctx: RunnerContext): void {
92
+ ctx.progress.turns++;
93
+ ctx.progress.activities = [];
94
+
95
+ if (!ctx.state.settled && ctx.timeoutSteered && ctx.graceTurnsRemaining !== undefined) {
96
+ ctx.graceTurnsRemaining--;
97
+ if (ctx.graceTurnsRemaining <= 0) {
98
+ ctx.state.aborting = true;
99
+ doFinalAbort(ctx);
100
+ }
101
+ }
102
+
103
+ emitProgress(ctx);
104
+ }
105
+
106
+ export function handleToolStart(
107
+ event: Extract<AgentSessionEvent, { type: "tool_execution_start" }>,
108
+ ctx: RunnerContext,
109
+ ): void {
110
+ ctx.progress.toolUses++;
111
+ const activity = toolNameToActivity(event.toolName, "start");
112
+ if (activity) ctx.progress.activities.push(activity);
113
+ ctx.invocation.onToolActivity?.({ toolName: event.toolName, phase: "start" });
114
+ emitProgress(ctx);
115
+ }
116
+
117
+ export function handleToolEnd(
118
+ event: Extract<AgentSessionEvent, { type: "tool_execution_end" }>,
119
+ ctx: RunnerContext,
120
+ ): void {
121
+ const activity = toolNameToActivity(event.toolName, "start");
122
+ if (activity) {
123
+ const index = ctx.progress.activities.indexOf(activity);
124
+ if (index !== -1) ctx.progress.activities.splice(index, 1);
125
+ }
126
+ ctx.invocation.onToolActivity?.({ toolName: event.toolName, phase: "end" });
127
+ emitProgress(ctx);
128
+ }
129
+
130
+ export function handleMessageEnd(
131
+ event: Extract<AgentSessionEvent, { type: "message_end" }>,
132
+ ctx: RunnerContext,
133
+ ): void {
134
+ if (ctx.state.settled || ctx.submitSteered || ctx.resultHolder.value) return;
135
+
136
+ const msg = event.message as { role?: string; stopReason?: string };
137
+ if (msg.role !== "assistant" || msg.stopReason !== "stop") return;
138
+
139
+ ctx.submitSteered = true;
140
+ ctx.session.steer(STEER_SUBMIT_MESSAGE).catch(() => {});
141
+ }
142
+
143
+ export function handleAgentEnd(
144
+ event: Extract<AgentSessionEvent, { type: "agent_end" }>,
145
+ ctx: RunnerContext,
146
+ ): void {
147
+ if (ctx.state.settled || ctx.state.aborting) return;
148
+ const retryAwareEvent = event as typeof event & { willRetry?: boolean };
149
+ if (retryAwareEvent.willRetry) return;
150
+
151
+ if (ctx.resultHolder.value) {
152
+ ctx.resolve(
153
+ ctx.cleanup({
154
+ kind: "success",
155
+ output: ctx.resultHolder.value,
156
+ snapshot: ctx.invocation.snapshot,
157
+ brief: ctx.invocation.brief,
158
+ modelId: ctx.invocation.model.canonicalId,
159
+ }),
160
+ );
161
+ return;
162
+ }
163
+
164
+ const lastText = extractLastAssistantDebug(ctx.session)?.text;
165
+ ctx.resolve(
166
+ ctx.cleanup({
167
+ kind: "failed",
168
+ reason: lastText
169
+ ? `Reviewer did not call submit_review. Assistant said: ${truncateText(lastText, 400)}`
170
+ : "Reviewer did not produce any output.",
171
+ snapshot: ctx.invocation.snapshot,
172
+ brief: ctx.invocation.brief,
173
+ modelId: ctx.invocation.model.canonicalId,
174
+ debug: buildFailureDebug({
175
+ progress: ctx.progress,
176
+ session: ctx.session,
177
+ recentEvents: ctx.debug.recentEvents,
178
+ }),
179
+ }),
180
+ );
181
+ }
182
+
183
+ export function handleSessionEvent(event: AgentSessionEvent, ctx: RunnerContext): void {
184
+ pushRecentEvent(ctx.debug.recentEvents, summarizeSessionEvent(event));
185
+
186
+ switch (event.type) {
187
+ case "turn_end":
188
+ handleTurnEnd(ctx);
189
+ break;
190
+ case "tool_execution_start":
191
+ handleToolStart(event, ctx);
192
+ break;
193
+ case "tool_execution_end":
194
+ handleToolEnd(event, ctx);
195
+ break;
196
+ case "message_end": {
197
+ handleMessageEnd(event, ctx);
198
+ break;
199
+ }
200
+ case "agent_end":
201
+ handleAgentEnd(event, ctx);
202
+ break;
203
+ default:
204
+ break;
205
+ }
206
+ }
207
+
208
+ function truncateText(text: string, maxLen: number): string {
209
+ if (text.length <= maxLen) return text;
210
+ return `${text.slice(0, maxLen)}... (${text.length - maxLen} more chars)`;
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Hard-abort helper (called from handleTurnEnd when grace turns expire)
215
+ // ---------------------------------------------------------------------------
216
+
217
+ function doFinalAbort(ctx: RunnerContext): void {
218
+ emitProgress(ctx);
219
+ void ctx.session
220
+ .abort()
221
+ .catch(() => {})
222
+ .finally(() => {
223
+ const partialText = extractLastAssistantDebug(ctx.session)?.text;
224
+ ctx.resolve(
225
+ ctx.cleanup({
226
+ kind: "timeout" as const,
227
+ snapshot: ctx.invocation.snapshot,
228
+ timeoutMs: ctx.invocation.timeoutMs ?? DEFAULT_TIMEOUT_MS,
229
+ partialOutput: partialText,
230
+ brief: ctx.invocation.brief,
231
+ modelId: ctx.invocation.model.canonicalId,
232
+ debug: buildFailureDebug({
233
+ progress: ctx.progress,
234
+ session: ctx.session,
235
+ recentEvents: ctx.debug.recentEvents,
236
+ }),
237
+ }),
238
+ );
239
+ });
240
+ }