@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.
- package/README.md +91 -0
- package/bin/albacli +31 -0
- package/dist/api/ExtensionAPI.d.ts +103 -0
- package/dist/api/ExtensionAPI.js +15 -0
- package/dist/api/Registry.d.ts +54 -0
- package/dist/api/Registry.js +103 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +230 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +41 -0
- package/dist/loader.d.ts +16 -0
- package/dist/loader.js +89 -0
- package/dist/mcp/entry.d.ts +2 -0
- package/dist/mcp/entry.js +71 -0
- package/dist/mcp/server.d.ts +31 -0
- package/dist/mcp/server.js +128 -0
- package/dist/models/CostTracker.d.ts +67 -0
- package/dist/models/CostTracker.js +151 -0
- package/dist/models/ModelRegistry.d.ts +159 -0
- package/dist/models/ModelRegistry.js +500 -0
- package/dist/models/index.d.ts +5 -0
- package/dist/models/index.js +3 -0
- package/dist/runner/AgentRunner.d.ts +88 -0
- package/dist/runner/AgentRunner.js +473 -0
- package/dist/runner/ModelClient.d.ts +97 -0
- package/dist/runner/ModelClient.js +350 -0
- package/dist/runner/SwarmRouter.d.ts +64 -0
- package/dist/runner/SwarmRouter.js +216 -0
- package/dist/runner/ToolDispatcher.d.ts +29 -0
- package/dist/runner/ToolDispatcher.js +168 -0
- package/dist/scheduler/AgentScheduler.d.ts +119 -0
- package/dist/scheduler/AgentScheduler.js +263 -0
- package/dist/server-entry.d.ts +11 -0
- package/dist/server-entry.js +15 -0
- package/dist/session/ContextStore.d.ts +96 -0
- package/dist/session/ContextStore.js +207 -0
- package/dist/session/GoalManager.d.ts +101 -0
- package/dist/session/GoalManager.js +167 -0
- package/dist/session/MemoryStore.d.ts +48 -0
- package/dist/session/MemoryStore.js +167 -0
- package/dist/session/SessionManager.d.ts +64 -0
- package/dist/session/SessionManager.js +193 -0
- package/dist/telemetry/Tracer.d.ts +48 -0
- package/dist/telemetry/Tracer.js +102 -0
- package/dist/tools/MarketSentiment.d.ts +166 -0
- package/dist/tools/MarketSentiment.js +209 -0
- package/dist/tools/NewsSentiment.d.ts +67 -0
- package/dist/tools/NewsSentiment.js +226 -0
- package/dist/tools/PriceFeed.d.ts +105 -0
- package/dist/tools/PriceFeed.js +282 -0
- package/dist/tools/TechnicalAnalysis.d.ts +110 -0
- package/dist/tools/TechnicalAnalysis.js +357 -0
- package/dist/tools/index.d.ts +7 -0
- package/dist/tools/index.js +4 -0
- package/dist/tui/App.d.ts +17 -0
- package/dist/tui/App.js +225 -0
- package/dist/tui/ModelSelector.d.ts +12 -0
- package/dist/tui/ModelSelector.js +87 -0
- package/dist/tui/REPL.d.ts +24 -0
- package/dist/tui/REPL.js +51 -0
- package/dist/tui/StatusBar.d.ts +14 -0
- package/dist/tui/StatusBar.js +14 -0
- package/dist/tui/theme.d.ts +30 -0
- package/dist/tui/theme.js +41 -0
- package/dist/util/safeLog.d.ts +3 -0
- package/dist/util/safeLog.js +21 -0
- 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
|