@ilya-lesikov/pi-pi 0.7.0 → 0.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/3p/pi-plannotator/apps/pi-extension/README.md +6 -5
- package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +25 -10
- package/3p/pi-plannotator/apps/pi-extension/plannotator-events.code-review.test.ts +172 -0
- package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +50 -12
- package/3p/pi-plannotator/apps/pi-extension/server/project.test.ts +45 -0
- package/3p/pi-plannotator/apps/pi-extension/server/project.ts +7 -6
- package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +41 -25
- package/3p/pi-subagents/src/agent-manager.ts +34 -1
- package/3p/pi-subagents/src/agent-runner.ts +66 -33
- package/3p/pi-subagents/src/index.ts +3 -38
- package/3p/pi-subagents/src/types.ts +4 -0
- package/extensions/orchestrator/agents/advisor.ts +35 -0
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/code-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/constraints.test.ts +44 -0
- package/extensions/orchestrator/agents/constraints.ts +3 -0
- package/extensions/orchestrator/agents/deep-debugger.ts +35 -0
- package/extensions/orchestrator/agents/plan-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/planner.ts +9 -8
- package/extensions/orchestrator/agents/prompts.test.ts +120 -0
- package/extensions/orchestrator/agents/reviewer.ts +48 -0
- package/extensions/orchestrator/agents/task.ts +6 -20
- package/extensions/orchestrator/agents/tool-routing.ts +23 -1
- package/extensions/orchestrator/command-handlers.ts +1 -1
- package/extensions/orchestrator/config.ts +5 -2
- package/extensions/orchestrator/context.test.ts +54 -0
- package/extensions/orchestrator/context.ts +65 -2
- package/extensions/orchestrator/event-handlers.test.ts +97 -1
- package/extensions/orchestrator/event-handlers.ts +176 -43
- package/extensions/orchestrator/flant-infra.test.ts +25 -0
- package/extensions/orchestrator/flant-infra.ts +91 -0
- package/extensions/orchestrator/index.ts +1 -1
- package/extensions/orchestrator/integration.test.ts +107 -12
- package/extensions/orchestrator/messages.test.ts +30 -0
- package/extensions/orchestrator/messages.ts +6 -0
- package/extensions/orchestrator/model-registry.test.ts +48 -1
- package/extensions/orchestrator/model-registry.ts +43 -1
- package/extensions/orchestrator/orchestrator.test.ts +113 -0
- package/extensions/orchestrator/orchestrator.ts +151 -2
- package/extensions/orchestrator/phases/brainstorm.ts +9 -20
- package/extensions/orchestrator/phases/implementation.test.ts +11 -0
- package/extensions/orchestrator/phases/implementation.ts +4 -6
- package/extensions/orchestrator/phases/planning.test.ts +16 -0
- package/extensions/orchestrator/phases/planning.ts +11 -4
- package/extensions/orchestrator/phases/review-task.ts +1 -4
- package/extensions/orchestrator/phases/review.test.ts +8 -0
- package/extensions/orchestrator/phases/review.ts +4 -3
- package/extensions/orchestrator/plannotator.ts +9 -6
- package/extensions/orchestrator/pp-menu.ts +168 -54
- package/extensions/orchestrator/pp-state-tools.test.ts +192 -0
- package/extensions/orchestrator/pp-state-tools.ts +249 -0
- package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +128 -0
- package/extensions/orchestrator/rate-limit-fallback.test.ts +98 -0
- package/extensions/orchestrator/rate-limit-fallback.ts +243 -0
- package/extensions/orchestrator/test-helpers.ts +4 -1
- package/extensions/orchestrator/usage-tracker.ts +5 -1
- package/extensions/orchestrator/validate-artifacts.test.ts +20 -0
- package/extensions/orchestrator/validate-artifacts.ts +2 -2
- package/package.json +1 -1
|
@@ -14,17 +14,20 @@ import {
|
|
|
14
14
|
loadAllContextFiles,
|
|
15
15
|
getPhaseArtifacts,
|
|
16
16
|
getLatestSynthesizedPlan,
|
|
17
|
+
getArtifactManifest,
|
|
17
18
|
loadBrainstormReviewOutputs,
|
|
18
19
|
loadCodeReviewOutputs,
|
|
19
20
|
loadPlanReviewOutputs,
|
|
20
21
|
} from "./context.js";
|
|
21
|
-
import { PRINCIPLES_BLOCK, TOOLS_BLOCK } from "./agents/tool-routing.js";
|
|
22
|
+
import { PRINCIPLES_BLOCK, TOOLS_BLOCK, DELEGATION_BLOCK } from "./agents/tool-routing.js";
|
|
22
23
|
import { constraintsBlock, phaseConstraint } from "./agents/constraints.js";
|
|
23
24
|
import { registerCbmTools } from "./cbm.js";
|
|
24
25
|
import { registerExaTools } from "./exa.js";
|
|
25
26
|
import { registerAstSearchTool } from "./ast-search.js";
|
|
26
27
|
import { SUBAGENT_SESSION_KEY } from "./index.js";
|
|
27
28
|
import { registerCommandHandlers } from "./command-handlers.js";
|
|
29
|
+
import { registerStateFileTools } from "./pp-state-tools.js";
|
|
30
|
+
import { handleMainRateLimit, handleSubagentRateLimit, isRateLimitError, isSdkRetryableError } from "./rate-limit-fallback.js";
|
|
28
31
|
import { setExtensionOnlyMode, unregisterAgentDefinitions } from "./agents/registry.js";
|
|
29
32
|
import { resolveModel, getModelInfo, updateRegistryFromAvailableModels } from "./model-registry.js";
|
|
30
33
|
import { spawnPlanners, spawnPlanReviewers } from "./phases/planning.js";
|
|
@@ -33,9 +36,10 @@ import { spawnBrainstormReviewers } from "./phases/brainstorm.js";
|
|
|
33
36
|
import { reviewPassUnanimousApprove } from "./phases/verdict.js";
|
|
34
37
|
import { validateExitCriteria } from "./phases/machine.js";
|
|
35
38
|
import { openPlannotator, waitForPlannotatorResult, cancelPendingPlannotatorWait } from "./plannotator.js";
|
|
39
|
+
import { advanceBanner } from "./messages.js";
|
|
36
40
|
import { Orchestrator, type ActiveTask } from "./orchestrator.js";
|
|
37
41
|
import { createCustomFooter, setFooterContext, setFooterTracker } from "./custom-footer.js";
|
|
38
|
-
import { createUsageTracker, dumpUsageSummary, loadUsageSummary, type UsageTracker } from "./usage-tracker.js";
|
|
42
|
+
import { createUsageTracker, dumpUsageSummary, loadUsageSummary, isSubscriptionRouted, type UsageTracker } from "./usage-tracker.js";
|
|
39
43
|
import { askUser, isCancel } from "../../3p/pi-ask-user/index.js";
|
|
40
44
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
41
45
|
import { findRootRepo, normalizeRepoPath, resolveRepoForFile, type RepoInfo } from "./repo-utils.js";
|
|
@@ -51,6 +55,34 @@ function isPathInside(basePath: string, targetPath: string): boolean {
|
|
|
51
55
|
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
52
56
|
}
|
|
53
57
|
|
|
58
|
+
// Builds the spawn-time context block appended to a free agent's spawn prompt:
|
|
59
|
+
// inlines USER_REQUEST + RESEARCH (always present by spawn time) and appends a
|
|
60
|
+
// path-aware manifest of additional on-demand documents (artifacts + plan).
|
|
61
|
+
function buildSpawnContextBlock(taskDir: string): string {
|
|
62
|
+
const parts: string[] = [];
|
|
63
|
+
|
|
64
|
+
const userRequestPath = join(taskDir, "USER_REQUEST.md");
|
|
65
|
+
if (existsSync(userRequestPath)) {
|
|
66
|
+
parts.push("=== USER REQUEST ===\n" + readFileSync(userRequestPath, "utf-8").trimEnd());
|
|
67
|
+
}
|
|
68
|
+
const researchPath = join(taskDir, "RESEARCH.md");
|
|
69
|
+
if (existsSync(researchPath)) {
|
|
70
|
+
parts.push("=== RESEARCH ===\n" + readFileSync(researchPath, "utf-8").trimEnd());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const manifest = getArtifactManifest(taskDir);
|
|
74
|
+
if (manifest.length > 0) {
|
|
75
|
+
const lines = manifest.map((m) => `- ${m.path} — ${m.title}`);
|
|
76
|
+
parts.push(
|
|
77
|
+
"=== ADDITIONAL DOCUMENTS (read from disk if relevant) ===\n" +
|
|
78
|
+
lines.join("\n") +
|
|
79
|
+
"\nDo NOT re-read USER_REQUEST/RESEARCH from disk (already above).",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return parts.join("\n\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
54
86
|
export async function detectDefaultBranch(orchestrator: Orchestrator, repos: RepoInfo[], repoPath: string): Promise<string> {
|
|
55
87
|
const normalizedPath = normalizeRepoPath(repoPath);
|
|
56
88
|
const repo = repos.find((r) => r.path === normalizedPath);
|
|
@@ -206,7 +238,7 @@ function tryCompleteReviewCycle(orchestrator: Orchestrator, spawnedReviewers?: n
|
|
|
206
238
|
},
|
|
207
239
|
"instruction",
|
|
208
240
|
);
|
|
209
|
-
orchestrator.safeSendUserMessage(reviewReadyMessage(phase, getEffectivePhaseMode(orchestrator.active.state)));
|
|
241
|
+
orchestrator.safeSendUserMessage(advanceBanner(reviewReadyMessage(phase, getEffectivePhaseMode(orchestrator.active.state))));
|
|
210
242
|
}
|
|
211
243
|
|
|
212
244
|
function reviewReadyMessage(phase: string, mode: TaskMode): string {
|
|
@@ -421,6 +453,7 @@ function registerOrchestratorTools(orchestrator: Orchestrator): void {
|
|
|
421
453
|
registerPhaseCompleteTool(orchestrator);
|
|
422
454
|
registerCommitTool(orchestrator);
|
|
423
455
|
registerSpecifyReviewsTool(orchestrator);
|
|
456
|
+
registerStateFileTools(orchestrator);
|
|
424
457
|
}
|
|
425
458
|
|
|
426
459
|
function registerRepoTool(orchestrator: Orchestrator): void {
|
|
@@ -562,24 +595,24 @@ function registerRepoTool(orchestrator: Orchestrator): void {
|
|
|
562
595
|
});
|
|
563
596
|
}
|
|
564
597
|
|
|
565
|
-
function openCodeReviewDirect(
|
|
566
|
-
|
|
598
|
+
async function openCodeReviewDirect(
|
|
599
|
+
orchestrator: Orchestrator,
|
|
567
600
|
payload: Record<string, unknown>,
|
|
568
601
|
): Promise<{ approved: boolean; feedback?: string } | { error: string }> {
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
}
|
|
602
|
+
const { opened, reviewId } = await openPlannotator(orchestrator.pi, "code-review", payload);
|
|
603
|
+
if (!opened) {
|
|
604
|
+
return { error: "Plannotator not available" };
|
|
605
|
+
}
|
|
606
|
+
let result: { approved: boolean; feedback?: string; error?: string };
|
|
607
|
+
try {
|
|
608
|
+
result = await waitForPlannotatorResult(orchestrator, reviewId, null);
|
|
609
|
+
} catch (err) {
|
|
610
|
+
return { error: err instanceof Error ? err.message : "Plannotator review failed" };
|
|
611
|
+
}
|
|
612
|
+
if (result.error) {
|
|
613
|
+
return { error: result.error };
|
|
614
|
+
}
|
|
615
|
+
return { approved: result.approved, feedback: result.feedback };
|
|
583
616
|
}
|
|
584
617
|
|
|
585
618
|
function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
|
|
@@ -671,7 +704,7 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
|
|
|
671
704
|
continue;
|
|
672
705
|
}
|
|
673
706
|
|
|
674
|
-
const result = await openCodeReviewDirect(
|
|
707
|
+
const result = await openCodeReviewDirect(orchestrator, {
|
|
675
708
|
cwd: reviewCwd,
|
|
676
709
|
diffType: "branch",
|
|
677
710
|
defaultBranch: compareBase,
|
|
@@ -1168,6 +1201,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1168
1201
|
if (data.description) {
|
|
1169
1202
|
orchestrator.agentDescriptions.set(data.id, data.description);
|
|
1170
1203
|
}
|
|
1204
|
+
|
|
1171
1205
|
trackSubagentEvent(data, "created");
|
|
1172
1206
|
startStaleAgentWatchdog();
|
|
1173
1207
|
const mgr = (globalThis as any)[Symbol.for("pi-subagents:manager")];
|
|
@@ -1214,7 +1248,14 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1214
1248
|
|
|
1215
1249
|
const failedPlannerVariants = [...orchestrator.failedPlannerVariants];
|
|
1216
1250
|
const effectiveMode = getEffectiveMode(orchestrator.active.state);
|
|
1217
|
-
|
|
1251
|
+
// Do NOT auto-retry failed variants while a subscription-429 fallback decision
|
|
1252
|
+
// is in flight: re-spawning now would use the still-sub-routed model and
|
|
1253
|
+
// re-hit the limit. Once the user decides, the fallback nudge re-drives this.
|
|
1254
|
+
if (
|
|
1255
|
+
effectiveMode === "autonomous" &&
|
|
1256
|
+
failedPlannerVariants.length > 0 &&
|
|
1257
|
+
!orchestrator.subFallbackPendingDecision
|
|
1258
|
+
) {
|
|
1218
1259
|
const alreadyRetried = orchestrator.active.state.plannerFailureAutoRetried === true;
|
|
1219
1260
|
if (!alreadyRetried) {
|
|
1220
1261
|
const failedSet = new Set(failedPlannerVariants);
|
|
@@ -1386,7 +1427,13 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1386
1427
|
|
|
1387
1428
|
const failedReviewerVariants = [...orchestrator.failedReviewerVariants];
|
|
1388
1429
|
const effectiveMode = getEffectiveMode(orchestrator.active.state);
|
|
1389
|
-
|
|
1430
|
+
// See the planner path: suppress auto-retry while a sub-429 fallback decision
|
|
1431
|
+
// is pending so we don't re-spawn on the still-sub-routed model.
|
|
1432
|
+
if (
|
|
1433
|
+
effectiveMode === "autonomous" &&
|
|
1434
|
+
failedReviewerVariants.length > 0 &&
|
|
1435
|
+
!orchestrator.subFallbackPendingDecision
|
|
1436
|
+
) {
|
|
1390
1437
|
const cycle = orchestrator.active.state.reviewCycle;
|
|
1391
1438
|
const phase = orchestrator.active.state.phase;
|
|
1392
1439
|
const outputs = loadPhaseReviewOutputs(orchestrator.active.dir, phase, cycle.pass);
|
|
@@ -1641,6 +1688,23 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1641
1688
|
orchestrator.abortAllSubagents();
|
|
1642
1689
|
}
|
|
1643
1690
|
|
|
1691
|
+
// Subscription rate-limit (429) on a sub-routed subagent: offer the ONE
|
|
1692
|
+
// global switch-to-non-sub dialogue (never per-subagent). subagents:failed
|
|
1693
|
+
// carries the subagent's resolved model id.
|
|
1694
|
+
const failedModelId = typeof data.modelId === "string" ? data.modelId : undefined;
|
|
1695
|
+
const subRateLimited =
|
|
1696
|
+
isRateLimitError(data.error) &&
|
|
1697
|
+
isSubscriptionRouted(failedModelId) &&
|
|
1698
|
+
!orchestrator.subFallbackActive;
|
|
1699
|
+
if (subRateLimited) {
|
|
1700
|
+
// Set the decision-pending flag SYNCHRONOUSLY (before the completion checks
|
|
1701
|
+
// below) so the autonomous planner/reviewer auto-retry does NOT re-spawn
|
|
1702
|
+
// the failed variant on the still-sub-routed model while the fallback
|
|
1703
|
+
// dialogue is in flight.
|
|
1704
|
+
orchestrator.subFallbackPendingDecision = true;
|
|
1705
|
+
void handleSubagentRateLimit(orchestrator, orchestrator.lastCtx, failedModelId);
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1644
1708
|
orchestrator.transitionController.sendCustom(
|
|
1645
1709
|
{
|
|
1646
1710
|
customType: "pp-subagent-error",
|
|
@@ -1821,6 +1885,10 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1821
1885
|
if (!isControllerInjected) {
|
|
1822
1886
|
orchestrator.nudgeHalted = false;
|
|
1823
1887
|
orchestrator.consecutiveNudges = 0;
|
|
1888
|
+
// Genuine user re-engagement also clears the API-error retry halt, so a
|
|
1889
|
+
// fresh request can auto-retry again from a clean counter.
|
|
1890
|
+
orchestrator.errorNudgeHalted = false;
|
|
1891
|
+
orchestrator.errorRetryCount = 0;
|
|
1824
1892
|
}
|
|
1825
1893
|
orchestrator.updateStatus(ctx);
|
|
1826
1894
|
|
|
@@ -1855,6 +1923,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1855
1923
|
constraintsBlock(phase as Phase, effectiveMode),
|
|
1856
1924
|
PRINCIPLES_BLOCK,
|
|
1857
1925
|
TOOLS_BLOCK,
|
|
1926
|
+
DELEGATION_BLOCK,
|
|
1858
1927
|
projectContext,
|
|
1859
1928
|
taskBlock,
|
|
1860
1929
|
]
|
|
@@ -1877,24 +1946,29 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1877
1946
|
if (event.toolName === "Agent" && orchestrator.active) {
|
|
1878
1947
|
const input = event.input as Record<string, unknown>;
|
|
1879
1948
|
const requestedType = ((input.subagent_type as string) || "").toLowerCase();
|
|
1949
|
+
const validTypes = ["explore", "librarian", "task", "advisor", "deep-debugger", "reviewer"];
|
|
1880
1950
|
if (!requestedType) {
|
|
1881
|
-
return { block: true, reason: "subagent_type is required.
|
|
1951
|
+
return { block: true, reason: "subagent_type is required. Valid types: explore (codebase research), librarian (external docs), task (implementation subtask), advisor (design/'why is this broken' judgment), deep-debugger (hard persistent failures), reviewer (code review — only when the user explicitly asks)." };
|
|
1882
1952
|
}
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1953
|
+
if (!validTypes.includes(requestedType)) {
|
|
1954
|
+
return { block: true, reason: `Unknown subagent_type "${requestedType}". Valid types: ${validTypes.join(", ")}.` };
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
const simple = orchestrator.config.agents.subagents.simple;
|
|
1958
|
+
const role = requestedType as keyof typeof simple;
|
|
1959
|
+
input.subagent_type = requestedType;
|
|
1960
|
+
input.model = resolveModel(simple[role].model);
|
|
1961
|
+
input.thinking = simple[role].thinking;
|
|
1962
|
+
|
|
1963
|
+
// Spawn-time context injection: append USER_REQUEST + RESEARCH content and a
|
|
1964
|
+
// path-aware manifest of on-demand documents to the LLM-supplied spawn prompt.
|
|
1965
|
+
// explore/librarian intentionally get nothing (fast, scoped retrieval).
|
|
1966
|
+
if (["task", "advisor", "deep-debugger", "reviewer"].includes(requestedType)) {
|
|
1967
|
+
const contextBlock = buildSpawnContextBlock(orchestrator.active.dir);
|
|
1968
|
+
if (contextBlock) {
|
|
1969
|
+
const existingPrompt = typeof input.prompt === "string" ? input.prompt : "";
|
|
1970
|
+
input.prompt = existingPrompt ? `${existingPrompt}\n\n${contextBlock}` : contextBlock;
|
|
1971
|
+
}
|
|
1898
1972
|
}
|
|
1899
1973
|
}
|
|
1900
1974
|
|
|
@@ -2146,6 +2220,39 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
2146
2220
|
contentBlocks: msg.content?.length ?? 0,
|
|
2147
2221
|
contentSummary,
|
|
2148
2222
|
}, "turn ended with error");
|
|
2223
|
+
// Subscription rate-limit (429) on a sub-routed main turn: retrying the
|
|
2224
|
+
// same sub model is futile against an account-level limit. Offer a
|
|
2225
|
+
// user-gated switch to non-sub Claude instead of the generic backoff.
|
|
2226
|
+
const activeModelId = (typeof msg.model === "string" && msg.model) || ctx.model?.id;
|
|
2227
|
+
const activeProvider = (typeof msg.provider === "string" && msg.provider) || ctx.model?.provider;
|
|
2228
|
+
if (
|
|
2229
|
+
isRateLimitError(errorMsg) &&
|
|
2230
|
+
isSubscriptionRouted(activeModelId, activeProvider) &&
|
|
2231
|
+
!orchestrator.subFallbackActive
|
|
2232
|
+
) {
|
|
2233
|
+
void handleMainRateLimit(orchestrator, ctx, activeModelId, activeProvider);
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
// The SDK already auto-retries this class of error itself (abortable
|
|
2237
|
+
// backoff bound to ESC, continuing the SAME turn). Running pi-pi's OWN
|
|
2238
|
+
// independent retry on top would double-fire: its followUp races the SDK's
|
|
2239
|
+
// continue() into "Agent is already processing", and it re-nudges a still-
|
|
2240
|
+
// failing model. So for SDK-retryable errors we defer entirely to the SDK
|
|
2241
|
+
// and do nothing here. pi-pi's own idle-gated retry remains only as the
|
|
2242
|
+
// fallback for errors the SDK does NOT retry.
|
|
2243
|
+
if (isSdkRetryableError(errorMsg)) {
|
|
2244
|
+
getLogger().debug({ s: "turn", err: errorMsg }, "deferring to SDK auto-retry; pi-pi retry skipped");
|
|
2245
|
+
return;
|
|
2246
|
+
}
|
|
2247
|
+
// Halt guard: once the consecutive-error cap is exceeded we stop auto-
|
|
2248
|
+
// retrying until the user re-engages. errorRetryCount is NO LONGER reset on
|
|
2249
|
+
// benign intervening turns (see below) — otherwise a retried turn that ends
|
|
2250
|
+
// as a harmless text-only reply would reset the counter and the cap could
|
|
2251
|
+
// never accumulate, letting transient errors nudge unbounded.
|
|
2252
|
+
if (orchestrator.errorNudgeHalted) {
|
|
2253
|
+
getLogger().debug({ s: "turn", err: errorMsg }, "error auto-retry halted; awaiting user re-engagement");
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2149
2256
|
orchestrator.errorRetryCount = (orchestrator.errorRetryCount ?? 0) + 1;
|
|
2150
2257
|
const maxRetries = 5;
|
|
2151
2258
|
if (orchestrator.errorRetryCount <= maxRetries) {
|
|
@@ -2153,18 +2260,44 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
2153
2260
|
ctx.ui.notify(`API error (attempt ${orchestrator.errorRetryCount}/${maxRetries}): ${errorMsg}. Retrying in ${delay / 1000}s...`, "warning");
|
|
2154
2261
|
const taskToken = orchestrator.activeTaskToken;
|
|
2155
2262
|
if (orchestrator.pendingRetryTimer) clearTimeout(orchestrator.pendingRetryTimer);
|
|
2263
|
+
// Arm a direct ESC interrupt for this retry window — no SDK/interactive
|
|
2264
|
+
// binding covers pi-pi's own timer (the turn already ended in error).
|
|
2265
|
+
orchestrator.armRetryEscInterrupt(ctx);
|
|
2156
2266
|
orchestrator.pendingRetryTimer = setTimeout(() => {
|
|
2157
2267
|
orchestrator.pendingRetryTimer = null;
|
|
2158
|
-
if (orchestrator.activeTaskToken !== taskToken || !orchestrator.active)
|
|
2159
|
-
|
|
2268
|
+
if (orchestrator.activeTaskToken !== taskToken || !orchestrator.active) {
|
|
2269
|
+
orchestrator.disarmRetryEscInterrupt();
|
|
2270
|
+
return;
|
|
2271
|
+
}
|
|
2272
|
+
// Defer until the main session is idle: sending a followUp while the SDK
|
|
2273
|
+
// still has an active run throws an async, runtime-swallowed "Agent is
|
|
2274
|
+
// already processing" error. sendUserMessageWhenIdle polls (reusing
|
|
2275
|
+
// pendingRetryTimer so ESC/abort still cancels) and disarms the ESC hook
|
|
2276
|
+
// once delivered.
|
|
2277
|
+
orchestrator.sendUserMessageWhenIdle(
|
|
2278
|
+
`[PI-PI] Previous request failed due to an API error. Continue working on the current phase (${phase}).`,
|
|
2279
|
+
taskToken,
|
|
2280
|
+
);
|
|
2160
2281
|
}, delay);
|
|
2161
2282
|
} else {
|
|
2162
|
-
ctx.ui.notify(`API error persisted after ${maxRetries} retries: ${errorMsg}. Stopping auto-retry.`, "error");
|
|
2163
|
-
|
|
2283
|
+
ctx.ui.notify(`API error persisted after ${maxRetries} retries: ${errorMsg}. Stopping auto-retry — send any message to resume.`, "error");
|
|
2284
|
+
// Halt (do NOT reset the counter) so no further error turn re-arms a retry
|
|
2285
|
+
// until the user re-engages. cancelPendingRetry would reset the count and
|
|
2286
|
+
// re-open the floodgate, so only clear the live timer/ESC hook here.
|
|
2287
|
+
orchestrator.errorNudgeHalted = true;
|
|
2288
|
+
if (orchestrator.pendingRetryTimer) {
|
|
2289
|
+
clearTimeout(orchestrator.pendingRetryTimer);
|
|
2290
|
+
orchestrator.pendingRetryTimer = null;
|
|
2291
|
+
}
|
|
2292
|
+
orchestrator.disarmRetryEscInterrupt();
|
|
2164
2293
|
}
|
|
2165
2294
|
return;
|
|
2166
2295
|
}
|
|
2167
|
-
|
|
2296
|
+
// NOTE: errorRetryCount is intentionally NOT reset here. Resetting on every
|
|
2297
|
+
// non-error turn let a benign nudge-induced turn zero the counter, defeating
|
|
2298
|
+
// the maxRetries cap and allowing unbounded error nudges. The counter is
|
|
2299
|
+
// reset only on genuine user re-engagement (before_agent_start) and on task
|
|
2300
|
+
// reset / cancelPendingRetry.
|
|
2168
2301
|
|
|
2169
2302
|
if ((globalThis as any)[SUBAGENT_SESSION_KEY]) {
|
|
2170
2303
|
return;
|
|
@@ -427,6 +427,7 @@ describe("flant-infra", () => {
|
|
|
427
427
|
enabled: false,
|
|
428
428
|
autoUpdate: true,
|
|
429
429
|
cacheTTLDays: 7,
|
|
430
|
+
switchBackIntervalMinutes: 30,
|
|
430
431
|
subscription: false,
|
|
431
432
|
lastUpdated: null,
|
|
432
433
|
cachedFlantModels: null,
|
|
@@ -465,6 +466,7 @@ describe("flant-infra", () => {
|
|
|
465
466
|
enabled: true,
|
|
466
467
|
autoUpdate: false,
|
|
467
468
|
cacheTTLDays: 3,
|
|
469
|
+
switchBackIntervalMinutes: 30,
|
|
468
470
|
subscription: false,
|
|
469
471
|
lastUpdated: "2026-01-02T03:04:05.000Z",
|
|
470
472
|
cachedFlantModels: ["gpt-5-4", "claude-opus-4-6"],
|
|
@@ -488,6 +490,7 @@ describe("flant-infra", () => {
|
|
|
488
490
|
enabled: true,
|
|
489
491
|
autoUpdate: true,
|
|
490
492
|
cacheTTLDays: 14,
|
|
493
|
+
switchBackIntervalMinutes: 30,
|
|
491
494
|
subscription: true,
|
|
492
495
|
lastUpdated: "2026-02-01T00:00:00.000Z",
|
|
493
496
|
cachedFlantModels: ["claude-opus-4-6", "gpt-5-4"],
|
|
@@ -508,4 +511,26 @@ describe("flant-infra", () => {
|
|
|
508
511
|
expect(loaded).toEqual(settings);
|
|
509
512
|
expect(existsSync(join(dir, "extensions", "pp", "cache", "flant-models.json"))).toBe(true);
|
|
510
513
|
});
|
|
514
|
+
|
|
515
|
+
it("normalizes switchBackIntervalMinutes: default when missing, parsed, floored to >=1", async () => {
|
|
516
|
+
const dir = makeTempDir();
|
|
517
|
+
const mod = await loadFlantInfraModule(dir);
|
|
518
|
+
const settingsDir = join(dir, "extensions", "pp", "cache");
|
|
519
|
+
const settingsPath = join(settingsDir, "flant-models.json");
|
|
520
|
+
mkdirSync(settingsDir, { recursive: true });
|
|
521
|
+
|
|
522
|
+
// Missing -> default 30.
|
|
523
|
+
writeFileSync(settingsPath, JSON.stringify({ enabled: true }), "utf-8");
|
|
524
|
+
expect(mod.loadFlantSettings().switchBackIntervalMinutes).toBe(30);
|
|
525
|
+
|
|
526
|
+
// String numeric -> parsed and rounded.
|
|
527
|
+
writeFileSync(settingsPath, JSON.stringify({ enabled: true, switchBackIntervalMinutes: "45" }), "utf-8");
|
|
528
|
+
expect(mod.loadFlantSettings().switchBackIntervalMinutes).toBe(45);
|
|
529
|
+
|
|
530
|
+
// Invalid / <1 -> floored to 1.
|
|
531
|
+
writeFileSync(settingsPath, JSON.stringify({ enabled: true, switchBackIntervalMinutes: 0 }), "utf-8");
|
|
532
|
+
expect(mod.loadFlantSettings().switchBackIntervalMinutes).toBe(1);
|
|
533
|
+
writeFileSync(settingsPath, JSON.stringify({ enabled: true, switchBackIntervalMinutes: "nonsense" }), "utf-8");
|
|
534
|
+
expect(mod.loadFlantSettings().switchBackIntervalMinutes).toBe(30);
|
|
535
|
+
});
|
|
511
536
|
});
|
|
@@ -35,6 +35,13 @@ export interface FlantSettings {
|
|
|
35
35
|
* Claude OAuth token (billed against their personal Claude subscription).
|
|
36
36
|
*/
|
|
37
37
|
subscription: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Minutes between out-of-band "is the subscription limit cleared yet?" probes
|
|
40
|
+
* while the sub→non-sub rate-limit fallback is active. On each interval a
|
|
41
|
+
* cheap probe hits the sub model; on success the user is asked to switch back.
|
|
42
|
+
* Default 30.
|
|
43
|
+
*/
|
|
44
|
+
switchBackIntervalMinutes: number;
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
const GEMINI_MAP: Record<string, string> = {
|
|
@@ -64,6 +71,7 @@ const DEFAULT_SETTINGS: FlantSettings = {
|
|
|
64
71
|
cachedFlantModels: null,
|
|
65
72
|
cachedOpenRouterData: null,
|
|
66
73
|
subscription: false,
|
|
74
|
+
switchBackIntervalMinutes: 30,
|
|
67
75
|
};
|
|
68
76
|
|
|
69
77
|
/** Provider name for the personal-subscription Claude routing. */
|
|
@@ -245,10 +253,15 @@ function normalizeSettings(raw: unknown): FlantSettings {
|
|
|
245
253
|
if (!raw || typeof raw !== "object") return { ...DEFAULT_SETTINGS };
|
|
246
254
|
const value = raw as Record<string, unknown>;
|
|
247
255
|
const cacheTTLDays = Math.max(1, Math.round(toNumber(value.cacheTTLDays, DEFAULT_SETTINGS.cacheTTLDays)));
|
|
256
|
+
const switchBackIntervalMinutes = Math.max(
|
|
257
|
+
1,
|
|
258
|
+
Math.round(toNumber(value.switchBackIntervalMinutes, DEFAULT_SETTINGS.switchBackIntervalMinutes)),
|
|
259
|
+
);
|
|
248
260
|
return {
|
|
249
261
|
enabled: !!value.enabled,
|
|
250
262
|
autoUpdate: value.autoUpdate === undefined ? true : !!value.autoUpdate,
|
|
251
263
|
cacheTTLDays,
|
|
264
|
+
switchBackIntervalMinutes,
|
|
252
265
|
subscription: !!value.subscription,
|
|
253
266
|
lastUpdated: typeof value.lastUpdated === "string" ? value.lastUpdated : null,
|
|
254
267
|
cachedFlantModels: Array.isArray(value.cachedFlantModels)
|
|
@@ -347,6 +360,81 @@ function mapFlantToOpenRouterId(modelId: string): string | null {
|
|
|
347
360
|
return null;
|
|
348
361
|
}
|
|
349
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Out-of-band probe: is the personal Claude subscription limit cleared yet?
|
|
365
|
+
* Sends a tiny, fully throwaway request (NOT part of the session, so it cannot
|
|
366
|
+
* pollute conversation/context/cache) to the gateway's Anthropic endpoint using
|
|
367
|
+
* the personal Claude OAuth token. Returns:
|
|
368
|
+
* "ok" — 200: capacity is back (offer switch-back)
|
|
369
|
+
* "rate_limited" — 429: still limited (stay on non-sub, retry later)
|
|
370
|
+
* "error" — credentials missing / network / other status (treat as not-back)
|
|
371
|
+
*
|
|
372
|
+
* The OAuth token is refreshed first so a failure is a genuine 429 and not an
|
|
373
|
+
* expired-token false negative. `max_tokens: 1` + an explicit "just respond hi"
|
|
374
|
+
* instruction keep output at ~1 token.
|
|
375
|
+
*/
|
|
376
|
+
// Derive the gateway probe model id (`sub/<bare-claude-id>`) from any stored
|
|
377
|
+
// form: `pp-flant-anthropic-sub/sub/<m>`, `sub/<m>`, or a bare `<m>`. Exported
|
|
378
|
+
// for testing the derivation without a network call.
|
|
379
|
+
export function subProbeModelId(modelId: string): string {
|
|
380
|
+
let bare = modelId;
|
|
381
|
+
if (bare.startsWith(`${SUB_PROVIDER}/`)) bare = bare.slice(`${SUB_PROVIDER}/`.length);
|
|
382
|
+
if (bare.startsWith(SUB_MODEL_PREFIX)) bare = bare.slice(SUB_MODEL_PREFIX.length);
|
|
383
|
+
return `${SUB_MODEL_PREFIX}${bare}`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export async function probeSubscriptionCleared(
|
|
387
|
+
modelId: string,
|
|
388
|
+
): Promise<"ok" | "rate_limited" | "error"> {
|
|
389
|
+
const log = getLogger();
|
|
390
|
+
await refreshClaudeOAuthToken();
|
|
391
|
+
const oauthToken = readClaudeOAuthToken();
|
|
392
|
+
const gatewayKey = readGatewayApiKey();
|
|
393
|
+
if (!oauthToken || !gatewayKey) {
|
|
394
|
+
log.debug({ s: "flant", hasOAuth: !!oauthToken, hasGatewayKey: !!gatewayKey }, "probe skipped: missing credentials");
|
|
395
|
+
return "error";
|
|
396
|
+
}
|
|
397
|
+
// The gateway expects the bare claude-* id under the `sub/` prefix.
|
|
398
|
+
const probeModel = subProbeModelId(modelId);
|
|
399
|
+
try {
|
|
400
|
+
const res = await fetch("https://llm-api.flant.ru/v1/messages", {
|
|
401
|
+
method: "POST",
|
|
402
|
+
headers: {
|
|
403
|
+
"content-type": "application/json",
|
|
404
|
+
"anthropic-version": "2023-06-01",
|
|
405
|
+
// Claude Code OAuth identity headers — the gateway requires these for
|
|
406
|
+
// subscription (sub/) routing; without them it can reject the probe and
|
|
407
|
+
// we would never detect that the limit cleared. Mirrors doctor.ts.
|
|
408
|
+
"anthropic-beta": "claude-code-20250219,oauth-2025-04-20",
|
|
409
|
+
"user-agent": "claude-cli/1.0.0",
|
|
410
|
+
"x-app": "cli",
|
|
411
|
+
Authorization: `Bearer ${oauthToken}`,
|
|
412
|
+
"x-litellm-api-key": `Bearer ${gatewayKey}`,
|
|
413
|
+
},
|
|
414
|
+
body: JSON.stringify({
|
|
415
|
+
model: probeModel,
|
|
416
|
+
max_tokens: 1,
|
|
417
|
+
temperature: 0,
|
|
418
|
+
messages: [{ role: "user", content: "just respond hi" }],
|
|
419
|
+
}),
|
|
420
|
+
signal: AbortSignal.timeout(30000),
|
|
421
|
+
});
|
|
422
|
+
if (res.status === 429) {
|
|
423
|
+
log.debug({ s: "flant", model: probeModel }, "probe: still rate limited");
|
|
424
|
+
return "rate_limited";
|
|
425
|
+
}
|
|
426
|
+
if (res.ok) {
|
|
427
|
+
log.debug({ s: "flant", model: probeModel }, "probe: capacity back");
|
|
428
|
+
return "ok";
|
|
429
|
+
}
|
|
430
|
+
log.debug({ s: "flant", model: probeModel, status: res.status }, "probe: unexpected status");
|
|
431
|
+
return "error";
|
|
432
|
+
} catch (err: any) {
|
|
433
|
+
log.debug({ s: "flant", err: err?.message }, "probe failed");
|
|
434
|
+
return "error";
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
350
438
|
export async function discoverFlantModels(apiKey: string): Promise<string[]> {
|
|
351
439
|
const res = await fetch("https://llm-api.flant.ru/v1/models", {
|
|
352
440
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
@@ -655,6 +743,9 @@ export function generateFlantConfig(models: string[], subscriptionActive = false
|
|
|
655
743
|
explore: { model: modelSpec(fastModel, sub), thinking: "low" },
|
|
656
744
|
librarian: { model: modelSpec(fastModel, sub), thinking: "medium" },
|
|
657
745
|
task: { model: modelSpec(taskModel, sub), thinking: "medium" },
|
|
746
|
+
advisor: { model: modelSpec(latestGpt ?? fallback, sub), thinking: "high" },
|
|
747
|
+
"deep-debugger": { model: modelSpec(latestGpt ?? fallback, sub), thinking: "high" },
|
|
748
|
+
reviewer: { model: modelSpec(latestGpt ?? fallback, sub), thinking: "high" },
|
|
658
749
|
},
|
|
659
750
|
presetGroups: {
|
|
660
751
|
planners: buildPresetGroup({
|
|
@@ -65,7 +65,7 @@ function registerSubagentTools(pi: ExtensionAPI): void {
|
|
|
65
65
|
...event.content,
|
|
66
66
|
{
|
|
67
67
|
type: "text" as const,
|
|
68
|
-
text: `\n\n<validation-error>\nPlan structure is invalid:\n${result.errors.map((e) => `- ${e}`).join("\n")}\n\nFix immediately. Required structure:\n# Plan\n## Scope\n<2-4 lines>\n## Checklist\n- [ ] <outcome> — Done when: <observable condition>\n## Blockers (optional)\n<issues>\n\nRewrite the file now.\n</validation-error>`,
|
|
68
|
+
text: `\n\n<validation-error>\nPlan structure is invalid:\n${result.errors.map((e) => `- ${e}`).join("\n")}\n\nFix immediately. Required structure:\n# Plan\n## Scope\n<2-4 lines>\n## Checklist\n- [ ] <outcome> — Done when: <observable condition>\n## Pattern constraints (optional; include when adding a type/function/user-facing value)\n<closest existing analog + conventions to mirror>\n## Blockers (optional)\n<issues>\n\nRewrite the file now.\n</validation-error>`,
|
|
69
69
|
},
|
|
70
70
|
],
|
|
71
71
|
};
|