@kodrunhq/opencode-autopilot 0.1.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/LICENSE +21 -0
- package/README.md +1 -0
- package/assets/agents/placeholder-agent.md +13 -0
- package/assets/commands/configure.md +17 -0
- package/assets/commands/new-agent.md +16 -0
- package/assets/commands/new-command.md +15 -0
- package/assets/commands/new-skill.md +15 -0
- package/assets/commands/review-pr.md +49 -0
- package/assets/skills/.gitkeep +0 -0
- package/assets/skills/coding-standards/SKILL.md +327 -0
- package/package.json +52 -0
- package/src/agents/autopilot.ts +42 -0
- package/src/agents/documenter.ts +44 -0
- package/src/agents/index.ts +49 -0
- package/src/agents/metaprompter.ts +50 -0
- package/src/agents/pipeline/index.ts +25 -0
- package/src/agents/pipeline/oc-architect.ts +49 -0
- package/src/agents/pipeline/oc-challenger.ts +44 -0
- package/src/agents/pipeline/oc-critic.ts +42 -0
- package/src/agents/pipeline/oc-explorer.ts +46 -0
- package/src/agents/pipeline/oc-implementer.ts +56 -0
- package/src/agents/pipeline/oc-planner.ts +45 -0
- package/src/agents/pipeline/oc-researcher.ts +46 -0
- package/src/agents/pipeline/oc-retrospector.ts +42 -0
- package/src/agents/pipeline/oc-reviewer.ts +44 -0
- package/src/agents/pipeline/oc-shipper.ts +42 -0
- package/src/agents/pr-reviewer.ts +74 -0
- package/src/agents/researcher.ts +43 -0
- package/src/config.ts +168 -0
- package/src/index.ts +152 -0
- package/src/installer.ts +130 -0
- package/src/orchestrator/arena.ts +41 -0
- package/src/orchestrator/artifacts.ts +28 -0
- package/src/orchestrator/confidence.ts +59 -0
- package/src/orchestrator/fallback/chat-message-handler.ts +49 -0
- package/src/orchestrator/fallback/error-classifier.ts +148 -0
- package/src/orchestrator/fallback/event-handler.ts +235 -0
- package/src/orchestrator/fallback/fallback-config.ts +16 -0
- package/src/orchestrator/fallback/fallback-manager.ts +323 -0
- package/src/orchestrator/fallback/fallback-state.ts +120 -0
- package/src/orchestrator/fallback/index.ts +11 -0
- package/src/orchestrator/fallback/message-replay.ts +40 -0
- package/src/orchestrator/fallback/resolve-chain.ts +34 -0
- package/src/orchestrator/fallback/tool-execute-handler.ts +44 -0
- package/src/orchestrator/fallback/types.ts +46 -0
- package/src/orchestrator/handlers/architect.ts +114 -0
- package/src/orchestrator/handlers/build.ts +363 -0
- package/src/orchestrator/handlers/challenge.ts +41 -0
- package/src/orchestrator/handlers/explore.ts +9 -0
- package/src/orchestrator/handlers/index.ts +21 -0
- package/src/orchestrator/handlers/plan.ts +35 -0
- package/src/orchestrator/handlers/recon.ts +40 -0
- package/src/orchestrator/handlers/retrospective.ts +123 -0
- package/src/orchestrator/handlers/ship.ts +38 -0
- package/src/orchestrator/handlers/types.ts +31 -0
- package/src/orchestrator/lesson-injection.ts +80 -0
- package/src/orchestrator/lesson-memory.ts +110 -0
- package/src/orchestrator/lesson-schemas.ts +24 -0
- package/src/orchestrator/lesson-types.ts +6 -0
- package/src/orchestrator/phase.ts +76 -0
- package/src/orchestrator/plan.ts +43 -0
- package/src/orchestrator/schemas.ts +86 -0
- package/src/orchestrator/skill-injection.ts +52 -0
- package/src/orchestrator/state.ts +80 -0
- package/src/orchestrator/types.ts +20 -0
- package/src/review/agent-catalog.ts +439 -0
- package/src/review/agents/auth-flow-verifier.ts +47 -0
- package/src/review/agents/code-quality-auditor.ts +51 -0
- package/src/review/agents/concurrency-checker.ts +47 -0
- package/src/review/agents/contract-verifier.ts +45 -0
- package/src/review/agents/database-auditor.ts +47 -0
- package/src/review/agents/dead-code-scanner.ts +47 -0
- package/src/review/agents/go-idioms-auditor.ts +46 -0
- package/src/review/agents/index.ts +82 -0
- package/src/review/agents/logic-auditor.ts +47 -0
- package/src/review/agents/product-thinker.ts +49 -0
- package/src/review/agents/python-django-auditor.ts +46 -0
- package/src/review/agents/react-patterns-auditor.ts +46 -0
- package/src/review/agents/red-team.ts +49 -0
- package/src/review/agents/rust-safety-auditor.ts +46 -0
- package/src/review/agents/scope-intent-verifier.ts +45 -0
- package/src/review/agents/security-auditor.ts +47 -0
- package/src/review/agents/silent-failure-hunter.ts +45 -0
- package/src/review/agents/spec-checker.ts +45 -0
- package/src/review/agents/state-mgmt-auditor.ts +46 -0
- package/src/review/agents/test-interrogator.ts +43 -0
- package/src/review/agents/type-soundness.ts +46 -0
- package/src/review/agents/wiring-inspector.ts +46 -0
- package/src/review/cross-verification.ts +71 -0
- package/src/review/finding-builder.ts +74 -0
- package/src/review/fix-cycle.ts +146 -0
- package/src/review/memory.ts +114 -0
- package/src/review/pipeline.ts +258 -0
- package/src/review/report.ts +141 -0
- package/src/review/sanitize.ts +8 -0
- package/src/review/schemas.ts +75 -0
- package/src/review/selection.ts +98 -0
- package/src/review/severity.ts +71 -0
- package/src/review/stack-gate.ts +127 -0
- package/src/review/types.ts +43 -0
- package/src/templates/agent-template.ts +47 -0
- package/src/templates/command-template.ts +29 -0
- package/src/templates/skill-template.ts +42 -0
- package/src/tools/confidence.ts +93 -0
- package/src/tools/create-agent.ts +81 -0
- package/src/tools/create-command.ts +74 -0
- package/src/tools/create-skill.ts +74 -0
- package/src/tools/forensics.ts +88 -0
- package/src/tools/orchestrate.ts +310 -0
- package/src/tools/phase.ts +92 -0
- package/src/tools/placeholder.ts +11 -0
- package/src/tools/plan.ts +56 -0
- package/src/tools/review.ts +295 -0
- package/src/tools/state.ts +112 -0
- package/src/utils/fs-helpers.ts +39 -0
- package/src/utils/gitignore.ts +27 -0
- package/src/utils/paths.ts +17 -0
- package/src/utils/validators.ts +57 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { isRetryableError } from "./error-classifier";
|
|
2
|
+
import type { FallbackConfig } from "./fallback-config";
|
|
3
|
+
import {
|
|
4
|
+
commitFallback,
|
|
5
|
+
createFallbackState,
|
|
6
|
+
planFallback,
|
|
7
|
+
recoverToOriginal,
|
|
8
|
+
} from "./fallback-state";
|
|
9
|
+
import type { FallbackPlan, FallbackState } from "./types";
|
|
10
|
+
|
|
11
|
+
const SELF_ABORT_WINDOW_MS = 2000;
|
|
12
|
+
|
|
13
|
+
export interface FallbackManagerOptions {
|
|
14
|
+
readonly config: FallbackConfig;
|
|
15
|
+
readonly resolveFallbackChain: (sessionID: string, agentName?: string) => readonly string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Encapsulates per-session fallback state and concurrency guards.
|
|
20
|
+
* Delegates to pure functions from fallback-state/error-classifier modules.
|
|
21
|
+
* Accepts SDK operations as callbacks to remain testable without the OpenCode runtime.
|
|
22
|
+
*/
|
|
23
|
+
export class FallbackManager {
|
|
24
|
+
private readonly config: FallbackConfig;
|
|
25
|
+
private readonly resolveFallbackChain: (
|
|
26
|
+
sessionID: string,
|
|
27
|
+
agentName?: string,
|
|
28
|
+
) => readonly string[];
|
|
29
|
+
|
|
30
|
+
// Per-session tracking (all private)
|
|
31
|
+
private readonly sessionStates: Map<string, FallbackState> = new Map();
|
|
32
|
+
private readonly sessionRetryInFlight: Set<string> = new Set();
|
|
33
|
+
private readonly sessionAwaitingFallbackResult: Set<string> = new Set();
|
|
34
|
+
private readonly sessionSelfAbortTimestamp: Map<string, number> = new Map();
|
|
35
|
+
private readonly sessionFirstTokenReceived: Map<string, boolean> = new Map();
|
|
36
|
+
private readonly sessionFallbackTimeouts: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
|
37
|
+
private readonly sessionParentID: Map<string, string | null> = new Map();
|
|
38
|
+
private readonly sessionCompactionInFlight: Set<string> = new Set();
|
|
39
|
+
private readonly sessionAgentName: Map<string, string> = new Map();
|
|
40
|
+
|
|
41
|
+
constructor(options: FallbackManagerOptions) {
|
|
42
|
+
this.config = options.config;
|
|
43
|
+
this.resolveFallbackChain = options.resolveFallbackChain;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Creates fallback state for a session with the given model.
|
|
48
|
+
*/
|
|
49
|
+
initSession(
|
|
50
|
+
sessionID: string,
|
|
51
|
+
model: string,
|
|
52
|
+
parentID?: string | null,
|
|
53
|
+
agentName?: string,
|
|
54
|
+
): void {
|
|
55
|
+
this.sessionStates.set(sessionID, createFallbackState(model));
|
|
56
|
+
if (parentID !== undefined) {
|
|
57
|
+
this.sessionParentID.set(sessionID, parentID);
|
|
58
|
+
}
|
|
59
|
+
if (agentName) {
|
|
60
|
+
this.sessionAgentName.set(sessionID, agentName);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Returns FallbackState for known session, undefined for unknown.
|
|
66
|
+
*/
|
|
67
|
+
getSessionState(sessionID: string): FallbackState | undefined {
|
|
68
|
+
return this.sessionStates.get(sessionID);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Removes all tracking state for a session. Safe to call on unknown sessions.
|
|
73
|
+
*/
|
|
74
|
+
cleanupSession(sessionID: string): void {
|
|
75
|
+
this.sessionStates.delete(sessionID);
|
|
76
|
+
this.sessionRetryInFlight.delete(sessionID);
|
|
77
|
+
this.sessionAwaitingFallbackResult.delete(sessionID);
|
|
78
|
+
this.sessionSelfAbortTimestamp.delete(sessionID);
|
|
79
|
+
this.sessionFirstTokenReceived.delete(sessionID);
|
|
80
|
+
this.sessionParentID.delete(sessionID);
|
|
81
|
+
this.sessionAgentName.delete(sessionID);
|
|
82
|
+
this.sessionCompactionInFlight.delete(sessionID);
|
|
83
|
+
|
|
84
|
+
// Clear TTFT timeout if present
|
|
85
|
+
const timer = this.sessionFallbackTimeouts.get(sessionID);
|
|
86
|
+
if (timer !== undefined) {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
this.sessionFallbackTimeouts.delete(sessionID);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Acquires the per-session retry lock. Returns true on first call, false if already held.
|
|
94
|
+
* Prevents dual event handler race (Pitfall 1).
|
|
95
|
+
*/
|
|
96
|
+
acquireRetryLock(sessionID: string): boolean {
|
|
97
|
+
if (this.sessionRetryInFlight.has(sessionID)) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
this.sessionRetryInFlight.add(sessionID);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Releases the per-session retry lock.
|
|
106
|
+
*/
|
|
107
|
+
releaseRetryLock(sessionID: string): void {
|
|
108
|
+
this.sessionRetryInFlight.delete(sessionID);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Returns true if a retry dispatch or fallback result is pending for the session.
|
|
113
|
+
*/
|
|
114
|
+
isDispatchInFlight(sessionID: string): boolean {
|
|
115
|
+
return (
|
|
116
|
+
this.sessionRetryInFlight.has(sessionID) || this.sessionAwaitingFallbackResult.has(sessionID)
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Marks a session as awaiting a fallback result (replay dispatched, not yet complete).
|
|
122
|
+
*/
|
|
123
|
+
markAwaitingResult(sessionID: string): void {
|
|
124
|
+
this.sessionAwaitingFallbackResult.add(sessionID);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Clears the awaiting-result flag for a session.
|
|
129
|
+
*/
|
|
130
|
+
clearAwaitingResult(sessionID: string): void {
|
|
131
|
+
this.sessionAwaitingFallbackResult.delete(sessionID);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Records a self-abort timestamp for self-abort suppression (Pitfall 2).
|
|
136
|
+
*/
|
|
137
|
+
recordSelfAbort(sessionID: string): void {
|
|
138
|
+
this.sessionSelfAbortTimestamp.set(sessionID, Date.now());
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Returns true when a MessageAbortedError arrives within 2000ms of recordSelfAbort.
|
|
143
|
+
* Suppresses self-inflicted abort errors (Pitfall 2).
|
|
144
|
+
*/
|
|
145
|
+
isSelfAbortError(sessionID: string): boolean {
|
|
146
|
+
const timestamp = this.sessionSelfAbortTimestamp.get(sessionID);
|
|
147
|
+
if (timestamp === undefined) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
if (Date.now() - timestamp < SELF_ABORT_WINDOW_MS) {
|
|
151
|
+
// Consume on match — prevents silencing subsequent legitimate errors
|
|
152
|
+
this.sessionSelfAbortTimestamp.delete(sessionID);
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
this.sessionSelfAbortTimestamp.delete(sessionID);
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Returns true when error model is in failedModels and differs from currentModel.
|
|
161
|
+
* Suppresses stale error events from previous models (Pitfall 5).
|
|
162
|
+
*/
|
|
163
|
+
isStaleError(sessionID: string, errorModel?: string): boolean {
|
|
164
|
+
const state = this.sessionStates.get(sessionID);
|
|
165
|
+
if (!state || !errorModel) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
return errorModel !== state.currentModel && state.failedModels.has(errorModel);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Creates a TTFT timer that calls the provided callback after timeoutSeconds.
|
|
173
|
+
* Replaces any existing timeout for the session.
|
|
174
|
+
*/
|
|
175
|
+
startTtftTimeout(sessionID: string, onTimeout: () => void): void {
|
|
176
|
+
// Clear existing timeout if any
|
|
177
|
+
const existingTimer = this.sessionFallbackTimeouts.get(sessionID);
|
|
178
|
+
if (existingTimer !== undefined) {
|
|
179
|
+
clearTimeout(existingTimer);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
this.sessionFirstTokenReceived.set(sessionID, false);
|
|
183
|
+
|
|
184
|
+
const timer = setTimeout(onTimeout, this.config.timeoutSeconds * 1000);
|
|
185
|
+
this.sessionFallbackTimeouts.set(sessionID, timer);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Records that the first token was received for a session. Cancels the TTFT timer.
|
|
190
|
+
*/
|
|
191
|
+
recordFirstToken(sessionID: string): void {
|
|
192
|
+
if (this.sessionFirstTokenReceived.get(sessionID) === false) {
|
|
193
|
+
this.sessionFirstTokenReceived.set(sessionID, true);
|
|
194
|
+
const timer = this.sessionFallbackTimeouts.get(sessionID);
|
|
195
|
+
if (timer !== undefined) {
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
this.sessionFallbackTimeouts.delete(sessionID);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Handles an error for a session. Orchestrates guard checks and returns a FallbackPlan
|
|
204
|
+
* if fallback should proceed, or null if the error should be suppressed/ignored.
|
|
205
|
+
*
|
|
206
|
+
* Guard order:
|
|
207
|
+
* 1. Self-abort suppression (Pitfall 2)
|
|
208
|
+
* 2. Stale error suppression (Pitfall 5)
|
|
209
|
+
* 3. Retryable error check
|
|
210
|
+
* 4. Retry lock (Pitfall 1)
|
|
211
|
+
* 5. Session state existence
|
|
212
|
+
* 6. Plan fallback via pure function
|
|
213
|
+
*/
|
|
214
|
+
handleError(sessionID: string, error: unknown, errorModel?: string): FallbackPlan | null {
|
|
215
|
+
// Pitfall 2: Suppress self-abort errors
|
|
216
|
+
if (this.isSelfAbortError(sessionID)) {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Pitfall 5: Suppress stale errors from previous models
|
|
221
|
+
if (this.isStaleError(sessionID, errorModel)) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Check if error is retryable
|
|
226
|
+
if (!isRetryableError(error, this.config.retryOnErrors, this.config.retryableErrorPatterns)) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Pitfall 1: Only one handler plans+dispatches per session
|
|
231
|
+
if (!this.acquireRetryLock(sessionID)) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Check session state
|
|
236
|
+
const state = this.sessionStates.get(sessionID);
|
|
237
|
+
if (!state) {
|
|
238
|
+
this.releaseRetryLock(sessionID);
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Plan fallback via pure function — pass agent name for per-agent chain resolution
|
|
243
|
+
const agentName = this.sessionAgentName.get(sessionID);
|
|
244
|
+
const chain = this.resolveFallbackChain(sessionID, agentName);
|
|
245
|
+
const result = planFallback(
|
|
246
|
+
state,
|
|
247
|
+
chain,
|
|
248
|
+
this.config.maxFallbackAttempts,
|
|
249
|
+
this.config.cooldownSeconds * 1000,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
if (!result.success) {
|
|
253
|
+
this.releaseRetryLock(sessionID);
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Return plan (caller commits after successful dispatch)
|
|
258
|
+
return result.plan;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Applies a fallback plan to session state immutably.
|
|
263
|
+
* Returns true if the plan was committed, false if stale or session unknown.
|
|
264
|
+
*/
|
|
265
|
+
commitAndUpdateState(sessionID: string, plan: FallbackPlan): boolean {
|
|
266
|
+
const state = this.sessionStates.get(sessionID);
|
|
267
|
+
if (!state) {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const result = commitFallback(state, plan);
|
|
272
|
+
if (result.committed) {
|
|
273
|
+
this.sessionStates.set(sessionID, result.state);
|
|
274
|
+
}
|
|
275
|
+
return result.committed;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Attempts to recover to the original model after cooldown expires.
|
|
280
|
+
* Returns true if recovery succeeded, false otherwise.
|
|
281
|
+
*/
|
|
282
|
+
tryRecoverToOriginal(sessionID: string): boolean {
|
|
283
|
+
const state = this.sessionStates.get(sessionID);
|
|
284
|
+
if (!state) {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const recovered = recoverToOriginal(state, this.config.cooldownSeconds * 1000);
|
|
289
|
+
if (recovered) {
|
|
290
|
+
this.sessionStates.set(sessionID, recovered);
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Marks a session as having compaction in flight.
|
|
298
|
+
*/
|
|
299
|
+
markCompactionInFlight(sessionID: string): void {
|
|
300
|
+
this.sessionCompactionInFlight.add(sessionID);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Clears the compaction-in-flight flag for a session.
|
|
305
|
+
*/
|
|
306
|
+
clearCompactionInFlight(sessionID: string): void {
|
|
307
|
+
this.sessionCompactionInFlight.delete(sessionID);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Returns true if compaction is in flight for a session.
|
|
312
|
+
*/
|
|
313
|
+
isCompactionInFlight(sessionID: string): boolean {
|
|
314
|
+
return this.sessionCompactionInFlight.has(sessionID);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Returns the parent session ID for a child session, or undefined if not tracked.
|
|
319
|
+
*/
|
|
320
|
+
getParentID(sessionID: string): string | null | undefined {
|
|
321
|
+
return this.sessionParentID.get(sessionID);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CommitResult,
|
|
3
|
+
FallbackPlan,
|
|
4
|
+
FallbackPlanFailure,
|
|
5
|
+
FallbackPlanResult,
|
|
6
|
+
FallbackState,
|
|
7
|
+
} from "./types";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Creates a new FallbackState for a model. Initial state: on primary model, no failures.
|
|
11
|
+
*/
|
|
12
|
+
export function createFallbackState(model: string): FallbackState {
|
|
13
|
+
return {
|
|
14
|
+
originalModel: model,
|
|
15
|
+
currentModel: model,
|
|
16
|
+
fallbackIndex: -1,
|
|
17
|
+
failedModels: new Map(),
|
|
18
|
+
attemptCount: 0,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Plans the next fallback action. Pure function: reads state + config, returns plan or failure.
|
|
24
|
+
* Does NOT mutate state.
|
|
25
|
+
*
|
|
26
|
+
* Finds the next model in the fallback chain that is not in cooldown.
|
|
27
|
+
* Returns failure if max attempts reached or all models exhausted.
|
|
28
|
+
*/
|
|
29
|
+
export function planFallback(
|
|
30
|
+
state: Readonly<FallbackState>,
|
|
31
|
+
fallbackChain: readonly string[],
|
|
32
|
+
maxAttempts: number,
|
|
33
|
+
cooldownMs: number,
|
|
34
|
+
): FallbackPlanResult | FallbackPlanFailure {
|
|
35
|
+
if (state.attemptCount >= maxAttempts) {
|
|
36
|
+
return {
|
|
37
|
+
success: false as const,
|
|
38
|
+
reason: `Max fallback attempts reached (${maxAttempts})`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
|
|
44
|
+
// Iterate chain starting from fallbackIndex + 1, wrapping around
|
|
45
|
+
for (let i = 0; i < fallbackChain.length; i++) {
|
|
46
|
+
const index = (state.fallbackIndex + 1 + i) % fallbackChain.length;
|
|
47
|
+
const model = fallbackChain[index];
|
|
48
|
+
|
|
49
|
+
// Skip current model — can't fall back to the model that just failed
|
|
50
|
+
if (model === state.currentModel) continue;
|
|
51
|
+
|
|
52
|
+
// Skip models still in cooldown
|
|
53
|
+
const failedAt = state.failedModels.get(model);
|
|
54
|
+
if (failedAt !== undefined && now - failedAt < cooldownMs) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
success: true as const,
|
|
60
|
+
plan: {
|
|
61
|
+
failedModel: state.currentModel,
|
|
62
|
+
newModel: model,
|
|
63
|
+
newFallbackIndex: index,
|
|
64
|
+
reason: `Fallback from ${state.currentModel} to ${model}`,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
success: false as const,
|
|
71
|
+
reason: "All fallback models exhausted or in cooldown",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Commits a fallback plan to produce a new state. Pure function: returns new state, NEVER mutates input.
|
|
77
|
+
*
|
|
78
|
+
* Returns committed:false if the plan is stale (currentModel changed since plan was created).
|
|
79
|
+
*/
|
|
80
|
+
export function commitFallback(state: Readonly<FallbackState>, plan: FallbackPlan): CommitResult {
|
|
81
|
+
if (state.currentModel !== plan.failedModel) {
|
|
82
|
+
return { committed: false, state: { ...state, failedModels: new Map(state.failedModels) } };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
committed: true,
|
|
87
|
+
state: {
|
|
88
|
+
...state,
|
|
89
|
+
fallbackIndex: plan.newFallbackIndex,
|
|
90
|
+
failedModels: new Map([...state.failedModels, [plan.failedModel, Date.now()]]),
|
|
91
|
+
attemptCount: state.attemptCount + 1,
|
|
92
|
+
currentModel: plan.newModel,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Attempts to recover to the original model after cooldown expires.
|
|
99
|
+
* Pure function: returns new state if recovery is possible, null otherwise.
|
|
100
|
+
*
|
|
101
|
+
* Returns null if already on original model or if original is still in cooldown.
|
|
102
|
+
*/
|
|
103
|
+
export function recoverToOriginal(
|
|
104
|
+
state: Readonly<FallbackState>,
|
|
105
|
+
cooldownMs: number,
|
|
106
|
+
): FallbackState | null {
|
|
107
|
+
if (state.currentModel === state.originalModel) return null;
|
|
108
|
+
|
|
109
|
+
const failedAt = state.failedModels.get(state.originalModel);
|
|
110
|
+
if (failedAt !== undefined && Date.now() - failedAt < cooldownMs) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
...state,
|
|
116
|
+
currentModel: state.originalModel,
|
|
117
|
+
fallbackIndex: -1,
|
|
118
|
+
failedModels: new Map(state.failedModels),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { createChatMessageHandler } from "./chat-message-handler";
|
|
2
|
+
export * from "./error-classifier";
|
|
3
|
+
export type { EventHandlerDeps, SdkOperations } from "./event-handler";
|
|
4
|
+
export { createEventHandler } from "./event-handler";
|
|
5
|
+
export * from "./fallback-config";
|
|
6
|
+
export type { FallbackManagerOptions } from "./fallback-manager";
|
|
7
|
+
export { FallbackManager } from "./fallback-manager";
|
|
8
|
+
export * from "./fallback-state";
|
|
9
|
+
export * from "./message-replay";
|
|
10
|
+
export { createToolExecuteAfterHandler } from "./tool-execute-handler";
|
|
11
|
+
export * from "./types";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ContentTier, MessagePart } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Filters message parts based on content tier.
|
|
5
|
+
*
|
|
6
|
+
* - Tier 1: All parts (full fidelity)
|
|
7
|
+
* - Tier 2: Text + images (filters out tool_call and tool_result)
|
|
8
|
+
* - Tier 3: Text only (maximum compatibility)
|
|
9
|
+
*/
|
|
10
|
+
export function filterPartsByTier(
|
|
11
|
+
parts: readonly MessagePart[],
|
|
12
|
+
tier: ContentTier,
|
|
13
|
+
): readonly MessagePart[] {
|
|
14
|
+
if (tier === 1) return parts;
|
|
15
|
+
|
|
16
|
+
if (tier === 2) {
|
|
17
|
+
return parts.filter((part) => part.type !== "tool_call" && part.type !== "tool_result");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Tier 3: text only
|
|
21
|
+
return parts.filter((part) => part.type === "text");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Returns degraded message parts based on the attempt number.
|
|
26
|
+
*
|
|
27
|
+
* - Attempt 0: Tier 1 (all parts)
|
|
28
|
+
* - Attempt 1: Tier 2 (text + images, no tool parts)
|
|
29
|
+
* - Attempt 2+: Tier 3 (text only)
|
|
30
|
+
*/
|
|
31
|
+
export function replayWithDegradation(
|
|
32
|
+
parts: readonly MessagePart[],
|
|
33
|
+
attempt: number,
|
|
34
|
+
): { readonly parts: readonly MessagePart[]; readonly tier: ContentTier } {
|
|
35
|
+
const tier: ContentTier = attempt === 0 ? 1 : attempt === 1 ? 2 : 3;
|
|
36
|
+
return {
|
|
37
|
+
parts: filterPartsByTier(parts, tier),
|
|
38
|
+
tier,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two-tier fallback chain resolution.
|
|
3
|
+
*
|
|
4
|
+
* Tier 1: Per-agent fallback_models from opencode.json agent config
|
|
5
|
+
* Tier 2: Global fallback_models from plugin config
|
|
6
|
+
*
|
|
7
|
+
* Normalizes a single string to a single-element array.
|
|
8
|
+
* Always returns a new array (no shared references).
|
|
9
|
+
*/
|
|
10
|
+
export function resolveChain(
|
|
11
|
+
agentName: string,
|
|
12
|
+
agentConfigs: Record<string, Record<string, unknown>> | undefined,
|
|
13
|
+
globalFallbacks: string | readonly string[] | undefined,
|
|
14
|
+
): string[] {
|
|
15
|
+
// Tier 1: Per-agent fallback_models
|
|
16
|
+
if (agentName && agentConfigs?.[agentName]) {
|
|
17
|
+
const perAgent = agentConfigs[agentName].fallback_models;
|
|
18
|
+
if (perAgent) {
|
|
19
|
+
if (typeof perAgent === "string") return [perAgent];
|
|
20
|
+
if (Array.isArray(perAgent))
|
|
21
|
+
return perAgent.filter((m): m is string => typeof m === "string" && m.length > 0);
|
|
22
|
+
}
|
|
23
|
+
// Non-string/non-array value (e.g. number, object) — skip to Tier 2
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Tier 2: Global fallback_models
|
|
27
|
+
if (globalFallbacks) {
|
|
28
|
+
if (typeof globalFallbacks === "string") return [globalFallbacks];
|
|
29
|
+
if (Array.isArray(globalFallbacks))
|
|
30
|
+
return [...globalFallbacks].filter((m): m is string => typeof m === "string" && m.length > 0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { FallbackManager } from "./fallback-manager";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Factory that creates a tool.execute.after hook handler for subagent result sync.
|
|
5
|
+
*
|
|
6
|
+
* Intercepts "task" tool results (subagent dispatches). When a child session's
|
|
7
|
+
* fallback is still in progress, the task result may be empty. This handler
|
|
8
|
+
* marks such results with a `fallbackPending` metadata flag so the orchestrator
|
|
9
|
+
* knows to retry.
|
|
10
|
+
*/
|
|
11
|
+
export function createToolExecuteAfterHandler(manager: FallbackManager) {
|
|
12
|
+
return async (
|
|
13
|
+
input: {
|
|
14
|
+
readonly tool: string;
|
|
15
|
+
readonly sessionID: string;
|
|
16
|
+
readonly callID: string;
|
|
17
|
+
readonly args: unknown;
|
|
18
|
+
},
|
|
19
|
+
output: { title: string; output: string; metadata: unknown },
|
|
20
|
+
): Promise<void> => {
|
|
21
|
+
// Only intercept 'task' tool results (subagent dispatch results)
|
|
22
|
+
if (input.tool !== "task") return;
|
|
23
|
+
|
|
24
|
+
// Check if output is empty (indicates subagent failure mid-fallback)
|
|
25
|
+
if (
|
|
26
|
+
!output.output ||
|
|
27
|
+
output.output.trim() === "" ||
|
|
28
|
+
output.output === "<task_result></task_result>"
|
|
29
|
+
) {
|
|
30
|
+
// Check if child session had a parent
|
|
31
|
+
const parentID = manager.getParentID(input.sessionID);
|
|
32
|
+
if (parentID != null) {
|
|
33
|
+
// Mark for the orchestrator to retry -- the child session's fallback
|
|
34
|
+
// will complete asynchronously. Add metadata flag.
|
|
35
|
+
output.metadata = {
|
|
36
|
+
...(typeof output.metadata === "object" && output.metadata !== null
|
|
37
|
+
? output.metadata
|
|
38
|
+
: {}),
|
|
39
|
+
fallbackPending: true,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export type ErrorType =
|
|
2
|
+
| "rate_limit"
|
|
3
|
+
| "quota_exceeded"
|
|
4
|
+
| "service_unavailable"
|
|
5
|
+
| "missing_api_key"
|
|
6
|
+
| "model_not_found"
|
|
7
|
+
| "content_filter"
|
|
8
|
+
| "context_length"
|
|
9
|
+
| "unknown";
|
|
10
|
+
|
|
11
|
+
export type ContentTier = 1 | 2 | 3;
|
|
12
|
+
|
|
13
|
+
export interface FallbackState {
|
|
14
|
+
readonly originalModel: string;
|
|
15
|
+
readonly currentModel: string;
|
|
16
|
+
readonly fallbackIndex: number; // -1 = primary
|
|
17
|
+
readonly failedModels: ReadonlyMap<string, number>; // model -> timestamp
|
|
18
|
+
readonly attemptCount: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface FallbackPlan {
|
|
22
|
+
readonly failedModel: string;
|
|
23
|
+
readonly newModel: string;
|
|
24
|
+
readonly newFallbackIndex: number;
|
|
25
|
+
readonly reason: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface FallbackPlanResult {
|
|
29
|
+
readonly success: true;
|
|
30
|
+
readonly plan: FallbackPlan;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface FallbackPlanFailure {
|
|
34
|
+
readonly success: false;
|
|
35
|
+
readonly reason: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CommitResult {
|
|
39
|
+
readonly committed: boolean;
|
|
40
|
+
readonly state: FallbackState;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MessagePart {
|
|
44
|
+
readonly type: string;
|
|
45
|
+
readonly [key: string]: unknown;
|
|
46
|
+
}
|