@albalink/agent 1.0.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 (67) hide show
  1. package/README.md +91 -0
  2. package/bin/albacli +31 -0
  3. package/dist/api/ExtensionAPI.d.ts +103 -0
  4. package/dist/api/ExtensionAPI.js +15 -0
  5. package/dist/api/Registry.d.ts +54 -0
  6. package/dist/api/Registry.js +103 -0
  7. package/dist/cli.d.ts +6 -0
  8. package/dist/cli.js +230 -0
  9. package/dist/index.d.ts +41 -0
  10. package/dist/index.js +41 -0
  11. package/dist/loader.d.ts +16 -0
  12. package/dist/loader.js +89 -0
  13. package/dist/mcp/entry.d.ts +2 -0
  14. package/dist/mcp/entry.js +71 -0
  15. package/dist/mcp/server.d.ts +31 -0
  16. package/dist/mcp/server.js +128 -0
  17. package/dist/models/CostTracker.d.ts +67 -0
  18. package/dist/models/CostTracker.js +151 -0
  19. package/dist/models/ModelRegistry.d.ts +159 -0
  20. package/dist/models/ModelRegistry.js +500 -0
  21. package/dist/models/index.d.ts +5 -0
  22. package/dist/models/index.js +3 -0
  23. package/dist/runner/AgentRunner.d.ts +88 -0
  24. package/dist/runner/AgentRunner.js +473 -0
  25. package/dist/runner/ModelClient.d.ts +97 -0
  26. package/dist/runner/ModelClient.js +350 -0
  27. package/dist/runner/SwarmRouter.d.ts +64 -0
  28. package/dist/runner/SwarmRouter.js +216 -0
  29. package/dist/runner/ToolDispatcher.d.ts +29 -0
  30. package/dist/runner/ToolDispatcher.js +168 -0
  31. package/dist/scheduler/AgentScheduler.d.ts +119 -0
  32. package/dist/scheduler/AgentScheduler.js +263 -0
  33. package/dist/server-entry.d.ts +11 -0
  34. package/dist/server-entry.js +15 -0
  35. package/dist/session/ContextStore.d.ts +96 -0
  36. package/dist/session/ContextStore.js +207 -0
  37. package/dist/session/GoalManager.d.ts +101 -0
  38. package/dist/session/GoalManager.js +167 -0
  39. package/dist/session/MemoryStore.d.ts +48 -0
  40. package/dist/session/MemoryStore.js +167 -0
  41. package/dist/session/SessionManager.d.ts +64 -0
  42. package/dist/session/SessionManager.js +193 -0
  43. package/dist/telemetry/Tracer.d.ts +48 -0
  44. package/dist/telemetry/Tracer.js +102 -0
  45. package/dist/tools/MarketSentiment.d.ts +166 -0
  46. package/dist/tools/MarketSentiment.js +209 -0
  47. package/dist/tools/NewsSentiment.d.ts +67 -0
  48. package/dist/tools/NewsSentiment.js +226 -0
  49. package/dist/tools/PriceFeed.d.ts +105 -0
  50. package/dist/tools/PriceFeed.js +282 -0
  51. package/dist/tools/TechnicalAnalysis.d.ts +110 -0
  52. package/dist/tools/TechnicalAnalysis.js +357 -0
  53. package/dist/tools/index.d.ts +7 -0
  54. package/dist/tools/index.js +4 -0
  55. package/dist/tui/App.d.ts +17 -0
  56. package/dist/tui/App.js +225 -0
  57. package/dist/tui/ModelSelector.d.ts +12 -0
  58. package/dist/tui/ModelSelector.js +87 -0
  59. package/dist/tui/REPL.d.ts +24 -0
  60. package/dist/tui/REPL.js +51 -0
  61. package/dist/tui/StatusBar.d.ts +14 -0
  62. package/dist/tui/StatusBar.js +14 -0
  63. package/dist/tui/theme.d.ts +30 -0
  64. package/dist/tui/theme.js +41 -0
  65. package/dist/util/safeLog.d.ts +3 -0
  66. package/dist/util/safeLog.js +21 -0
  67. package/package.json +67 -0
@@ -0,0 +1,88 @@
1
+ /**
2
+ * AgentRunner — the core agentic loop.
3
+ *
4
+ * 1. Fires before_agent_start hooks (prompt injection, live context)
5
+ * 2. Streams model response (with multi-model fallback rotation)
6
+ * 3. If model returns tool calls → dispatch → feed results back → continue
7
+ * 4. Emits events so the TUI can render incrementally
8
+ */
9
+ import type { Registry } from "../api/Registry.js";
10
+ import type { SessionManager } from "../session/SessionManager.js";
11
+ import type { SessionContext } from "../api/ExtensionAPI.js";
12
+ import type { ModelRegistry } from "../models/ModelRegistry.js";
13
+ import type { CostTracker } from "../models/CostTracker.js";
14
+ import type { GoalManager } from "../session/GoalManager.js";
15
+ import type { ContextStore } from "../session/ContextStore.js";
16
+ import { Tracer } from "../telemetry/Tracer.js";
17
+ export type RunnerEvent = {
18
+ type: "text_delta";
19
+ text: string;
20
+ } | {
21
+ type: "tool_start";
22
+ name: string;
23
+ args: string;
24
+ } | {
25
+ type: "tool_done";
26
+ name: string;
27
+ result: string;
28
+ isError: boolean;
29
+ } | {
30
+ type: "model_fallback";
31
+ from: string;
32
+ to: string;
33
+ reason: string;
34
+ } | {
35
+ type: "swarm_subtask";
36
+ task: string;
37
+ model: string;
38
+ ms: number;
39
+ remaining: number;
40
+ } | {
41
+ type: "swarm_review";
42
+ subCount: number;
43
+ } | {
44
+ type: "turn_done";
45
+ } | {
46
+ type: "error";
47
+ message: string;
48
+ }
49
+ /** #10: Approval gate — TUI pauses and waits for user y/n */
50
+ | {
51
+ type: "approval_request";
52
+ toolName: string;
53
+ args: string;
54
+ approve: (yes: boolean) => void;
55
+ };
56
+ export type RunnerEventHandler = (event: RunnerEvent) => void;
57
+ export declare class AgentRunner {
58
+ private registry;
59
+ private session;
60
+ private onEvent;
61
+ private sessionCtx;
62
+ private effectLevel;
63
+ private goalManager?;
64
+ private contextStore?;
65
+ private modelChain;
66
+ private dispatcher;
67
+ private swarmRouter;
68
+ private modelRegistry?;
69
+ private costTracker?;
70
+ private abortController;
71
+ /** #25: Cancel the current in-flight stream immediately */
72
+ abort(): void;
73
+ constructor(registry: Registry, session: SessionManager, onEvent: RunnerEventHandler, sessionCtx: SessionContext, effectLevel?: string, modelReg?: ModelRegistry, costTracker?: CostTracker, goalManager?: GoalManager | undefined, contextStore?: ContextStore | undefined);
74
+ /**
75
+ * Live reconfigure effect level without recreating the runner.
76
+ * Called by the /effect REPL command immediately on each invocation so that
77
+ * the next user turn uses the new swarm config.
78
+ */
79
+ setEffectLevel(level: string): void;
80
+ /** Run one user turn — may invoke multiple tool rounds and model fallbacks internally */
81
+ run(userMessage: string): Promise<void>;
82
+ private buildLiveContext;
83
+ private buildPersonalitySuffix;
84
+ private buildDynamicSystemSuffix;
85
+ private runSwarm;
86
+ runSingleAgent(userMessage?: string, tracer?: Tracer): Promise<void>;
87
+ }
88
+ //# sourceMappingURL=AgentRunner.d.ts.map
@@ -0,0 +1,473 @@
1
+ /**
2
+ * AgentRunner — the core agentic loop.
3
+ *
4
+ * 1. Fires before_agent_start hooks (prompt injection, live context)
5
+ * 2. Streams model response (with multi-model fallback rotation)
6
+ * 3. If model returns tool calls → dispatch → feed results back → continue
7
+ * 4. Emits events so the TUI can render incrementally
8
+ */
9
+ import { ModelClient, resolveModelChain, } from "./ModelClient.js";
10
+ import { ToolDispatcher, forecastContextGrowth } from "./ToolDispatcher.js";
11
+ import { SwarmRouter } from "./SwarmRouter.js";
12
+ import { readFileSync, existsSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { homedir } from "node:os";
15
+ import { priceFeed } from "../tools/PriceFeed.js";
16
+ import { newsFeed } from "../tools/NewsSentiment.js";
17
+ import { Tracer } from "../telemetry/Tracer.js";
18
+ const MAX_TOOL_ROUNDS = 12;
19
+ const REFLECT_AT_ROUND = 6;
20
+ function detectTaskType(message) {
21
+ const m = message.toLowerCase();
22
+ if (/\bhow much|price of|worth|cost of|current price\b/.test(m))
23
+ return "price_check";
24
+ if (/\brsi|macd|bollinger|technical|chart|candle|ohlcv\b/.test(m))
25
+ return "ta_analysis";
26
+ if (/\bcode|script|write|implement|function|typescript|python\b/.test(m))
27
+ return "code";
28
+ if (/\bpredict|forecast|will.*price|going to|expect.*price\b/.test(m))
29
+ return "prediction";
30
+ if (/\bnews|sentiment|latest|headlines|today.*market\b/.test(m))
31
+ return "news_summary";
32
+ if (/\bstrategy|plan|portfolio|risk|position|trade\b/.test(m))
33
+ return "strategy";
34
+ return "general";
35
+ }
36
+ // Task → tier mapping: cheap tasks go to workers, deep tasks go to orchestrators
37
+ const TASK_TIER_MAP = {
38
+ price_check: "worker", // fast cheap answer: $0.02-0.10/M
39
+ news_summary: "worker", // simple text summarization
40
+ code: "worker", // qwen3-coder, deepseek are great
41
+ ta_analysis: "analyst", // needs math accuracy
42
+ general: "analyst", // balanced
43
+ strategy: "orchestrator", // needs deep reasoning
44
+ prediction: "orchestrator", // thinking model for max effect
45
+ };
46
+ /** Effect level → swarm behaviour config */
47
+ const EFFECT_SWARM = {
48
+ eco: { threshold: 999, maxAgents: 0, useLLM: false }, // never swarm
49
+ normal: { threshold: 999, maxAgents: 0, useLLM: false }, // never swarm
50
+ turbo: { threshold: 40, maxAgents: 2, useLLM: true },
51
+ max: { threshold: 30, maxAgents: 5, useLLM: true },
52
+ };
53
+ export class AgentRunner {
54
+ registry;
55
+ session;
56
+ onEvent;
57
+ sessionCtx;
58
+ effectLevel;
59
+ goalManager;
60
+ contextStore;
61
+ modelChain;
62
+ dispatcher;
63
+ swarmRouter;
64
+ modelRegistry;
65
+ costTracker;
66
+ abortController = null;
67
+ /** #25: Cancel the current in-flight stream immediately */
68
+ abort() {
69
+ this.abortController?.abort();
70
+ this.abortController = null;
71
+ }
72
+ constructor(registry, session, onEvent, sessionCtx, effectLevel = "normal", modelReg, costTracker, goalManager, contextStore) {
73
+ this.registry = registry;
74
+ this.session = session;
75
+ this.onEvent = onEvent;
76
+ this.sessionCtx = sessionCtx;
77
+ this.effectLevel = effectLevel;
78
+ this.goalManager = goalManager;
79
+ this.contextStore = contextStore;
80
+ this.modelRegistry = modelReg;
81
+ this.costTracker = costTracker;
82
+ this.modelChain = resolveModelChain(modelReg);
83
+ this.dispatcher = new ToolDispatcher(registry);
84
+ const sc = EFFECT_SWARM[effectLevel] ?? EFFECT_SWARM["normal"];
85
+ this.swarmRouter = new SwarmRouter({
86
+ maxAgents: sc.maxAgents,
87
+ complexityThreshold: sc.threshold,
88
+ useLLM: sc.useLLM,
89
+ }, modelReg);
90
+ }
91
+ /**
92
+ * Live reconfigure effect level without recreating the runner.
93
+ * Called by the /effect REPL command immediately on each invocation so that
94
+ * the next user turn uses the new swarm config.
95
+ */
96
+ setEffectLevel(level) {
97
+ this.effectLevel = level;
98
+ this.modelChain = resolveModelChain(this.modelRegistry);
99
+ const sc = EFFECT_SWARM[level] ?? EFFECT_SWARM["normal"];
100
+ this.swarmRouter = new SwarmRouter({
101
+ maxAgents: sc.maxAgents,
102
+ complexityThreshold: sc.threshold,
103
+ useLLM: sc.useLLM,
104
+ }, this.modelRegistry);
105
+ }
106
+ /** Run one user turn — may invoke multiple tool rounds and model fallbacks internally */
107
+ async run(userMessage) {
108
+ // #30: Start trace for this turn
109
+ const sessionId = `alba-${Date.now().toString(36)}`;
110
+ const tracer = new Tracer(sessionId, userMessage);
111
+ // 1. Fire before_agent_start hooks
112
+ await this.registry.fireHook("before_agent_start", this.sessionCtx);
113
+ // 2. #38: Rebuild dynamic system prompt each turn
114
+ const basePrompt = this.registry.getSystemPrompt();
115
+ const dynamicSuffix = this.buildDynamicSystemSuffix();
116
+ this.session.setSystemPrompt(basePrompt + dynamicSuffix);
117
+ // 3. #40/#32: Pre-flight context pressure check — smart compact if needed
118
+ const pressure = this.session.getContextPressure();
119
+ if (pressure.pct >= 85 && pressure.pct < 95) {
120
+ // #32: Try tier-2 summarization with cheap model before hard-dropping turns
121
+ await this.session.summarizeOldTurns(async (messages) => {
122
+ const chain = resolveModelChain(this.modelRegistry);
123
+ // Use cheapest available model for summarization (worker or free tier)
124
+ const summaryCfg = chain[chain.length - 1] ?? chain[0];
125
+ const client = new ModelClient(summaryCfg, this.modelRegistry);
126
+ const preview = messages
127
+ .map(m => `${m.role}: ${typeof m.content === "string" ? m.content.slice(0, 150) : "[tool call]"}`)
128
+ .join("\n");
129
+ let out = "";
130
+ for await (const chunk of client.stream([
131
+ { role: "system", content: "Summarize the following conversation in 3-5 bullet points. Be specific about prices, decisions, and findings." },
132
+ { role: "user", content: preview },
133
+ ], [])) {
134
+ if (chunk.type === "delta" && chunk.text)
135
+ out += chunk.text;
136
+ }
137
+ return out || "(summary unavailable)";
138
+ });
139
+ }
140
+ else if (pressure.pct >= 95) {
141
+ this.session.forceCompact();
142
+ }
143
+ // 4. #33: Guard swarm against insufficient context headroom
144
+ if (this.swarmRouter.shouldSwarm(userMessage)) {
145
+ if (!this.session.getContextPressure().turboReady) {
146
+ this.onEvent({ type: "text_delta", text: "\u26a1 Compacting context for turbo mode...\n" });
147
+ this.session.forceCompact();
148
+ }
149
+ await this.runSwarm(userMessage);
150
+ return;
151
+ }
152
+ // 5. #16: Inject live market context into the user message
153
+ const enriched = await this.buildLiveContext(userMessage);
154
+ this.session.addMessage({ role: "user", content: enriched });
155
+ await this.runSingleAgent(userMessage, tracer);
156
+ tracer.flush("ok");
157
+ this.onEvent({ type: "turn_done" });
158
+ }
159
+ // ── #16: Live market context injection ─────────────────────────────────────
160
+ async buildLiveContext(message) {
161
+ const parts = [];
162
+ // Extract ticker symbols mentioned in the message
163
+ const tickerRe = /\b(BTC|ETH|SOL|BNB|MATIC|ARB|OP|AVAX|LINK|UNI|DOGE|XRP|ADA|DOT|ATOM|NEAR|SUI|APT|PEPE|AAVE|WIF|BONK)\b/gi;
164
+ const mentioned = [...new Set((message.match(tickerRe) ?? []).map(s => s.toLowerCase()))];
165
+ if (mentioned.length > 0) {
166
+ const ticks = priceFeed.getMultiple(mentioned);
167
+ if (ticks.length > 0) {
168
+ parts.push("Current prices: " + ticks.map(t => priceFeed.formatPrice(t)).join(" | "));
169
+ }
170
+ }
171
+ // News sentiment badge if message is analysis/sentiment related
172
+ if (/sentiment|news|market|mood|bullish|bearish|fear|greed/i.test(message)) {
173
+ const badge = newsFeed.statusBadge();
174
+ if (badge && badge !== "📰 ?")
175
+ parts.push(`News: ${badge}`);
176
+ }
177
+ if (parts.length === 0)
178
+ return message;
179
+ return `<live_context>\n${parts.join("\n")}\n</live_context>\n\n${message}`;
180
+ }
181
+ // ── #38: Dynamic system prompt suffix ──────────────────────────────────────
182
+ buildPersonalitySuffix() {
183
+ try {
184
+ const ALBA_HOME = process.env.ALBA_HOME ?? join(homedir(), ".alba");
185
+ // Read config for personality setting
186
+ const configPath = join(ALBA_HOME, "config.json");
187
+ let personality = "Professional";
188
+ if (existsSync(configPath)) {
189
+ const cfg = JSON.parse(readFileSync(configPath, "utf-8"));
190
+ if (cfg.personality)
191
+ personality = cfg.personality;
192
+ }
193
+ // Read Soul.md identity doc
194
+ const soulPath = join(__dirname, "..", "identity", "Soul.md");
195
+ if (!existsSync(soulPath))
196
+ return "";
197
+ const soul = readFileSync(soulPath, "utf-8");
198
+ // Extract the matching personality section from Soul.md
199
+ const headerMap = {
200
+ "Professional": "### Professional",
201
+ "Casual": "### Casual",
202
+ "Concise": "### Concise",
203
+ "Creative": "### Creative",
204
+ };
205
+ const header = headerMap[personality];
206
+ if (!header)
207
+ return "";
208
+ const startIdx = soul.indexOf(header);
209
+ if (startIdx < 0)
210
+ return "";
211
+ // Find next personality header or end of file
212
+ let endIdx = soul.length;
213
+ for (const h of Object.values(headerMap)) {
214
+ if (h === header)
215
+ continue;
216
+ const idx = soul.indexOf(h, startIdx + header.length);
217
+ if (idx > 0 && idx < endIdx)
218
+ endIdx = idx;
219
+ }
220
+ const block = soul.slice(startIdx, endIdx).trim();
221
+ return `\n\n## Personality\n${block}`;
222
+ }
223
+ catch {
224
+ return "";
225
+ }
226
+ }
227
+ buildDynamicSystemSuffix() {
228
+ const sections = [];
229
+ // Personality
230
+ const pers = this.buildPersonalitySuffix();
231
+ if (pers)
232
+ sections.push(pers);
233
+ // Active goals
234
+ const goals = this.goalManager?.getActive() ?? [];
235
+ if (goals.length > 0) {
236
+ sections.push(`\n## Active Goals\n${goals.map(g => `- [${g.id}] ${g.text}`).join("\n")}`);
237
+ }
238
+ // Active task context references
239
+ const activeTasks = this.contextStore?.getActiveTasks() ?? [];
240
+ if (activeTasks.length > 0) {
241
+ sections.push(`\n## Saved Task Context\n` +
242
+ activeTasks.map(t => this.contextStore.getReference(t.taskId)).join("\n"));
243
+ }
244
+ // Context pressure advisory
245
+ const pressure = this.session.getContextPressure();
246
+ if (pressure.level === "red" || pressure.level === "critical") {
247
+ sections.push(`\n## ⚠ Context Window at ${pressure.pct}%\n` +
248
+ `Be concise. Prefer short summaries. Use read_task_context() for historical data rather than repeating it. ` +
249
+ `${pressure.turboReady ? "" : "Swarm mode is temporarily paused to preserve headroom."}`);
250
+ }
251
+ // Effect level advisory
252
+ if (this.effectLevel === "eco") {
253
+ sections.push("\n## Mode: ECO\nBe brief. Minimize tool calls. Prefer one tool per response.");
254
+ }
255
+ return sections.join("");
256
+ }
257
+ // ── Swarm path ─────────────────────────────────────────────────────────────
258
+ async runSwarm(userMessage) {
259
+ const systemPrompt = this.registry.getSystemPrompt();
260
+ const { synthesis, subResults } = await this.swarmRouter.run(userMessage, systemPrompt, (result, remaining) => {
261
+ this.onEvent({
262
+ type: "swarm_subtask",
263
+ task: result.task,
264
+ model: result.model,
265
+ ms: result.ms,
266
+ remaining,
267
+ });
268
+ }, this.contextStore);
269
+ this.onEvent({ type: "swarm_review", subCount: subResults.length });
270
+ // Stream reviewer synthesis token-by-token (already complete — re-emit as deltas)
271
+ for (const ch of synthesis) {
272
+ this.onEvent({ type: "text_delta", text: ch });
273
+ }
274
+ // Save to session history as a single assistant message
275
+ this.session.addMessage({ role: "user", content: userMessage });
276
+ this.session.addMessage({ role: "assistant", content: synthesis });
277
+ this.onEvent({ type: "turn_done" });
278
+ }
279
+ // ── Single-agent path (also used for each sub-task in turbo/max) ────────────
280
+ async runSingleAgent(userMessage, tracer) {
281
+ const openAITools = this.registry.toOpenAITools();
282
+ const t0 = Date.now();
283
+ this.abortController = new AbortController();
284
+ const abortSignal = this.abortController.signal;
285
+ let rounds = 0;
286
+ // #37: Route to appropriate model tier based on task type
287
+ let taskModelChain = this.modelChain;
288
+ if (userMessage && this.modelRegistry) {
289
+ const taskType = detectTaskType(userMessage);
290
+ const targetTier = TASK_TIER_MAP[taskType];
291
+ // For max effect + prediction tasks, enable thinking mode
292
+ const useThinking = this.effectLevel === "max" && taskType === "prediction";
293
+ const taskModel = this.modelRegistry.pick(targetTier);
294
+ if (taskModel) {
295
+ const cfg = this.modelRegistry.buildConfig(taskModel.id, this.modelChain[0]?.maxTokens ?? 8192, this.modelChain[0]?.temperature ?? 0.7, targetTier);
296
+ if (cfg) {
297
+ if (useThinking) {
298
+ cfg.thinkingEnabled = true;
299
+ cfg.thinkingBudget = 8000;
300
+ }
301
+ taskModelChain = [cfg, ...this.modelChain.filter(m => m.model !== cfg.model)];
302
+ }
303
+ }
304
+ }
305
+ while (rounds < MAX_TOOL_ROUNDS) {
306
+ rounds++;
307
+ const messages = this.session.getMessages();
308
+ let assistantText = "";
309
+ let pendingToolCalls = [];
310
+ let modelError = null;
311
+ let usageTokens = null;
312
+ // Try model chain — rotate on 429/5xx
313
+ for (let mi = 0; mi < taskModelChain.length; mi++) {
314
+ const cfg = taskModelChain[mi];
315
+ const client = new ModelClient(cfg, this.modelRegistry);
316
+ assistantText = "";
317
+ pendingToolCalls = [];
318
+ modelError = null;
319
+ let gotError = false;
320
+ let isRateLimit = false;
321
+ for await (const chunk of client.stream(messages, openAITools, abortSignal)) {
322
+ if (chunk.type === "delta" && chunk.text) {
323
+ assistantText += chunk.text;
324
+ this.onEvent({ type: "text_delta", text: chunk.text });
325
+ }
326
+ else if (chunk.type === "tool_call" && chunk.tool_calls) {
327
+ pendingToolCalls = chunk.tool_calls;
328
+ }
329
+ else if (chunk.type === "done" && chunk.finish_reason === "aborted") {
330
+ // #25: Stream was aborted by user — clean exit
331
+ this.onEvent({ type: "turn_done" });
332
+ return;
333
+ }
334
+ else if (chunk.type === "done" && chunk.usage) {
335
+ usageTokens = chunk.usage;
336
+ }
337
+ else if (chunk.type === "error") {
338
+ modelError = chunk.error ?? "Unknown model error";
339
+ gotError = true;
340
+ this.costTracker?.recordError(); // #1: track errors
341
+ // Rotate on 429 rate-limit OR any 5xx server error
342
+ isRateLimit = /429|rate.?limit/i.test(modelError)
343
+ || (chunk.status !== undefined && chunk.status >= 500);
344
+ break;
345
+ }
346
+ }
347
+ if (!gotError)
348
+ break; // success — use this model's output
349
+ // Rotate to next model on rate-limit or server errors
350
+ const nextCfg = taskModelChain[mi + 1];
351
+ // Save any partial text the user already saw before rotating
352
+ if (assistantText.trim()) {
353
+ this.session.addMessage({ role: "assistant", content: assistantText + "\n\n[connection interrupted — retrying with fallback model]" });
354
+ }
355
+ if (nextCfg && isRateLimit) {
356
+ this.onEvent({
357
+ type: "model_fallback",
358
+ from: cfg.model,
359
+ to: nextCfg.model,
360
+ reason: modelError ?? "rate limit",
361
+ });
362
+ continue;
363
+ }
364
+ // Non-recoverable error or no more fallbacks — commit partial text if any
365
+ if (assistantText.trim()) {
366
+ this.session.addMessage({ role: "assistant", content: assistantText });
367
+ }
368
+ this.onEvent({ type: "error", message: modelError ?? "Model error" });
369
+ return;
370
+ }
371
+ if (modelError && assistantText === "" && pendingToolCalls.length === 0) {
372
+ this.onEvent({ type: "error", message: modelError });
373
+ return;
374
+ }
375
+ // #1: Record cost for this model call
376
+ if (this.costTracker && !modelError) {
377
+ const cfg = this.modelChain[0];
378
+ if (usageTokens) {
379
+ this.costTracker.record(cfg.model, usageTokens.prompt_tokens, usageTokens.completion_tokens, Date.now() - t0);
380
+ }
381
+ else {
382
+ // Fallback: estimate from char counts (~4 chars per token)
383
+ const allMsgs = this.session.getMessages();
384
+ const promptChars = allMsgs.reduce((n, m) => n + (typeof m.content === "string" ? m.content.length : 0), 0);
385
+ const promptTok = Math.ceil(promptChars / 4);
386
+ const completeTok = Math.ceil(assistantText.length / 4);
387
+ this.costTracker.record(cfg.model, promptTok, completeTok, Date.now() - t0);
388
+ }
389
+ usageTokens = null; // reset for next round
390
+ }
391
+ // Save assistant turn
392
+ const assistantMsg = {
393
+ role: "assistant",
394
+ content: assistantText || null,
395
+ ...(pendingToolCalls.length > 0 ? { tool_calls: pendingToolCalls } : {}),
396
+ };
397
+ this.session.addMessage(assistantMsg);
398
+ if (pendingToolCalls.length === 0)
399
+ break;
400
+ // #9: Reflection — at mid-point, force model to assess progress
401
+ if (rounds === REFLECT_AT_ROUND) {
402
+ this.session.addMessage({
403
+ role: "user",
404
+ content: `[AGENT REFLECTION — round ${rounds}/${MAX_TOOL_ROUNDS}] ` +
405
+ `You have used ${rounds} tool calls. ` +
406
+ `Summarize what you have found so far, then decide: ` +
407
+ `(a) you have enough to answer — do so now, or ` +
408
+ `(b) you need specific additional data — state exactly what and use ONE more tool. ` +
409
+ `Do not call tools unless you have a clear remaining gap.`,
410
+ });
411
+ }
412
+ // #10: Check for tools requiring approval before dispatching
413
+ const approvedCalls = [];
414
+ for (const tc of pendingToolCalls) {
415
+ const toolDef = this.registry.getTool(tc.function.name);
416
+ if (toolDef?.requiresApproval) {
417
+ const approved = await new Promise((resolve) => {
418
+ this.onEvent({
419
+ type: "approval_request",
420
+ toolName: tc.function.name,
421
+ args: tc.function.arguments,
422
+ approve: resolve,
423
+ });
424
+ // Auto-deny after 60 seconds if no response
425
+ setTimeout(() => resolve(false), 60_000);
426
+ });
427
+ if (!approved) {
428
+ // Inject a denial message so model knows it was blocked
429
+ this.session.addMessage({
430
+ role: "tool",
431
+ content: `Tool "${tc.function.name}" was denied by user. Do not retry without asking explicitly.`,
432
+ name: tc.function.name,
433
+ tool_call_id: tc.id,
434
+ });
435
+ this.onEvent({ type: "tool_done", name: tc.function.name, result: "[DENIED by user]", isError: true });
436
+ continue;
437
+ }
438
+ }
439
+ approvedCalls.push(tc);
440
+ }
441
+ for (const tc of approvedCalls) {
442
+ this.onEvent({ type: "tool_start", name: tc.function.name, args: tc.function.arguments });
443
+ tracer?.startSpan(`tool:${tc.function.name}`, { args: tc.function.arguments.slice(0, 100) });
444
+ }
445
+ if (approvedCalls.length === 0)
446
+ continue;
447
+ // #40: Pre-tool context budget forecast — compact proactively if needed
448
+ const forecastedGrowth = forecastContextGrowth(approvedCalls);
449
+ const currentChars = this.session.charCount();
450
+ const forecastedPct = (currentChars + forecastedGrowth) / 80_000 * 100;
451
+ if (forecastedPct > 90) {
452
+ // Pre-compact before tools add more content
453
+ this.session.forceCompact();
454
+ this.onEvent({ type: "text_delta", text: `\n⚡ Pre-compacting context (forecast: ${forecastedPct.toFixed(0)}% after tools)\n` });
455
+ }
456
+ const results = await this.dispatcher.dispatch(approvedCalls);
457
+ for (const r of results) {
458
+ this.onEvent({ type: "tool_done", name: r.name, result: r.content, isError: r.isError });
459
+ // #30: End tool span
460
+ const toolSpan = tracer?.trace?.spans?.slice().reverse().find((s) => s.name === `tool:${r.name}` && !s.durationMs);
461
+ if (toolSpan)
462
+ tracer?.endSpan(toolSpan.spanId, r.isError ? "error" : "ok", { resultLen: r.content.length });
463
+ this.session.addMessage({
464
+ role: "tool",
465
+ content: r.content,
466
+ name: r.name,
467
+ tool_call_id: r.tool_call_id,
468
+ });
469
+ }
470
+ }
471
+ }
472
+ }
473
+ //# sourceMappingURL=AgentRunner.js.map
@@ -0,0 +1,97 @@
1
+ /**
2
+ * ModelClient — sends messages to any OpenAI-compatible model API.
3
+ *
4
+ * Provider priority (auto-detected from env):
5
+ * OpenRouter > Anthropic compat > OpenAI > local (ollama/lm-studio)
6
+ *
7
+ * Model rotation: resolveModelChain() returns up to 5 configs — the AgentRunner
8
+ * walks the chain on 429 (rate limit) or 5xx errors, with exponential backoff
9
+ * (up to 2 retries per model) before falling through.
10
+ *
11
+ * When a ModelRegistry is available, chains are dynamically built from the
12
+ * tiered pool, with per-model performance tracking and cost estimation.
13
+ *
14
+ * All outbound, all local — no inbound ports, no server.
15
+ */
16
+ import type { ModelRegistry } from "../models/ModelRegistry.js";
17
+ export interface Message {
18
+ role: "system" | "user" | "assistant" | "tool";
19
+ content: string | null;
20
+ name?: string;
21
+ tool_calls?: ToolCall[];
22
+ tool_call_id?: string;
23
+ }
24
+ export interface ToolCall {
25
+ id: string;
26
+ type: "function";
27
+ function: {
28
+ name: string;
29
+ arguments: string;
30
+ };
31
+ }
32
+ export interface ChatChunk {
33
+ type: "delta" | "tool_call" | "done" | "error";
34
+ text?: string;
35
+ tool_calls?: ToolCall[];
36
+ error?: string;
37
+ finish_reason?: string;
38
+ /** HTTP status code — used by AgentRunner for rate-limit detection */
39
+ status?: number;
40
+ /** Token usage reported by provider on final chunk */
41
+ usage?: {
42
+ prompt_tokens: number;
43
+ completion_tokens: number;
44
+ };
45
+ }
46
+ export interface ModelConfig {
47
+ baseUrl: string;
48
+ apiKey: string;
49
+ model: string;
50
+ maxTokens: number;
51
+ temperature: number;
52
+ siteUrl?: string;
53
+ siteName?: string;
54
+ /** Enable extended thinking for Claude Opus 4.x / o3 / Qwen3 thinking models (#13) */
55
+ thinkingEnabled?: boolean;
56
+ /** Thinking token budget — only applies when thinkingEnabled=true (#13) */
57
+ thinkingBudget?: number;
58
+ }
59
+ /**
60
+ * Build the ordered model fallback chain.
61
+ *
62
+ * If a ModelRegistry is provided, builds from the tiered pool dynamically.
63
+ * Falls back to static env-var parsing otherwise.
64
+ *
65
+ * User-configurable pool: ALBA_MODEL_1 … ALBA_MODEL_5
66
+ * If any ALBA_MODEL_N vars are set they take priority; up to 5 are used in
67
+ * order. Unset slots are filled with provider-appropriate defaults.
68
+ *
69
+ * ALBA_MODEL_N format: just the model ID string, e.g.
70
+ * ALBA_MODEL_1=anthropic/claude-opus-4-5
71
+ * ALBA_MODEL_2=openai/gpt-4o
72
+ * ALBA_MODEL_3=google/gemini-2.5-pro
73
+ */
74
+ export declare function resolveModelChain(modelReg?: ModelRegistry): ModelConfig[];
75
+ /** Convenience: returns just the primary (first) model config */
76
+ export declare function resolveModelConfig(modelReg?: ModelRegistry): ModelConfig;
77
+ export declare class ModelClient {
78
+ private cfg;
79
+ private modelRegistry?;
80
+ constructor(cfg: ModelConfig, modelReg?: ModelRegistry);
81
+ /**
82
+ * Stream a chat completion. Yields ChatChunk objects.
83
+ * Retries up to 2 times on 429 / 5xx with exponential backoff (1s, 2s).
84
+ * On persistent HTTP error the generator yields a single { type: "error", status, error }
85
+ * chunk and returns — the caller (AgentRunner) decides whether to rotate.
86
+ * Also reports success/failure to the ModelRegistry for tiering and cooldown.
87
+ */
88
+ stream(messages: Message[], tools?: Array<{
89
+ type: "function";
90
+ function: {
91
+ name: string;
92
+ description: string;
93
+ parameters: unknown;
94
+ };
95
+ }>, abortSignal?: AbortSignal): AsyncGenerator<ChatChunk>;
96
+ }
97
+ //# sourceMappingURL=ModelClient.d.ts.map