@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.
- package/LICENSE +21 -0
- package/README.md +9 -0
- package/dist/index-LbxYtxxS.d.ts +560 -0
- package/dist/index.d.ts +995 -0
- package/dist/index.js +3840 -0
- package/dist/index.js.map +1 -0
- package/dist/kernel/index.d.ts +1 -0
- package/dist/kernel/index.js +551 -0
- package/dist/kernel/index.js.map +1 -0
- package/package.json +39 -0
- package/src/agent/agent-loop.ts +254 -0
- package/src/agent/context.ts +99 -0
- package/src/agent/conversation-state.ts +44 -0
- package/src/agent/provider-runner.ts +241 -0
- package/src/agent/system-prompt-builder.ts +193 -0
- package/src/execution/compactor.ts +256 -0
- package/src/execution/index.ts +7 -0
- package/src/execution/output-serializer.ts +90 -0
- package/src/execution/schema-validator.ts +124 -0
- package/src/execution/tool-executor.ts +276 -0
- package/src/execution/tool-registry.ts +104 -0
- package/src/index.ts +215 -0
- package/src/infrastructure/catalog-parser.ts +218 -0
- package/src/infrastructure/index.ts +16 -0
- package/src/infrastructure/path-resolver.ts +123 -0
- package/src/infrastructure/provider-factory.ts +116 -0
- package/src/infrastructure/provider-presets.ts +19 -0
- package/src/infrastructure/retry-policy.ts +50 -0
- package/src/infrastructure/secret-scrubber.ts +67 -0
- package/src/infrastructure/token-counter.ts +156 -0
- package/src/infrastructure/tracer.ts +23 -0
- package/src/kernel/container.ts +166 -0
- package/src/kernel/events.ts +323 -0
- package/src/kernel/index.ts +18 -0
- package/src/kernel/pipeline.ts +152 -0
- package/src/kernel/run-controller.ts +85 -0
- package/src/kernel/tokens.ts +21 -0
- package/src/security/index.ts +13 -0
- package/src/security/permission-policy.ts +273 -0
- package/src/session/audit-log.ts +201 -0
- package/src/session/auth-service.ts +178 -0
- package/src/session/index.ts +26 -0
- package/src/session/secret-vault.ts +183 -0
- package/src/session/session-store.ts +339 -0
- package/src/session/types.ts +100 -0
- package/src/types/blocks.ts +56 -0
- package/src/types/context.ts +54 -0
- package/src/types/errors.ts +359 -0
- package/src/types/index.ts +34 -0
- package/src/types/provider.ts +58 -0
- package/src/types/tool.ts +39 -0
- package/src/utils/error.ts +3 -0
- package/src/utils/fs.ts +185 -0
- package/src/utils/image-resize.ts +76 -0
- package/src/utils/ssrf-guard.ts +133 -0
- package/src/utils/ulid.ts +72 -0
- package/src/utils/version-check.ts +59 -0
- package/tests/agent-loop.test.ts +490 -0
- package/tests/audit-log.test.ts +199 -0
- package/tests/auth-service.test.ts +170 -0
- package/tests/blocks.test.ts +79 -0
- package/tests/catalog-parser.test.ts +174 -0
- package/tests/compactor.test.ts +180 -0
- package/tests/container.test.ts +224 -0
- package/tests/conversation-state.test.ts +75 -0
- package/tests/errors.test.ts +429 -0
- package/tests/events-v021.test.ts +60 -0
- package/tests/events-v022.test.ts +75 -0
- package/tests/events.test.ts +340 -0
- package/tests/fixtures/large-image.png +0 -0
- package/tests/fixtures/small-image.png +0 -0
- package/tests/fs-utils.test.ts +164 -0
- package/tests/image-resize.test.ts +51 -0
- package/tests/output-serializer.test.ts +79 -0
- package/tests/path-resolver.test.ts +91 -0
- package/tests/permission-policy.test.ts +174 -0
- package/tests/pipeline.test.ts +193 -0
- package/tests/provider-factory.test.ts +245 -0
- package/tests/provider-runner.test.ts +535 -0
- package/tests/retry-policy.test.ts +104 -0
- package/tests/run-controller.test.ts +115 -0
- package/tests/sanity.test.ts +26 -0
- package/tests/schema-validator.test.ts +109 -0
- package/tests/secret-scrubber.test.ts +133 -0
- package/tests/secret-vault.test.ts +130 -0
- package/tests/session-store.test.ts +429 -0
- package/tests/ssrf-guard.test.ts +112 -0
- package/tests/system-prompt-builder.test.ts +116 -0
- package/tests/token-counter.test.ts +163 -0
- package/tests/tokens.test.ts +42 -0
- package/tests/tool-executor.test.ts +452 -0
- package/tests/tool-registry.test.ts +143 -0
- package/tests/tracer.test.ts +32 -0
- package/tests/ulid.test.ts +53 -0
- package/tests/version-check.test.ts +57 -0
- package/tsconfig.json +11 -0
- 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
|
+
}
|