@flowcodex/core 0.3.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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -0
  3. package/dist/index-LbxYtxxS.d.ts +560 -0
  4. package/dist/index.d.ts +995 -0
  5. package/dist/index.js +3840 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/kernel/index.d.ts +1 -0
  8. package/dist/kernel/index.js +551 -0
  9. package/dist/kernel/index.js.map +1 -0
  10. package/package.json +39 -0
  11. package/src/agent/agent-loop.ts +254 -0
  12. package/src/agent/context.ts +99 -0
  13. package/src/agent/conversation-state.ts +44 -0
  14. package/src/agent/provider-runner.ts +241 -0
  15. package/src/agent/system-prompt-builder.ts +193 -0
  16. package/src/execution/compactor.ts +256 -0
  17. package/src/execution/index.ts +7 -0
  18. package/src/execution/output-serializer.ts +90 -0
  19. package/src/execution/schema-validator.ts +124 -0
  20. package/src/execution/tool-executor.ts +276 -0
  21. package/src/execution/tool-registry.ts +104 -0
  22. package/src/index.ts +215 -0
  23. package/src/infrastructure/catalog-parser.ts +218 -0
  24. package/src/infrastructure/index.ts +16 -0
  25. package/src/infrastructure/path-resolver.ts +123 -0
  26. package/src/infrastructure/provider-factory.ts +116 -0
  27. package/src/infrastructure/provider-presets.ts +19 -0
  28. package/src/infrastructure/retry-policy.ts +50 -0
  29. package/src/infrastructure/secret-scrubber.ts +67 -0
  30. package/src/infrastructure/token-counter.ts +156 -0
  31. package/src/infrastructure/tracer.ts +23 -0
  32. package/src/kernel/container.ts +166 -0
  33. package/src/kernel/events.ts +323 -0
  34. package/src/kernel/index.ts +18 -0
  35. package/src/kernel/pipeline.ts +152 -0
  36. package/src/kernel/run-controller.ts +85 -0
  37. package/src/kernel/tokens.ts +21 -0
  38. package/src/security/index.ts +13 -0
  39. package/src/security/permission-policy.ts +273 -0
  40. package/src/session/audit-log.ts +201 -0
  41. package/src/session/auth-service.ts +178 -0
  42. package/src/session/index.ts +26 -0
  43. package/src/session/secret-vault.ts +183 -0
  44. package/src/session/session-store.ts +339 -0
  45. package/src/session/types.ts +100 -0
  46. package/src/types/blocks.ts +56 -0
  47. package/src/types/context.ts +54 -0
  48. package/src/types/errors.ts +359 -0
  49. package/src/types/index.ts +34 -0
  50. package/src/types/provider.ts +58 -0
  51. package/src/types/tool.ts +39 -0
  52. package/src/utils/error.ts +3 -0
  53. package/src/utils/fs.ts +185 -0
  54. package/src/utils/image-resize.ts +76 -0
  55. package/src/utils/ssrf-guard.ts +133 -0
  56. package/src/utils/ulid.ts +72 -0
  57. package/src/utils/version-check.ts +59 -0
  58. package/tests/agent-loop.test.ts +490 -0
  59. package/tests/audit-log.test.ts +199 -0
  60. package/tests/auth-service.test.ts +170 -0
  61. package/tests/blocks.test.ts +79 -0
  62. package/tests/catalog-parser.test.ts +174 -0
  63. package/tests/compactor.test.ts +180 -0
  64. package/tests/container.test.ts +224 -0
  65. package/tests/conversation-state.test.ts +75 -0
  66. package/tests/errors.test.ts +429 -0
  67. package/tests/events-v021.test.ts +60 -0
  68. package/tests/events-v022.test.ts +75 -0
  69. package/tests/events.test.ts +340 -0
  70. package/tests/fixtures/large-image.png +0 -0
  71. package/tests/fixtures/small-image.png +0 -0
  72. package/tests/fs-utils.test.ts +164 -0
  73. package/tests/image-resize.test.ts +51 -0
  74. package/tests/output-serializer.test.ts +79 -0
  75. package/tests/path-resolver.test.ts +91 -0
  76. package/tests/permission-policy.test.ts +174 -0
  77. package/tests/pipeline.test.ts +193 -0
  78. package/tests/provider-factory.test.ts +245 -0
  79. package/tests/provider-runner.test.ts +535 -0
  80. package/tests/retry-policy.test.ts +104 -0
  81. package/tests/run-controller.test.ts +115 -0
  82. package/tests/sanity.test.ts +26 -0
  83. package/tests/schema-validator.test.ts +109 -0
  84. package/tests/secret-scrubber.test.ts +133 -0
  85. package/tests/secret-vault.test.ts +130 -0
  86. package/tests/session-store.test.ts +429 -0
  87. package/tests/ssrf-guard.test.ts +112 -0
  88. package/tests/system-prompt-builder.test.ts +116 -0
  89. package/tests/token-counter.test.ts +163 -0
  90. package/tests/tokens.test.ts +42 -0
  91. package/tests/tool-executor.test.ts +452 -0
  92. package/tests/tool-registry.test.ts +143 -0
  93. package/tests/tracer.test.ts +32 -0
  94. package/tests/ulid.test.ts +53 -0
  95. package/tests/version-check.test.ts +57 -0
  96. package/tsconfig.json +11 -0
  97. package/tsup.config.ts +16 -0
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@flowcodex/core",
3
+ "version": "0.3.0",
4
+ "description": "FlowCodex kernel and agent runtime — Container, Pipeline, EventBus, RunController, Agent, ToolExecutor",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "main": "./dist/index.js",
11
+ "module": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./src/index.ts",
16
+ "development": "./src/index.ts",
17
+ "import": "./dist/index.js"
18
+ },
19
+ "./kernel": {
20
+ "types": "./src/kernel/index.ts",
21
+ "development": "./src/kernel/index.ts",
22
+ "import": "./dist/kernel/index.js"
23
+ }
24
+ },
25
+ "dependencies": {
26
+ "@silvia-odwyer/photon": "^0.3.3",
27
+ "ulid": "^3.0.1"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^22.0.0"
31
+ },
32
+ "scripts": {
33
+ "build": "tsup",
34
+ "dev": "tsup --watch",
35
+ "typecheck": "tsc --noEmit",
36
+ "test": "vitest run",
37
+ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
38
+ }
39
+ }
@@ -0,0 +1,254 @@
1
+ import type { CatalogParser } from '../infrastructure/catalog-parser.js';
2
+ import type { RetryPolicy } from '../infrastructure/retry-policy.js';
3
+ import type { TokenCounter } from '../infrastructure/token-counter.js';
4
+ import { DefaultTokenCounter } from '../infrastructure/token-counter.js';
5
+ import type { EventBus } from '../kernel/events.js';
6
+ import type { ContentBlock, Message } from '../types/blocks.js';
7
+ import { isToolUseBlock } from '../types/blocks.js';
8
+ import type { Tool } from '../types/tool.js';
9
+ import type { SystemPromptBuilder } from './system-prompt-builder.js';
10
+ import type { AgentStatus, Context, RunResult } from '../types/context.js';
11
+ import type { LLMRequest, LLMResponse, Provider } from '../types/provider.js';
12
+ import type { FallbackEntry } from './provider-runner.js';
13
+ import { FlowCodexError, ERROR_CODES } from '../types/errors.js';
14
+ import { runProviderWithRetry } from './provider-runner.js';
15
+ import type { Compactor } from '../execution/compactor.js';
16
+ import { MODE_CONFIGS, ACTIVE_MODE, NOOP_RETRY_DELTA_TOKENS } from '../execution/compactor.js';
17
+
18
+ export interface AgentLoopOptions {
19
+ ctx: Context;
20
+ provider: Provider;
21
+ events: EventBus;
22
+ retry: RetryPolicy;
23
+ maxIterations?: number;
24
+ signal: AbortSignal;
25
+ executeTools?: (toolUses: { id: string; name: string; input: unknown }[]) => Promise<void>;
26
+ fallbackModel?: string | undefined;
27
+ fallback?: FallbackEntry[] | undefined;
28
+ catalog?: CatalogParser | undefined;
29
+ /** DI TokenCounter — single source of truth for token estimates. Falls back
30
+ * to a per-run DefaultTokenCounter when omitted (e.g. unit tests). */
31
+ tokenCounter?: TokenCounter | undefined;
32
+ /** Builds the structured system prompt (4 layers + cache_control). When
33
+ * provided, the loop refreshes ctx.systemPrompt each iteration. */
34
+ systemPromptBuilder?: SystemPromptBuilder | undefined;
35
+ tokenSavingMode?: boolean | undefined;
36
+ /** HybridCompactor for inline compaction at the soft/hard thresholds. When
37
+ * provided, the loop compacts ctx.state.messages before each request. */
38
+ compactor?: Compactor | undefined;
39
+ }
40
+
41
+ export async function runAgentLoop(opts: AgentLoopOptions): Promise<RunResult> {
42
+ const { ctx, provider, events, retry, signal } = opts;
43
+ const tokenCounter = opts.tokenCounter ?? new DefaultTokenCounter();
44
+ const maxIterations = opts.maxIterations ?? ctx.budget.maxIterations;
45
+ let iterations = 0;
46
+ let finalText = '';
47
+
48
+ for (let i = 0; ; i++) {
49
+ iterations = i + 1;
50
+
51
+ if (signal.aborted) {
52
+ return { status: 'aborted', iterations, finalText, abortReason: 'aborted' };
53
+ }
54
+
55
+ if (i >= maxIterations) {
56
+ events.emit('iteration.limit_reached', {
57
+ currentIterations: i,
58
+ currentLimit: maxIterations,
59
+ grant: () => {},
60
+ deny: () => {},
61
+ });
62
+ return { status: 'limit_reached' as AgentStatus, iterations, finalText };
63
+ }
64
+
65
+ events.emit('iteration.started', { index: i });
66
+
67
+ if (ctx.btwNotes.length > 0) {
68
+ const notes = ctx.btwNotes.splice(0, ctx.btwNotes.length);
69
+ const block: ContentBlock = {
70
+ type: 'text',
71
+ text: `[BY THE WAY — the user added this while you were working. Fold it into your current task; do not restart.]\n${notes.join('\n')}`,
72
+ };
73
+ const last = ctx.messages[ctx.messages.length - 1];
74
+ if (last && last.role === 'user') {
75
+ const content: ContentBlock[] =
76
+ typeof last.content === 'string'
77
+ ? [{ type: 'text', text: last.content }, block]
78
+ : [...last.content, block];
79
+ ctx.messages[ctx.messages.length - 1] = { ...last, content };
80
+ } else {
81
+ ctx.messages.push({ role: 'user', content: [block] });
82
+ }
83
+ }
84
+
85
+ const structuredOutput = ctx.structuredOutput;
86
+ const toolsForRequest = structuredOutput
87
+ ? [{
88
+ name: structuredOutput.name,
89
+ description: structuredOutput.description ?? 'Return the structured output as JSON.',
90
+ inputSchema: structuredOutput.schema,
91
+ }]
92
+ : ctx.readOnly
93
+ ? (ctx.tools as Array<{ mutating?: boolean }>).filter((t) => !t.mutating)
94
+ : ctx.tools;
95
+
96
+ if (opts.systemPromptBuilder) {
97
+ ctx.systemPrompt = await opts.systemPromptBuilder.build({
98
+ tools: toolsForRequest as readonly Tool[],
99
+ model: ctx.model,
100
+ projectRoot: ctx.projectRoot,
101
+ cwd: ctx.workingDir,
102
+ tokenSavingMode: opts.tokenSavingMode ?? false,
103
+ });
104
+ }
105
+
106
+ const requestMaxTokens = ctx.maxTokens ?? 8192;
107
+ ctx.lastRequestTokens = tokenCounter.estimateRequestTokens(
108
+ ctx.systemPrompt,
109
+ ctx.messages,
110
+ toolsForRequest,
111
+ ctx.model.model,
112
+ );
113
+ tokenCounter.setRequestTokens(ctx.lastRequestTokens);
114
+
115
+ if (opts.compactor) {
116
+ const config = MODE_CONFIGS[ACTIVE_MODE];
117
+ const usable = resolveUsable(opts.catalog, ctx.model.provider, ctx.model.model, requestMaxTokens);
118
+ const load = usable > 0 ? ctx.lastRequestTokens / usable : 0;
119
+
120
+ events.emit('ctx.pct', { load, tokens: ctx.lastRequestTokens, maxContext: usable });
121
+
122
+ if (load >= config.soft) {
123
+ const aggressive = load >= config.hard;
124
+ const result = await opts.compactor.compact({
125
+ messages: ctx.state.messages,
126
+ mode: ACTIVE_MODE,
127
+ aggressive,
128
+ });
129
+ if (result.after < result.before - NOOP_RETRY_DELTA_TOKENS) {
130
+ ctx.state.replaceMessages(result.messages);
131
+ ctx.lastRequestTokens = result.after;
132
+ events.emit('compaction.fired', { before: result.before, after: result.after, level: result.level, aggressive });
133
+ } else {
134
+ events.emit('compaction.failed', { reason: 'noop below delta', attemptedLevel: result.level });
135
+ }
136
+ if (load >= config.hard) {
137
+ const newLoad = usable > 0 ? ctx.lastRequestTokens / usable : 0;
138
+ if (newLoad >= config.hard) {
139
+ throw new FlowCodexError({
140
+ message: 'compaction could not reduce context below hard threshold',
141
+ code: ERROR_CODES.COMPACTION_FAILED,
142
+ subsystem: 'agent',
143
+ });
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ const request: LLMRequest = {
150
+ model: ctx.model.model,
151
+ system: ctx.systemPrompt,
152
+ messages: ctx.messages,
153
+ tools: toolsForRequest,
154
+ max_tokens: requestMaxTokens,
155
+ ...(structuredOutput && { tool_choice: { type: 'tool', name: structuredOutput.name } }),
156
+ };
157
+
158
+ let response: LLMResponse;
159
+ try {
160
+ response = await runProviderWithRetry({
161
+ provider,
162
+ request,
163
+ signal,
164
+ events,
165
+ retry,
166
+ fallbackModel: opts.fallbackModel,
167
+ fallback: opts.fallback,
168
+ catalog: opts.catalog,
169
+ });
170
+ } catch (err) {
171
+ if (signal.aborted) {
172
+ return { status: 'aborted', iterations, finalText, abortReason: 'aborted' };
173
+ }
174
+ return { status: 'failed', iterations, finalText, error: err };
175
+ }
176
+
177
+ if (response.usage) {
178
+ tokenCounter.account(response.usage, ctx.model.model);
179
+ const cost = tokenCounter.estimateCost(ctx.model.model, response.usage);
180
+ events.emit('token.accounted', { usage: response.usage, cost: { input: cost, output: cost, total: cost } });
181
+ }
182
+
183
+ const assistantMessage: Message = { role: 'assistant', content: response.content };
184
+ ctx.messages.push(assistantMessage);
185
+
186
+ const toolUses = response.content.filter(isToolUseBlock);
187
+ const textBlocks = response.content.filter((b) => b.type === 'text');
188
+ const firstText = textBlocks[0];
189
+ if (firstText && 'text' in firstText) {
190
+ finalText = firstText.text;
191
+ }
192
+
193
+ if (structuredOutput) {
194
+ const structuredCall = toolUses.find((t) => t.name === structuredOutput.name);
195
+ if (structuredCall) {
196
+ events.emit('provider.structured_output', { name: structuredOutput.name, valid: true });
197
+ return { status: 'structured', iterations, finalText, structuredResult: structuredCall.input };
198
+ }
199
+ events.emit('provider.structured_output', { name: structuredOutput.name, valid: false });
200
+ return {
201
+ status: 'failed',
202
+ iterations,
203
+ finalText,
204
+ error: new FlowCodexError({
205
+ message: 'model did not call the forced structured tool',
206
+ code: ERROR_CODES.AGENT_STRUCTURED_OUTPUT_NOT_PRODUCED,
207
+ subsystem: 'agent',
208
+ }),
209
+ };
210
+ }
211
+
212
+ if (toolUses.length === 0 || response.stopReason !== 'tool_use') {
213
+ events.emit('iteration.completed', { index: i });
214
+ return { status: 'completed', iterations, finalText };
215
+ }
216
+
217
+ if (opts.executeTools) {
218
+ try {
219
+ await opts.executeTools(toolUses);
220
+ } catch (toolErr) {
221
+ if (signal.aborted) {
222
+ return { status: 'aborted', iterations, finalText, abortReason: 'aborted' };
223
+ }
224
+ return { status: 'failed', iterations, finalText, error: toolErr };
225
+ }
226
+ }
227
+
228
+ if (signal.aborted) {
229
+ return { status: 'aborted', iterations, finalText, abortReason: 'aborted' };
230
+ }
231
+
232
+ events.emit('iteration.completed', { index: i });
233
+ }
234
+ }
235
+
236
+ function resolveUsable(
237
+ catalog: CatalogParser | undefined,
238
+ provider: string,
239
+ model: string,
240
+ requestMaxTokens: number,
241
+ ): number {
242
+ let context = 200_000;
243
+ try {
244
+ const models = catalog?.getModels(provider);
245
+ const match = models?.find(
246
+ (m) => m.id === model,
247
+ );
248
+ if (match?.limit?.context) context = match.limit.context;
249
+ } catch {
250
+ // catalog unavailable → fallback
251
+ }
252
+ const reserved = Math.max(20_000, requestMaxTokens);
253
+ return context - reserved;
254
+ }
@@ -0,0 +1,99 @@
1
+ import * as path from 'node:path';
2
+ import type { ContentBlock, Message, ModelRef } from '../types/blocks.js';
3
+ import type { Budget } from '../types/context.js';
4
+ import type { Context as IContext } from '../types/context.js';
5
+ import type { StructuredOutputConfig } from '../types/provider.js';
6
+ import { DefaultConversationState } from './conversation-state.js';
7
+ import type { ConversationState } from './conversation-state.js';
8
+
9
+ export interface ContextInit {
10
+ model: ModelRef;
11
+ projectRoot: string;
12
+ cwd?: string;
13
+ systemPrompt?: ContentBlock[];
14
+ budget?: Budget;
15
+ signal?: AbortSignal;
16
+ readOnly?: boolean;
17
+ structuredOutput?: StructuredOutputConfig | undefined;
18
+ maxTokens?: number | undefined;
19
+ batchToolUse?: boolean | undefined;
20
+ }
21
+
22
+ export class AgentContext implements IContext {
23
+ private readonly _state = new DefaultConversationState();
24
+ get messages(): Message[] {
25
+ return this._state.messages;
26
+ }
27
+ get state(): ConversationState {
28
+ return this._state;
29
+ }
30
+ model: ModelRef;
31
+ projectRoot: string;
32
+ workingDir: string;
33
+ tools: unknown[] = [];
34
+ readFiles = new Map<string, number>();
35
+ btwNotes: string[] = [];
36
+ lastRequestTokens = 0;
37
+ budget: Budget;
38
+ signal: AbortSignal;
39
+ systemPrompt: ContentBlock[];
40
+ readOnly: boolean;
41
+ structuredOutput: StructuredOutputConfig | undefined;
42
+ maxTokens: number | undefined;
43
+ batchToolUse: boolean | undefined;
44
+
45
+ private abortHooks = new Set<() => void | Promise<void>>();
46
+
47
+ constructor(init: ContextInit) {
48
+ this.model = init.model;
49
+ this.projectRoot = init.projectRoot;
50
+ this.workingDir = init.cwd ?? init.projectRoot;
51
+ this.budget = init.budget ?? { maxIterations: 50, maxTokens: 1_000_000, maxCost: 10 };
52
+ this.signal = init.signal ?? new AbortController().signal;
53
+ this.systemPrompt = init.systemPrompt ?? [];
54
+ this.readOnly = init.readOnly ?? false;
55
+ this.structuredOutput = init.structuredOutput;
56
+ this.maxTokens = init.maxTokens;
57
+ this.batchToolUse = init.batchToolUse;
58
+ }
59
+
60
+ registerAbortHook(fn: () => void | Promise<void>): () => void {
61
+ this.abortHooks.add(fn);
62
+ return () => this.abortHooks.delete(fn);
63
+ }
64
+
65
+ async drainAbortHooks(): Promise<void> {
66
+ const snapshot = [...this.abortHooks].reverse();
67
+ this.abortHooks.clear();
68
+ for (const fn of snapshot) {
69
+ try {
70
+ await fn();
71
+ } catch {
72
+ // best-effort
73
+ }
74
+ }
75
+ }
76
+
77
+ setWorkingDir(dir: string): void {
78
+ const resolved = path.isAbsolute(dir)
79
+ ? path.resolve(dir)
80
+ : path.resolve(this.projectRoot, dir);
81
+ const rel = path.relative(this.projectRoot, resolved);
82
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
83
+ throw new Error(`Working directory "${resolved}" is outside project root "${this.projectRoot}"`);
84
+ }
85
+ this.workingDir = resolved;
86
+ }
87
+
88
+ recordRead(absPath: string, mtimeMs: number): void {
89
+ this.readFiles.set(absPath, mtimeMs);
90
+ }
91
+
92
+ clearFileTracking(): void {
93
+ this.readFiles.clear();
94
+ }
95
+
96
+ hasRead(absPath: string): boolean {
97
+ return this.readFiles.has(absPath);
98
+ }
99
+ }
@@ -0,0 +1,44 @@
1
+ import type { Message } from '../types/blocks.js';
2
+
3
+ export interface ConversationState {
4
+ readonly messages: readonly Message[];
5
+ appendMessage(m: Message): void;
6
+ replaceMessages(ms: readonly Message[]): void;
7
+ onChange(cb: () => void): () => void;
8
+ }
9
+
10
+ export class DefaultConversationState implements ConversationState {
11
+ private _messages: Message[] = [];
12
+ private readonly listeners = new Set<() => void>();
13
+
14
+ get messages(): Message[] {
15
+ return this._messages;
16
+ }
17
+
18
+ appendMessage(m: Message): void {
19
+ this._messages.push(m);
20
+ this._notify();
21
+ }
22
+
23
+ replaceMessages(ms: readonly Message[]): void {
24
+ this._messages = [...ms];
25
+ this._notify();
26
+ }
27
+
28
+ onChange(cb: () => void): () => void {
29
+ this.listeners.add(cb);
30
+ return () => {
31
+ this.listeners.delete(cb);
32
+ };
33
+ }
34
+
35
+ private _notify(): void {
36
+ for (const cb of this.listeners) {
37
+ try {
38
+ cb();
39
+ } catch {
40
+ // swallow — mirrors EventBus listener semantics (one bad listener can't block siblings)
41
+ }
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,241 @@
1
+ import type { CatalogParser } from '../infrastructure/catalog-parser.js';
2
+ import type { RetryPolicy } from '../infrastructure/retry-policy.js';
3
+ import type { EventBus } from '../kernel/events.js';
4
+ import { toFlowCodexError } from '../types/errors.js';
5
+ import type { LLMRequest, LLMResponse, Provider } from '../types/provider.js';
6
+ import { toErrorMessage } from '../utils/error.js';
7
+
8
+ export interface FallbackEntry {
9
+ providerId: string;
10
+ model: string;
11
+ providerFactory: () => Provider | undefined;
12
+ }
13
+
14
+ export interface RunProviderOptions {
15
+ provider: Provider;
16
+ request: LLMRequest;
17
+ signal: AbortSignal;
18
+ events: EventBus;
19
+ retry: RetryPolicy;
20
+ fallbackModel?: string | undefined;
21
+ fallback?: FallbackEntry[] | undefined;
22
+ catalog?: CatalogParser | undefined;
23
+ }
24
+
25
+ export async function runProviderWithRetry(opts: RunProviderOptions): Promise<LLMResponse> {
26
+ const { signal, events, retry } = opts;
27
+ let request = opts.request;
28
+ let activeProvider = opts.provider;
29
+ let attempt = 0;
30
+ let fallbackTried = false;
31
+ const triedModels = new Set<string>([`${activeProvider.name}/${request.model}`]);
32
+
33
+ for (;;) {
34
+ try {
35
+ const response = await aggregateStream(activeProvider, request, signal, events);
36
+ return response;
37
+ } catch (err) {
38
+ if (signal.aborted) throw err;
39
+
40
+ const providerErr = err as { status?: number; message: string; retryable?: boolean; retryAfterMs?: number };
41
+ const status = providerErr.status ?? 0;
42
+ const message = toErrorMessage(err);
43
+ const retryable = providerErr.retryable ?? false;
44
+
45
+ const shouldRetry = retry.shouldRetry({ status, message, retryable }, attempt);
46
+ if (!shouldRetry) {
47
+ if ((status === 429 || status === 529) && !fallbackTried) {
48
+ const fb = resolveFallbackModel(opts, activeProvider.name, request.model);
49
+ if (fb && !triedModels.has(`${activeProvider.name}/${fb}`)) {
50
+ triedModels.add(`${activeProvider.name}/${fb}`);
51
+ events.emit('provider.fallback', {
52
+ providerId: activeProvider.name,
53
+ from: `${activeProvider.name}/${request.model}`,
54
+ to: `${activeProvider.name}/${fb}`,
55
+ reason: 'rate_limit_exhausted',
56
+ status,
57
+ });
58
+ request = { ...request, model: fb };
59
+ attempt = 0;
60
+ fallbackTried = true;
61
+ continue;
62
+ }
63
+
64
+ if (opts.fallback && opts.fallback.length > 0) {
65
+ let hopped = false;
66
+ for (const entry of opts.fallback) {
67
+ const key = `${entry.providerId}/${entry.model}`;
68
+ if (triedModels.has(key)) continue;
69
+ triedModels.add(key);
70
+ const nextProvider = entry.providerFactory();
71
+ if (!nextProvider) {
72
+ events.emit('provider.fallback_skipped', {
73
+ providerId: entry.providerId,
74
+ reason: 'no_api_key',
75
+ });
76
+ continue;
77
+ }
78
+ events.emit('provider.fallback', {
79
+ providerId: entry.providerId,
80
+ from: `${activeProvider.name}/${request.model}`,
81
+ to: key,
82
+ reason: 'rate_limit_exhausted',
83
+ status,
84
+ });
85
+ activeProvider = nextProvider;
86
+ request = { ...request, model: entry.model };
87
+ attempt = 0;
88
+ fallbackTried = true;
89
+ hopped = true;
90
+ break;
91
+ }
92
+ if (hopped) continue;
93
+ }
94
+ }
95
+
96
+ events.emit('provider.error', {
97
+ providerId: activeProvider.name,
98
+ status,
99
+ description: message,
100
+ retryable: false,
101
+ });
102
+ throw toFlowCodexError(err);
103
+ }
104
+
105
+ const delay = providerErr.retryAfterMs ?? retry.delayMs(attempt, { status, message, retryable });
106
+ const attemptNum = attempt + 1;
107
+
108
+ events.emit('provider.retry', {
109
+ providerId: activeProvider.name,
110
+ attempt: attemptNum,
111
+ delayMs: delay,
112
+ status,
113
+ description: message,
114
+ });
115
+
116
+ await sleep(delay, signal);
117
+ attempt++;
118
+ }
119
+ }
120
+ }
121
+
122
+ function resolveFallbackModel(
123
+ opts: RunProviderOptions,
124
+ providerName: string,
125
+ currentModel: string,
126
+ ): string | undefined {
127
+ if (opts.fallbackModel) return opts.fallbackModel;
128
+ if (opts.catalog) {
129
+ const entry = opts.catalog.getCheapestModel(providerName, currentModel);
130
+ return entry?.id;
131
+ }
132
+ return undefined;
133
+ }
134
+
135
+ async function aggregateStream(
136
+ provider: Provider,
137
+ request: LLMRequest,
138
+ signal: AbortSignal,
139
+ events: EventBus,
140
+ ): Promise<LLMResponse> {
141
+ const textBuffers: string[] = [];
142
+ const thinkingBlocks: { text: string; signature?: string | undefined }[] = [];
143
+ const tools: Map<string, { name: string; input: string }> = new Map();
144
+ const blockOrder: string[] = [];
145
+ let usage: {
146
+ input: number;
147
+ output: number;
148
+ cache_read?: number | undefined;
149
+ cache_creation?: number | undefined;
150
+ } = { input: 0, output: 0 };
151
+ let stopReason = 'end_turn';
152
+
153
+ for await (const ev of provider.stream(request)) {
154
+ if (signal.aborted) throw new Error('aborted');
155
+
156
+ switch (ev.type) {
157
+ case 'text_delta':
158
+ textBuffers.push(ev.text);
159
+ events.emit('provider.text_delta', { text: ev.text });
160
+ break;
161
+ case 'thinking_delta':
162
+ thinkingBlocks.push({ text: ev.text });
163
+ events.emit('provider.thinking_delta', { text: ev.text });
164
+ break;
165
+ case 'tool_use_start':
166
+ tools.set(ev.id, { name: ev.name, input: '' });
167
+ blockOrder.push(`tool:${ev.id}`);
168
+ events.emit('provider.tool_use_start', { id: ev.id, name: ev.name });
169
+ break;
170
+ case 'tool_use_input_delta': {
171
+ const tool = tools.get(ev.id);
172
+ if (tool) {
173
+ tool.input += ev.partialJson;
174
+ }
175
+ events.emit('provider.tool_use_input_delta', { id: ev.id, partialJson: ev.partialJson });
176
+ break;
177
+ }
178
+ case 'tool_use_stop':
179
+ events.emit('provider.tool_use_stop', { id: ev.id, name: tools.get(ev.id)?.name ?? '' });
180
+ break;
181
+ case 'finish':
182
+ usage = ev.usage;
183
+ stopReason = ev.stopReason;
184
+ break;
185
+ case 'error':
186
+ events.emit('provider.stream_error', { eventType: 'error', msg: ev.error.message });
187
+ const streamErr = new Error(ev.error.message) as Error & {
188
+ status: number;
189
+ retryable: boolean;
190
+ retryAfterMs?: number | undefined;
191
+ };
192
+ streamErr.status = ev.error.status;
193
+ streamErr.retryable = ev.error.retryable;
194
+ streamErr.retryAfterMs = ev.error.retryAfterMs;
195
+ throw streamErr;
196
+ }
197
+ }
198
+
199
+ const content: import('../types/blocks.js').ContentBlock[] = [];
200
+
201
+ for (const t of thinkingBlocks) {
202
+ content.push({ type: 'thinking', text: t.text, signature: t.signature });
203
+ }
204
+
205
+ const text = textBuffers.join('');
206
+ if (text) {
207
+ content.push({ type: 'text', text });
208
+ }
209
+
210
+ for (const [id, tool] of tools) {
211
+ let input: unknown;
212
+ try {
213
+ input = JSON.parse(tool.input);
214
+ } catch {
215
+ input = { __raw: tool.input };
216
+ }
217
+ content.push({ type: 'tool_use', id, name: tool.name, input });
218
+ }
219
+
220
+ events.emit('provider.response', { usage, stopReason });
221
+
222
+ return { content, usage, stopReason };
223
+ }
224
+
225
+ function sleep(ms: number, signal: AbortSignal): Promise<void> {
226
+ return new Promise((resolve, reject) => {
227
+ const t = setTimeout(() => {
228
+ signal.removeEventListener('abort', onAbort);
229
+ resolve();
230
+ }, ms);
231
+ const onAbort = () => {
232
+ clearTimeout(t);
233
+ reject(new Error('aborted'));
234
+ };
235
+ if (signal.aborted) {
236
+ onAbort();
237
+ return;
238
+ }
239
+ signal.addEventListener('abort', onAbort, { once: true });
240
+ });
241
+ }