@phi-code-admin/phi-code 0.86.0 → 0.87.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/CHANGELOG.md +43 -0
- package/docs/fork-policy.md +18 -0
- package/extensions/phi/agents.ts +7 -4
- package/extensions/phi/benchmark.ts +129 -49
- package/extensions/phi/browser.ts +10 -37
- package/extensions/phi/btw/btw.ts +1 -7
- package/extensions/phi/chrome/index.ts +1283 -741
- package/extensions/phi/commit.ts +9 -14
- package/extensions/phi/goal/index.ts +10 -33
- package/extensions/phi/init.ts +37 -47
- package/extensions/phi/keys.ts +2 -7
- package/extensions/phi/mcp/callback-server.ts +162 -165
- package/extensions/phi/mcp/config.ts +122 -136
- package/extensions/phi/mcp/errors.ts +18 -23
- package/extensions/phi/mcp/index.ts +322 -355
- package/extensions/phi/mcp/oauth-provider.ts +289 -289
- package/extensions/phi/mcp/server-manager.ts +390 -413
- package/extensions/phi/mcp/tool-bridge.ts +381 -415
- package/extensions/phi/memory.ts +2 -2
- package/extensions/phi/models.ts +27 -26
- package/extensions/phi/orchestrator.ts +338 -229
- package/extensions/phi/productivity.ts +4 -2
- package/extensions/phi/providers/agent-def.ts +1 -1
- package/extensions/phi/providers/alibaba.ts +56 -7
- package/extensions/phi/providers/context-window.ts +4 -1
- package/extensions/phi/providers/live-models.ts +5 -20
- package/extensions/phi/providers/orchestrator-helpers.ts +19 -5
- package/extensions/phi/providers/phase-machine.ts +220 -0
- package/extensions/phi/setup.ts +196 -169
- package/extensions/phi/skill-loader.ts +14 -17
- package/extensions/phi/smart-router.ts +6 -6
- package/extensions/phi/web-search.ts +90 -50
- package/package.json +1 -1
|
@@ -332,7 +332,8 @@ export default function productivityExtension(pi: ExtensionAPI) {
|
|
|
332
332
|
// /title
|
|
333
333
|
// ------------------------------------------------------------------------
|
|
334
334
|
pi.registerCommand("title", {
|
|
335
|
-
description:
|
|
335
|
+
description:
|
|
336
|
+
"Derive a session title + kebab-case branch name from the first user message (deterministic, no LLM).",
|
|
336
337
|
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
337
338
|
try {
|
|
338
339
|
const source = firstUserMessageText(ctx);
|
|
@@ -387,7 +388,8 @@ export default function productivityExtension(pi: ExtensionAPI) {
|
|
|
387
388
|
// /dream
|
|
388
389
|
// ------------------------------------------------------------------------
|
|
389
390
|
pi.registerCommand("dream", {
|
|
390
|
-
description:
|
|
391
|
+
description:
|
|
392
|
+
"Deterministic memory consolidation: report exact-duplicate notes that could be merged (no deletion).",
|
|
391
393
|
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
392
394
|
try {
|
|
393
395
|
const files = memory.notes.list();
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* resolves to a real agents/ directory in both.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { existsSync,
|
|
18
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
19
19
|
import { homedir } from "node:os";
|
|
20
20
|
import { basename, join } from "node:path";
|
|
21
21
|
|
|
@@ -51,16 +51,65 @@ export interface AlibabaModelSpec {
|
|
|
51
51
|
* are multimodal); the qwen3-coder / qwen3-max / glm / MiniMax entries are text.
|
|
52
52
|
*/
|
|
53
53
|
export const ALIBABA_MODELS: readonly AlibabaModelSpec[] = [
|
|
54
|
-
{
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
54
|
+
{
|
|
55
|
+
id: "qwen3.7-plus",
|
|
56
|
+
name: "Qwen 3.7 Plus",
|
|
57
|
+
contextWindow: 1_000_000,
|
|
58
|
+
maxTokens: 16_384,
|
|
59
|
+
reasoning: true,
|
|
60
|
+
vision: true,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "qwen3.6-plus",
|
|
64
|
+
name: "Qwen 3.6 Plus",
|
|
65
|
+
contextWindow: 1_000_000,
|
|
66
|
+
maxTokens: 16_384,
|
|
67
|
+
reasoning: true,
|
|
68
|
+
vision: true,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: "qwen3.5-plus",
|
|
72
|
+
name: "Qwen 3.5 Plus",
|
|
73
|
+
contextWindow: 1_000_000,
|
|
74
|
+
maxTokens: 16_384,
|
|
75
|
+
reasoning: true,
|
|
76
|
+
vision: true,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: "qwen3-max-2026-01-23",
|
|
80
|
+
name: "Qwen 3 Max",
|
|
81
|
+
contextWindow: 262_144,
|
|
82
|
+
maxTokens: 16_384,
|
|
83
|
+
reasoning: true,
|
|
84
|
+
vision: false,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: "qwen3-coder-plus",
|
|
88
|
+
name: "Qwen 3 Coder Plus",
|
|
89
|
+
contextWindow: 1_000_000,
|
|
90
|
+
maxTokens: 16_384,
|
|
91
|
+
reasoning: true,
|
|
92
|
+
vision: false,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: "qwen3-coder-next",
|
|
96
|
+
name: "Qwen 3 Coder Next",
|
|
97
|
+
contextWindow: 1_000_000,
|
|
98
|
+
maxTokens: 16_384,
|
|
99
|
+
reasoning: true,
|
|
100
|
+
vision: false,
|
|
101
|
+
},
|
|
60
102
|
{ id: "kimi-k2.5", name: "Kimi K2.5", contextWindow: 262_144, maxTokens: 16_384, reasoning: true, vision: true },
|
|
61
103
|
{ id: "glm-5", name: "GLM 5", contextWindow: 200_000, maxTokens: 128_000, reasoning: true, vision: false },
|
|
62
104
|
{ id: "glm-4.7", name: "GLM 4.7", contextWindow: 200_000, maxTokens: 128_000, reasoning: true, vision: false },
|
|
63
|
-
{
|
|
105
|
+
{
|
|
106
|
+
id: "MiniMax-M2.5",
|
|
107
|
+
name: "MiniMax M2.5",
|
|
108
|
+
contextWindow: 1_000_000,
|
|
109
|
+
maxTokens: 16_384,
|
|
110
|
+
reasoning: true,
|
|
111
|
+
vision: false,
|
|
112
|
+
},
|
|
64
113
|
] as const;
|
|
65
114
|
|
|
66
115
|
export const ALIBABA_RATE_LIMITS = {
|
|
@@ -44,7 +44,10 @@ export function inferContextWindow(modelId: string, apiValue?: number, providerI
|
|
|
44
44
|
|
|
45
45
|
/** Parse a context-window value like "256k", "1M", "1.5m", or "200000". */
|
|
46
46
|
export function parseContextWindow(input: string): number | undefined {
|
|
47
|
-
const m = input
|
|
47
|
+
const m = input
|
|
48
|
+
.trim()
|
|
49
|
+
.toLowerCase()
|
|
50
|
+
.match(/^(\d+(?:\.\d+)?)\s*([km])?$/);
|
|
48
51
|
if (!m) return undefined;
|
|
49
52
|
const n = Number.parseFloat(m[1]);
|
|
50
53
|
if (!Number.isFinite(n) || n <= 0) return undefined;
|
|
@@ -19,13 +19,9 @@
|
|
|
19
19
|
* are exposed so the wizard can show *something* before the first live fetch.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import {
|
|
23
|
-
OPENCODE_GO_FALLBACK_MODELS,
|
|
24
|
-
getOpenCodeGoModels,
|
|
25
|
-
pingOpenCodeGo,
|
|
26
|
-
} from "./opencode-go.js";
|
|
27
22
|
import { ALIBABA_MODELS, ALIBABA_PROVIDERS, pingAlibaba } from "./alibaba.js";
|
|
28
23
|
import { inferContextWindow } from "./context-window.js";
|
|
24
|
+
import { getOpenCodeGoModels, OPENCODE_GO_FALLBACK_MODELS, pingOpenCodeGo } from "./opencode-go.js";
|
|
29
25
|
|
|
30
26
|
export const LAST_VERIFIED = "2026-07-10";
|
|
31
27
|
|
|
@@ -66,11 +62,7 @@ function isCacheValid(entry: CacheEntry | undefined, now: number): boolean {
|
|
|
66
62
|
return entry !== undefined && now - entry.fetchedAt < CACHE_TTL_MS;
|
|
67
63
|
}
|
|
68
64
|
|
|
69
|
-
async function fetchJson(
|
|
70
|
-
url: string,
|
|
71
|
-
headers: Record<string, string>,
|
|
72
|
-
timeoutMs: number,
|
|
73
|
-
): Promise<unknown> {
|
|
65
|
+
async function fetchJson(url: string, headers: Record<string, string>, timeoutMs: number): Promise<unknown> {
|
|
74
66
|
const controller = new AbortController();
|
|
75
67
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
76
68
|
try {
|
|
@@ -401,10 +393,7 @@ async function dispatchFetch(providerId: string, options: FetchOptions): Promise
|
|
|
401
393
|
* 3. Previous cache entry, if any (even if stale).
|
|
402
394
|
* 4. Static fallback (versioned).
|
|
403
395
|
*/
|
|
404
|
-
export async function fetchLiveModels(
|
|
405
|
-
providerId: string,
|
|
406
|
-
options: FetchOptions = {},
|
|
407
|
-
): Promise<LiveModelsResult> {
|
|
396
|
+
export async function fetchLiveModels(providerId: string, options: FetchOptions = {}): Promise<LiveModelsResult> {
|
|
408
397
|
const now = Date.now();
|
|
409
398
|
const force = options.forceRefresh === true;
|
|
410
399
|
|
|
@@ -425,9 +414,7 @@ export async function fetchLiveModels(
|
|
|
425
414
|
const models = await promise;
|
|
426
415
|
if (models.length === 0) {
|
|
427
416
|
const fallback = staticFallbackFor(providerId);
|
|
428
|
-
return fallback.length > 0
|
|
429
|
-
? { models: fallback, source: "fallback" }
|
|
430
|
-
: { models: [], source: "unsupported" };
|
|
417
|
+
return fallback.length > 0 ? { models: fallback, source: "fallback" } : { models: [], source: "unsupported" };
|
|
431
418
|
}
|
|
432
419
|
cache.set(providerId, { models, fetchedAt: now });
|
|
433
420
|
return { models, source: "live" };
|
|
@@ -466,9 +453,7 @@ export async function pingProvider(
|
|
|
466
453
|
default: {
|
|
467
454
|
try {
|
|
468
455
|
const models = await dispatchFetch(providerId, { apiKey, timeoutMs, forceRefresh: true });
|
|
469
|
-
return models.length > 0
|
|
470
|
-
? { ok: true }
|
|
471
|
-
: { ok: false, error: "no models returned" };
|
|
456
|
+
return models.length > 0 ? { ok: true } : { ok: false, error: "no models returned" };
|
|
472
457
|
} catch (err) {
|
|
473
458
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
474
459
|
}
|
|
@@ -21,14 +21,28 @@ export function parsePhaseVerdict(content: string): PhaseVerdict | null {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* Extract the body of a
|
|
25
|
-
*
|
|
24
|
+
* Extract the body of a section by name (e.g. "BLOCKING", "HANDOFF"), up to the
|
|
25
|
+
* next section header or end of file. Returns "" if absent.
|
|
26
|
+
*
|
|
27
|
+
* Tolerant of the three header shapes models actually emit for the same
|
|
28
|
+
* section: a markdown heading (`## HANDOFF`), a standalone bold label
|
|
29
|
+
* (`**HANDOFF**`), or a plain label line (`HANDOFF:`). Termination stops at the
|
|
30
|
+
* next markdown heading or the next standalone bold/plain label line, so a
|
|
31
|
+
* report written entirely with bold labels still splits into sections instead
|
|
32
|
+
* of the first one swallowing the rest.
|
|
26
33
|
*/
|
|
27
34
|
export function extractSection(content: string, heading: string): string {
|
|
28
35
|
if (!content) return "";
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
36
|
+
// Header line: optional leading heading hashes and/or bold stars, then the name.
|
|
37
|
+
const start = new RegExp(`(?:^|\\n)[ \\t]{0,3}(?:#{1,6}[ \\t]*)?\\*{0,2}[ \\t]*${heading}\\b[^\\n]*\\n`, "i");
|
|
38
|
+
const m = start.exec(content);
|
|
39
|
+
if (!m) return "";
|
|
40
|
+
const rest = content.slice(m.index + m[0].length);
|
|
41
|
+
// Next section boundary: a markdown heading line, or a line that is ONLY a
|
|
42
|
+
// bold label (`**LABEL**`) — not inline bold inside a bullet, which would
|
|
43
|
+
// truncate the body.
|
|
44
|
+
const end = rest.search(/\n[ \t]{0,3}(?:#{1,6}[ \t]|\*{2}[A-Za-z][^\n]*\*{2}[ \t]*(?:\n|$))/);
|
|
45
|
+
return (end === -1 ? rest : rest.slice(0, end)).trim();
|
|
32
46
|
}
|
|
33
47
|
|
|
34
48
|
export function extractBlockingFindings(content: string): string {
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure decision core of the /plan orchestrator's phase state machine.
|
|
3
|
+
*
|
|
4
|
+
* orchestrator.ts owns the I/O (UI notifications, queue mutation, checkpoint
|
|
5
|
+
* files, model switching); everything DECIDABLE from a finished phase's
|
|
6
|
+
* messages and report lives here so the exact transition behavior is unit
|
|
7
|
+
* tested — including what happens when a model deviates from the text
|
|
8
|
+
* contracts (missing report, unparseable verdict, zero tool calls).
|
|
9
|
+
*
|
|
10
|
+
* Behavior contract (mirrors the agent_end hook, in priority order):
|
|
11
|
+
* user abort > auth error (401) > transient retry (once, fallback model)
|
|
12
|
+
* > BLOCKED pause > review-FAIL fix cycle (once, unless looping)
|
|
13
|
+
* > continue (with diagnostics).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { isTransientError, type PhaseVerdict } from "./orchestrator-helpers.js";
|
|
17
|
+
|
|
18
|
+
export interface PhaseEndAnalysis {
|
|
19
|
+
userAborted: boolean;
|
|
20
|
+
hasAuthError: boolean;
|
|
21
|
+
transient: boolean;
|
|
22
|
+
toolCallCount: number;
|
|
23
|
+
filesWritten: string[];
|
|
24
|
+
filesEdited: string[];
|
|
25
|
+
errorsHit: string[];
|
|
26
|
+
testResults: string[];
|
|
27
|
+
calledMemorySearch: boolean;
|
|
28
|
+
calledMemoryWrite: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface LooseMessage {
|
|
32
|
+
role?: string;
|
|
33
|
+
content?: unknown;
|
|
34
|
+
name?: string;
|
|
35
|
+
toolName?: string;
|
|
36
|
+
isError?: boolean;
|
|
37
|
+
stopReason?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function messageText(msg: LooseMessage): string {
|
|
41
|
+
if (typeof msg.content === "string") return msg.content;
|
|
42
|
+
if (Array.isArray(msg.content)) {
|
|
43
|
+
return msg.content.map((c: { text?: string }) => c?.text || "").join("");
|
|
44
|
+
}
|
|
45
|
+
return JSON.stringify(msg.content ?? "");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Analyze a finished phase's message list into the facts the transition
|
|
50
|
+
* decision needs. Pure: no I/O, no state.
|
|
51
|
+
*/
|
|
52
|
+
export function analyzePhaseMessages(rawMessages: readonly unknown[]): PhaseEndAnalysis {
|
|
53
|
+
const messages = (rawMessages || []) as LooseMessage[];
|
|
54
|
+
|
|
55
|
+
const userAborted = messages.some((m) => m.role === "assistant" && m.stopReason === "aborted");
|
|
56
|
+
|
|
57
|
+
const hasAuthError = messages.some((m) => {
|
|
58
|
+
const content = typeof m.content === "string" ? m.content : JSON.stringify(m.content || "");
|
|
59
|
+
return (
|
|
60
|
+
content.includes("401") &&
|
|
61
|
+
(content.includes("invalid access token") ||
|
|
62
|
+
content.includes("token expired") ||
|
|
63
|
+
content.includes("Unauthorized"))
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const filesWritten: string[] = [];
|
|
68
|
+
const filesEdited: string[] = [];
|
|
69
|
+
const errorsHit: string[] = [];
|
|
70
|
+
const testResults: string[] = [];
|
|
71
|
+
let toolCallCount = 0;
|
|
72
|
+
const toolNames: string[] = [];
|
|
73
|
+
|
|
74
|
+
for (const msg of messages) {
|
|
75
|
+
// Pi uses role: "toolResult" instead of "tool"
|
|
76
|
+
if (msg.role !== "tool" && msg.role !== "function" && msg.role !== "toolResult") continue;
|
|
77
|
+
toolCallCount++;
|
|
78
|
+
const content = messageText(msg);
|
|
79
|
+
const name = msg.name || msg.toolName || "";
|
|
80
|
+
toolNames.push(name);
|
|
81
|
+
|
|
82
|
+
// Track writes
|
|
83
|
+
if (name === "write" && content.includes("Successfully wrote")) {
|
|
84
|
+
const match = content.match(/wrote \d+ bytes to (.+)/);
|
|
85
|
+
if (match) filesWritten.push(match[1]);
|
|
86
|
+
}
|
|
87
|
+
// Track edits — the edit tool returns "Successfully replaced N block(s) in <path>."
|
|
88
|
+
// Anchor the path capture so it does not over-capture unrelated text.
|
|
89
|
+
if (name === "edit" && !content.includes("ERR") && !msg.isError) {
|
|
90
|
+
const match = content.match(/replaced \d+ block\(s\) in (.+?)\.?$/m);
|
|
91
|
+
if (match) filesEdited.push(match[1]);
|
|
92
|
+
}
|
|
93
|
+
// Track errors — but filter out edit retries (old_text mismatch = normal retry, not error)
|
|
94
|
+
if (
|
|
95
|
+
(content.includes("ERR:") || content.includes("Error:") || content.includes("FAIL")) &&
|
|
96
|
+
!content.includes("old text must match") &&
|
|
97
|
+
!content.includes("The old text") &&
|
|
98
|
+
!content.includes("oldText not found") &&
|
|
99
|
+
!content.includes("old_text not found")
|
|
100
|
+
) {
|
|
101
|
+
const preview = content.slice(0, 150).replace(/\n/g, " ");
|
|
102
|
+
errorsHit.push(`${name}: ${preview}`);
|
|
103
|
+
}
|
|
104
|
+
// Track test results
|
|
105
|
+
if (content.includes("PASS") || content.includes("✅") || content.includes("✗") || content.includes("❌")) {
|
|
106
|
+
const lines = content.split("\n").filter((l: string) => /PASS|FAIL|✅|❌|✗/.test(l));
|
|
107
|
+
testResults.push(...lines.slice(0, 10));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
userAborted,
|
|
113
|
+
hasAuthError,
|
|
114
|
+
transient: isTransientError(rawMessages),
|
|
115
|
+
toolCallCount,
|
|
116
|
+
filesWritten,
|
|
117
|
+
filesEdited,
|
|
118
|
+
errorsHit,
|
|
119
|
+
testResults,
|
|
120
|
+
calledMemorySearch: toolNames.includes("memory_search"),
|
|
121
|
+
calledMemoryWrite: toolNames.includes("memory_write"),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Build the heuristic summary injected into the next phase's instruction. */
|
|
126
|
+
export function buildPhaseSummary(analysis: PhaseEndAnalysis, maxToolCallsPerPhase: number): string {
|
|
127
|
+
const parts: string[] = [];
|
|
128
|
+
parts.push(`Tool calls: ${analysis.toolCallCount}`);
|
|
129
|
+
if (analysis.toolCallCount > maxToolCallsPerPhase) {
|
|
130
|
+
parts.push(
|
|
131
|
+
`⚠️ WARNING: Phase used ${analysis.toolCallCount} tool calls (limit: ${maxToolCallsPerPhase}). Possible loop detected.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (analysis.filesWritten.length > 0) parts.push(`Files created/written: ${analysis.filesWritten.join(", ")}`);
|
|
135
|
+
if (analysis.filesEdited.length > 0) parts.push(`Files edited: ${analysis.filesEdited.join(", ")}`);
|
|
136
|
+
if (analysis.testResults.length > 0) parts.push(`Test results:\n${analysis.testResults.join("\n")}`);
|
|
137
|
+
if (analysis.errorsHit.length > 0)
|
|
138
|
+
parts.push(`Errors encountered: ${analysis.errorsHit.length}\n${analysis.errorsHit.slice(0, 5).join("\n")}`);
|
|
139
|
+
if (!analysis.calledMemorySearch) parts.push(`⚠️ Phase did NOT call memory_search (mandatory)`);
|
|
140
|
+
if (!analysis.calledMemoryWrite) parts.push(`⚠️ Phase did NOT call memory_write (mandatory)`);
|
|
141
|
+
return parts.join("\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Prefer the phase's canonical "## HANDOFF" block over the heuristic summary;
|
|
146
|
+
* fall back to the heuristic when the model did not write one.
|
|
147
|
+
*/
|
|
148
|
+
export function buildNextBrief(
|
|
149
|
+
analysis: PhaseEndAnalysis,
|
|
150
|
+
handoff: string,
|
|
151
|
+
phaseLabel: string,
|
|
152
|
+
maxToolCallsPerPhase: number,
|
|
153
|
+
): string {
|
|
154
|
+
return handoff
|
|
155
|
+
? `Tool calls: ${analysis.toolCallCount}\n## HANDOFF (from ${phaseLabel})\n${handoff}`
|
|
156
|
+
: buildPhaseSummary(analysis, maxToolCallsPerPhase);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export type PhaseDecision =
|
|
160
|
+
| { action: "stop"; reason: "user-abort" | "auth-error" }
|
|
161
|
+
| { action: "retry-fallback" }
|
|
162
|
+
| { action: "pause-blocked" }
|
|
163
|
+
| { action: "review-fix-cycle" }
|
|
164
|
+
| {
|
|
165
|
+
action: "continue";
|
|
166
|
+
/** The phase answered with prose only — warn and nudge the next phase. */
|
|
167
|
+
zeroToolCalls: boolean;
|
|
168
|
+
/**
|
|
169
|
+
* The phase was expected to write a "VERDICT:" line but none parsed —
|
|
170
|
+
* a model deviated from the text contract. Surface it instead of
|
|
171
|
+
* silently treating the phase as passed.
|
|
172
|
+
*/
|
|
173
|
+
missingVerdict: boolean;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export interface PhaseDecisionInput {
|
|
177
|
+
analysis: PhaseEndAnalysis;
|
|
178
|
+
/** Currently executing phase, or null when the queue raced. */
|
|
179
|
+
phase: { key: string; retried?: boolean } | null;
|
|
180
|
+
/** Verdict parsed from the phase's report file (null = absent/unparseable). */
|
|
181
|
+
verdict: PhaseVerdict | null;
|
|
182
|
+
/** Whether a report file existed at all for this phase. */
|
|
183
|
+
hasReport: boolean;
|
|
184
|
+
/** Completed review-fix cycles so far (bounded to one). */
|
|
185
|
+
reviewFixRounds: number;
|
|
186
|
+
maxToolCallsPerPhase: number;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Phases whose contract REQUIRES a leading "VERDICT:" line in their report. */
|
|
190
|
+
const VERDICT_PHASES = new Set(["test", "review"]);
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Decide the transition after a phase's agent loop completed. Pure — the
|
|
194
|
+
* caller interprets the decision (notifications, queue edits, checkpoints).
|
|
195
|
+
*/
|
|
196
|
+
export function decidePhaseTransition(input: PhaseDecisionInput): PhaseDecision {
|
|
197
|
+
const { analysis, phase, verdict, hasReport, reviewFixRounds, maxToolCallsPerPhase } = input;
|
|
198
|
+
|
|
199
|
+
if (analysis.userAborted) return { action: "stop", reason: "user-abort" };
|
|
200
|
+
if (analysis.hasAuthError) return { action: "stop", reason: "auth-error" };
|
|
201
|
+
|
|
202
|
+
// Transient provider/proxy failure: retry the SAME phase once on the
|
|
203
|
+
// fallback model (a different family often gets through).
|
|
204
|
+
if (phase && !phase.retried && analysis.transient) {
|
|
205
|
+
return { action: "retry-fallback" };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (verdict === "BLOCKED" && phase) return { action: "pause-blocked" };
|
|
209
|
+
|
|
210
|
+
const looped = analysis.toolCallCount > maxToolCallsPerPhase;
|
|
211
|
+
if (phase?.key === "review" && verdict === "FAIL" && reviewFixRounds < 1 && !looped) {
|
|
212
|
+
return { action: "review-fix-cycle" };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
action: "continue",
|
|
217
|
+
zeroToolCalls: analysis.toolCallCount === 0,
|
|
218
|
+
missingVerdict: phase !== null && VERDICT_PHASES.has(phase.key) && hasReport && verdict === null,
|
|
219
|
+
};
|
|
220
|
+
}
|