@mcoda/mswarm 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,842 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { spawn } from "node:child_process";
5
+ const DEFAULT_GATEWAY_BASE_URL = "http://127.0.0.1:8080";
6
+ const DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434";
7
+ const DEFAULT_LISTEN_HOST = "127.0.0.1";
8
+ const DEFAULT_LISTEN_PORT = 18083;
9
+ const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 30;
10
+ const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
11
+ const DEFAULT_MCODA_BIN = "mcoda";
12
+ const DEFAULT_MCODA_LIST_ARGS = ["agent", "list", "--json", "--refresh-health"];
13
+ const DEFAULT_COMMAND_MAX_BUFFER = 16 * 1024 * 1024;
14
+ function optionalText(value) {
15
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
16
+ }
17
+ function parsePositiveInteger(value, fallback) {
18
+ const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
19
+ if (!Number.isFinite(parsed) || parsed <= 0) {
20
+ return fallback;
21
+ }
22
+ return Math.floor(parsed);
23
+ }
24
+ function parseBoolean(value, fallback) {
25
+ if (typeof value === "boolean")
26
+ return value;
27
+ if (typeof value !== "string")
28
+ return fallback;
29
+ const normalized = value.trim().toLowerCase();
30
+ if (normalized === "1" || normalized === "true" || normalized === "yes")
31
+ return true;
32
+ if (normalized === "0" || normalized === "false" || normalized === "no")
33
+ return false;
34
+ return fallback;
35
+ }
36
+ function parseList(value) {
37
+ if (Array.isArray(value)) {
38
+ return value.map((entry) => optionalText(entry)).filter((entry) => Boolean(entry));
39
+ }
40
+ if (typeof value !== "string") {
41
+ return [];
42
+ }
43
+ return value
44
+ .split(",")
45
+ .map((entry) => entry.trim())
46
+ .filter(Boolean);
47
+ }
48
+ function parseArgs(value, fallback) {
49
+ const parsed = parseList(value);
50
+ return parsed.length > 0 ? parsed : fallback;
51
+ }
52
+ function parseDiscoveryMode(value) {
53
+ const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
54
+ return normalized === "ollama" ? "ollama" : "mcoda";
55
+ }
56
+ function trimTrailingSlash(value) {
57
+ return value.replace(/\/+$/g, "");
58
+ }
59
+ function defaultStatePath() {
60
+ return join(homedir(), ".mswarm", "self-hosted-node", "config.json");
61
+ }
62
+ function defaultRuntimeTokenPath() {
63
+ return join(homedir(), ".mswarm", "self-hosted-node", "node.key");
64
+ }
65
+ function isVisionModel(modelName, family) {
66
+ const normalized = `${modelName} ${family || ""}`.toLowerCase();
67
+ return normalized.includes("llava") || normalized.includes("vision") || normalized.includes("bakllava");
68
+ }
69
+ function isEmbeddingModel(modelName, family) {
70
+ const normalized = `${modelName} ${family || ""}`.toLowerCase();
71
+ return (normalized.includes("embed") ||
72
+ normalized.includes("embedding") ||
73
+ normalized.includes("nomic-bert") ||
74
+ normalized.includes("bert"));
75
+ }
76
+ function isEmbeddingUsage(value) {
77
+ if (!value) {
78
+ return false;
79
+ }
80
+ const normalized = value.toLowerCase();
81
+ return normalized.includes("embedding") || normalized.includes("embed") || normalized.includes("vector");
82
+ }
83
+ function optionalNumber(...values) {
84
+ for (const value of values) {
85
+ if (typeof value === "number" && Number.isFinite(value)) {
86
+ return value;
87
+ }
88
+ }
89
+ return null;
90
+ }
91
+ function optionalBoolean(...values) {
92
+ for (const value of values) {
93
+ if (typeof value === "boolean") {
94
+ return value;
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+ function normalizeCapabilities(value) {
100
+ if (!Array.isArray(value)) {
101
+ return [];
102
+ }
103
+ return Array.from(new Set(value
104
+ .map((entry) => optionalText(entry))
105
+ .filter((entry) => Boolean(entry))));
106
+ }
107
+ function normalizeHealthStatus(value) {
108
+ const normalized = optionalText(value)?.toLowerCase();
109
+ if (normalized === "healthy")
110
+ return "healthy";
111
+ if (normalized === "degraded")
112
+ return "degraded";
113
+ if (normalized === "unreachable" || normalized === "unhealthy" || normalized === "offline") {
114
+ return "unreachable";
115
+ }
116
+ if (normalized === "blocked")
117
+ return "blocked";
118
+ return "unknown";
119
+ }
120
+ function isMswarmManagedCloudAgent(agent) {
121
+ const config = agent.config && typeof agent.config === "object" ? agent.config : {};
122
+ const mswarmCloud = config.mswarmCloud;
123
+ return Boolean(mswarmCloud &&
124
+ typeof mswarmCloud === "object" &&
125
+ mswarmCloud.managed === true);
126
+ }
127
+ function isModelExposed(modelName, family, config) {
128
+ if (config.modelBlocklist.includes(modelName)) {
129
+ return false;
130
+ }
131
+ if (isEmbeddingModel(modelName, family)) {
132
+ return false;
133
+ }
134
+ if (config.modelAllowlist.length > 0) {
135
+ return config.modelAllowlist.includes(modelName);
136
+ }
137
+ return config.exposeAllModels;
138
+ }
139
+ function isAgentExposed(agentSlug, defaultModel, bestUsage, config) {
140
+ const identities = [agentSlug, defaultModel].filter((value) => Boolean(value));
141
+ if (identities.some((identity) => config.modelBlocklist.includes(identity))) {
142
+ return false;
143
+ }
144
+ if (isEmbeddingUsage(bestUsage) || identities.some((identity) => isEmbeddingModel(identity))) {
145
+ return false;
146
+ }
147
+ if (config.modelAllowlist.length > 0) {
148
+ return identities.some((identity) => config.modelAllowlist.includes(identity));
149
+ }
150
+ return config.exposeAllModels;
151
+ }
152
+ async function fetchJson(fetchImpl, url, init, timeoutMs) {
153
+ const controller = new AbortController();
154
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
155
+ try {
156
+ const response = await fetchImpl(url, { ...init, signal: controller.signal });
157
+ if (!response.ok) {
158
+ const text = await response.text().catch(() => "");
159
+ throw new Error(`request_failed:${response.status}:${text.slice(0, 200)}`);
160
+ }
161
+ return (await response.json());
162
+ }
163
+ finally {
164
+ clearTimeout(timeout);
165
+ }
166
+ }
167
+ export async function readSelfHostedNodeState(statePath) {
168
+ try {
169
+ const content = await readFile(statePath, "utf8");
170
+ const parsed = JSON.parse(content);
171
+ return parsed && typeof parsed === "object" ? parsed : {};
172
+ }
173
+ catch (error) {
174
+ if (error.code === "ENOENT") {
175
+ return {};
176
+ }
177
+ throw error;
178
+ }
179
+ }
180
+ export async function writeSelfHostedNodeState(statePath, state) {
181
+ await mkdir(dirname(statePath), { recursive: true });
182
+ await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
183
+ }
184
+ export async function readSelfHostedRuntimeToken(tokenPath) {
185
+ try {
186
+ const content = await readFile(tokenPath, "utf8");
187
+ const trimmed = content.trim();
188
+ return trimmed || null;
189
+ }
190
+ catch (error) {
191
+ if (error.code === "ENOENT") {
192
+ return null;
193
+ }
194
+ throw error;
195
+ }
196
+ }
197
+ export async function writeSelfHostedRuntimeToken(tokenPath, runtimeToken) {
198
+ await mkdir(dirname(tokenPath), { recursive: true });
199
+ await writeFile(tokenPath, `${runtimeToken.trim()}\n`, { encoding: "utf8", mode: 0o600 });
200
+ }
201
+ export async function readSelfHostedNodeConfig(env = process.env) {
202
+ const statePath = optionalText(env.MSWARM_SELF_HOSTED_NODE_STATE_PATH) || defaultStatePath();
203
+ const runtimeTokenPath = optionalText(env.MSWARM_SELF_HOSTED_NODE_KEY_PATH) || defaultRuntimeTokenPath();
204
+ const state = await readSelfHostedNodeState(statePath);
205
+ const persistedRuntimeToken = await readSelfHostedRuntimeToken(runtimeTokenPath);
206
+ const nodeId = optionalText(env.MSWARM_SELF_HOSTED_NODE_ID) || state.node_id || "";
207
+ if (!nodeId) {
208
+ throw new Error("MSWARM_SELF_HOSTED_NODE_ID is required");
209
+ }
210
+ const gatewayBaseUrl = optionalText(env.MSWARM_GATEWAY_BASE_URL) || state.gateway_base_url || DEFAULT_GATEWAY_BASE_URL;
211
+ const ollamaBaseUrl = optionalText(env.MSWARM_SELF_HOSTED_OLLAMA_BASE_URL) ||
212
+ state.ollama_base_url ||
213
+ optionalText(env.OLLAMA_HOST) ||
214
+ DEFAULT_OLLAMA_BASE_URL;
215
+ return {
216
+ gatewayBaseUrl: trimTrailingSlash(gatewayBaseUrl),
217
+ nodeId,
218
+ enrollmentToken: optionalText(env.MSWARM_SELF_HOSTED_ENROLLMENT_TOKEN),
219
+ runtimeToken: optionalText(env.MSWARM_SELF_HOSTED_RUNTIME_TOKEN) || persistedRuntimeToken || state.runtime_token || null,
220
+ discoveryMode: parseDiscoveryMode(env.MSWARM_SELF_HOSTED_DISCOVERY_MODE),
221
+ mcodaBin: optionalText(env.MSWARM_SELF_HOSTED_MCODA_BIN) || DEFAULT_MCODA_BIN,
222
+ mcodaListArgs: parseArgs(env.MSWARM_SELF_HOSTED_MCODA_LIST_ARGS, DEFAULT_MCODA_LIST_ARGS),
223
+ ollamaBaseUrl: trimTrailingSlash(ollamaBaseUrl),
224
+ statePath,
225
+ runtimeTokenPath,
226
+ invocationSigningSecret: optionalText(env.MSWARM_SELF_HOSTED_INVOCATION_SIGNING_SECRET) ||
227
+ optionalText(env.MSWARM_SELF_HOSTED_RELAY_SIGNING_SECRET),
228
+ listenHost: optionalText(env.MSWARM_SELF_HOSTED_LISTEN_HOST) || DEFAULT_LISTEN_HOST,
229
+ listenPort: parsePositiveInteger(env.MSWARM_SELF_HOSTED_LISTEN_PORT, DEFAULT_LISTEN_PORT),
230
+ nodeVersion: optionalText(env.MSWARM_SELF_HOSTED_NODE_VERSION) || "0.1.0",
231
+ heartbeatIntervalSeconds: parsePositiveInteger(env.MSWARM_SELF_HOSTED_HEARTBEAT_INTERVAL_SECONDS, state.heartbeat_interval_seconds || DEFAULT_HEARTBEAT_INTERVAL_SECONDS),
232
+ requestTimeoutMs: parsePositiveInteger(env.MSWARM_SELF_HOSTED_REQUEST_TIMEOUT_MS, DEFAULT_REQUEST_TIMEOUT_MS),
233
+ exposeAllModels: parseBoolean(env.MSWARM_SELF_HOSTED_EXPOSE_ALL_MODELS, false),
234
+ modelAllowlist: parseList(env.MSWARM_SELF_HOSTED_MODEL_ALLOWLIST),
235
+ modelBlocklist: parseList(env.MSWARM_SELF_HOSTED_MODEL_BLOCKLIST)
236
+ };
237
+ }
238
+ export function mapOllamaModelToSelfHostedModel(model, config) {
239
+ const name = optionalText(model.name);
240
+ if (!name) {
241
+ return null;
242
+ }
243
+ const family = optionalText(model.details?.family);
244
+ const parameterSize = optionalText(model.details?.parameter_size);
245
+ const quantizationLevel = optionalText(model.details?.quantization_level);
246
+ const embeddingOnly = isEmbeddingModel(name, family);
247
+ return {
248
+ name,
249
+ provider: "ollama",
250
+ adapter: "ollama",
251
+ model_id: name,
252
+ digest: optionalText(model.digest),
253
+ family,
254
+ parameter_size: parameterSize,
255
+ quantization_level: quantizationLevel,
256
+ supports_tools: false,
257
+ supports_vision: isVisionModel(name, family),
258
+ openai_compatible: false,
259
+ exposed: isModelExposed(name, family, config),
260
+ best_usage: embeddingOnly
261
+ ? "embedding"
262
+ : family === "codellama" || name.toLowerCase().includes("code")
263
+ ? "code_writer"
264
+ : "general_chat",
265
+ rating: 5,
266
+ reasoning_rating: 4,
267
+ max_complexity: 3,
268
+ health_status: "healthy",
269
+ metadata_quality: model.details ? "discovered" : "unknown"
270
+ };
271
+ }
272
+ export function mapMcodaAgentToSelfHostedModel(agent, config) {
273
+ if (isMswarmManagedCloudAgent(agent)) {
274
+ return null;
275
+ }
276
+ const slug = optionalText(agent.slug);
277
+ if (!slug) {
278
+ return null;
279
+ }
280
+ const adapter = optionalText(agent.adapter) || "unknown";
281
+ const defaultModel = optionalText(agent.defaultModel) ||
282
+ optionalText(agent.default_model) ||
283
+ optionalText(agent.models?.find((model) => model.isDefault === true || model.is_default === true)?.modelName) ||
284
+ optionalText(agent.models?.find((model) => model.isDefault === true || model.is_default === true)?.model_name) ||
285
+ null;
286
+ const bestUsage = optionalText(agent.bestUsage) || optionalText(agent.best_usage) || "general_chat";
287
+ const healthStatus = normalizeHealthStatus(agent.health?.status);
288
+ const capabilities = normalizeCapabilities(agent.capabilities);
289
+ return {
290
+ name: slug,
291
+ provider: "mcoda",
292
+ adapter,
293
+ source_agent_id: optionalText(agent.id),
294
+ source_agent_slug: slug,
295
+ model_id: defaultModel || slug,
296
+ display_name: slug,
297
+ context_window: optionalNumber(agent.contextWindow, agent.context_window),
298
+ max_output_tokens: optionalNumber(agent.maxOutputTokens, agent.max_output_tokens),
299
+ supports_tools: optionalBoolean(agent.supportsTools, agent.supports_tools) === true,
300
+ supports_vision: capabilities.some((capability) => capability.toLowerCase().includes("vision")) ||
301
+ capabilities.some((capability) => capability.toLowerCase().includes("visual")),
302
+ openai_compatible: optionalBoolean(agent.openaiCompatible, agent.openai_compatible) === true,
303
+ exposed: healthStatus !== "blocked" &&
304
+ isAgentExposed(slug, defaultModel, bestUsage, config),
305
+ best_usage: bestUsage,
306
+ capabilities,
307
+ cost_per_million: optionalNumber(agent.costPerMillion, agent.cost_per_million),
308
+ rating: optionalNumber(agent.rating),
309
+ reasoning_rating: optionalNumber(agent.reasoningRating, agent.reasoning_rating),
310
+ max_complexity: optionalNumber(agent.maxComplexity, agent.max_complexity),
311
+ health_status: healthStatus,
312
+ metadata_quality: "discovered"
313
+ };
314
+ }
315
+ function parseMcodaAgentListOutput(stdout) {
316
+ const parsed = JSON.parse(stdout);
317
+ if (Array.isArray(parsed)) {
318
+ return parsed;
319
+ }
320
+ if (parsed && typeof parsed === "object") {
321
+ const record = parsed;
322
+ if (Array.isArray(record.agents)) {
323
+ return record.agents;
324
+ }
325
+ }
326
+ throw new Error("mcoda agent list returned unsupported JSON");
327
+ }
328
+ async function defaultCommandRunner(command, args, options) {
329
+ return new Promise((resolve, reject) => {
330
+ const child = spawn(command, args, {
331
+ stdio: ["pipe", "pipe", "pipe"]
332
+ });
333
+ let stdout = "";
334
+ let stderr = "";
335
+ let settled = false;
336
+ const timer = setTimeout(() => {
337
+ if (settled)
338
+ return;
339
+ settled = true;
340
+ child.kill("SIGTERM");
341
+ reject(new Error(`command timed out after ${options.timeoutMs}ms: ${command}`));
342
+ }, options.timeoutMs);
343
+ const finish = (error) => {
344
+ if (settled)
345
+ return;
346
+ settled = true;
347
+ clearTimeout(timer);
348
+ if (error) {
349
+ reject(error);
350
+ return;
351
+ }
352
+ resolve({ stdout, stderr });
353
+ };
354
+ child.stdout.on("data", (chunk) => {
355
+ stdout += String(chunk);
356
+ if (stdout.length > options.maxBuffer) {
357
+ child.kill("SIGTERM");
358
+ finish(new Error(`command stdout exceeded ${options.maxBuffer} bytes: ${command}`));
359
+ }
360
+ });
361
+ child.stderr.on("data", (chunk) => {
362
+ stderr += String(chunk);
363
+ if (stderr.length > options.maxBuffer) {
364
+ child.kill("SIGTERM");
365
+ finish(new Error(`command stderr exceeded ${options.maxBuffer} bytes: ${command}`));
366
+ }
367
+ });
368
+ child.on("error", finish);
369
+ child.on("close", (code) => {
370
+ if (code && code !== 0) {
371
+ finish(new Error(`command failed (${code}): ${command} ${args.join(" ")} ${stderr}`.trim()));
372
+ return;
373
+ }
374
+ finish();
375
+ });
376
+ if (options.input) {
377
+ child.stdin.write(options.input);
378
+ }
379
+ child.stdin.end();
380
+ });
381
+ }
382
+ export class McodaAgentInventoryClient {
383
+ command;
384
+ args;
385
+ timeoutMs;
386
+ runner;
387
+ constructor(input) {
388
+ this.command = input.command || DEFAULT_MCODA_BIN;
389
+ this.args = input.args?.length ? input.args : DEFAULT_MCODA_LIST_ARGS;
390
+ this.timeoutMs = input.timeoutMs || DEFAULT_REQUEST_TIMEOUT_MS;
391
+ this.runner = input.runner || defaultCommandRunner;
392
+ }
393
+ async listAgents(config) {
394
+ let stdout;
395
+ try {
396
+ stdout = (await this.runner(this.command, this.args, {
397
+ timeoutMs: this.timeoutMs,
398
+ maxBuffer: DEFAULT_COMMAND_MAX_BUFFER
399
+ })).stdout;
400
+ }
401
+ catch (error) {
402
+ if (!this.args.includes("--refresh-health")) {
403
+ throw error;
404
+ }
405
+ stdout = (await this.runner(this.command, ["agent", "list", "--json"], {
406
+ timeoutMs: this.timeoutMs,
407
+ maxBuffer: DEFAULT_COMMAND_MAX_BUFFER
408
+ })).stdout;
409
+ }
410
+ return parseMcodaAgentListOutput(stdout)
411
+ .map((agent) => mapMcodaAgentToSelfHostedModel(agent, config))
412
+ .filter((model) => Boolean(model));
413
+ }
414
+ }
415
+ export class OllamaClient {
416
+ baseUrl;
417
+ fetchImpl;
418
+ timeoutMs;
419
+ constructor(input) {
420
+ this.baseUrl = trimTrailingSlash(input.baseUrl);
421
+ this.fetchImpl = input.fetchImpl || fetch;
422
+ this.timeoutMs = input.timeoutMs || DEFAULT_REQUEST_TIMEOUT_MS;
423
+ }
424
+ async getVersion() {
425
+ const response = await fetchJson(this.fetchImpl, `${this.baseUrl}/api/version`, { method: "GET" }, this.timeoutMs);
426
+ return optionalText(response.version);
427
+ }
428
+ async listModels(config) {
429
+ const response = await fetchJson(this.fetchImpl, `${this.baseUrl}/api/tags`, { method: "GET" }, this.timeoutMs);
430
+ return (response.models || [])
431
+ .map((model) => mapOllamaModelToSelfHostedModel(model, config))
432
+ .filter((model) => Boolean(model));
433
+ }
434
+ async chat(input) {
435
+ const response = await fetchJson(this.fetchImpl, `${this.baseUrl}/api/chat`, {
436
+ method: "POST",
437
+ headers: { "content-type": "application/json" },
438
+ body: JSON.stringify({
439
+ model: input.model,
440
+ messages: input.messages.map((message) => ({
441
+ role: message.role,
442
+ content: openAIMessageContentToText(message.content)
443
+ })),
444
+ stream: false,
445
+ ...(input.options && Object.keys(input.options).length > 0 ? { options: input.options } : {})
446
+ })
447
+ }, this.timeoutMs);
448
+ return {
449
+ content: typeof response.message?.content === "string" ? response.message.content : "",
450
+ promptTokens: typeof response.prompt_eval_count === "number" ? response.prompt_eval_count : null,
451
+ completionTokens: typeof response.eval_count === "number" ? response.eval_count : null,
452
+ raw: response
453
+ };
454
+ }
455
+ }
456
+ function openAIMessageContentToText(content) {
457
+ if (typeof content === "string") {
458
+ return content;
459
+ }
460
+ return content
461
+ .map((part) => (part.type === "text" && typeof part.text === "string" ? part.text : ""))
462
+ .filter(Boolean)
463
+ .join("\n");
464
+ }
465
+ function messagesToPrompt(messages) {
466
+ return messages
467
+ .map((message) => {
468
+ const role = message.role || "user";
469
+ const content = openAIMessageContentToText(message.content).trim();
470
+ return content ? `${role}: ${content}` : "";
471
+ })
472
+ .filter(Boolean)
473
+ .join("\n\n")
474
+ .trim();
475
+ }
476
+ function positiveInteger(value) {
477
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : null;
478
+ }
479
+ function buildOpenAIChatCompletion(input) {
480
+ const promptTokens = positiveInteger(input.promptTokens);
481
+ const completionTokens = positiveInteger(input.completionTokens);
482
+ const totalTokens = promptTokens !== null && completionTokens !== null ? promptTokens + completionTokens : null;
483
+ return {
484
+ id: `chatcmpl-${input.requestId}`,
485
+ object: "chat.completion",
486
+ created: Math.floor(Date.now() / 1000),
487
+ model: input.model,
488
+ choices: [
489
+ {
490
+ index: 0,
491
+ message: { role: "assistant", content: input.content },
492
+ finish_reason: "stop"
493
+ }
494
+ ],
495
+ usage: {
496
+ prompt_tokens: promptTokens,
497
+ completion_tokens: completionTokens,
498
+ total_tokens: totalTokens,
499
+ cost_cents: 0
500
+ },
501
+ metadata: input.metadata
502
+ };
503
+ }
504
+ export class McodaLocalAgentExecutor {
505
+ command;
506
+ timeoutMs;
507
+ runner;
508
+ constructor(input) {
509
+ this.command = input.command || DEFAULT_MCODA_BIN;
510
+ this.timeoutMs = input.timeoutMs || DEFAULT_REQUEST_TIMEOUT_MS;
511
+ this.runner = input.runner || defaultCommandRunner;
512
+ }
513
+ async invoke(agentSlug, prompt) {
514
+ const stdout = (await this.runner(this.command, ["agent-run", agentSlug, "--json", "--stdin"], {
515
+ timeoutMs: this.timeoutMs,
516
+ maxBuffer: DEFAULT_COMMAND_MAX_BUFFER,
517
+ input: prompt
518
+ })).stdout;
519
+ const parsed = JSON.parse(stdout);
520
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.responses)) {
521
+ throw new Error("mcoda agent-run returned unsupported JSON");
522
+ }
523
+ const response = parsed.responses[0] || {};
524
+ const output = optionalText(response.output);
525
+ if (!output) {
526
+ throw new Error("mcoda agent-run response did not include output");
527
+ }
528
+ return {
529
+ output,
530
+ adapter: optionalText(response.adapter) || undefined,
531
+ model: optionalText(response.model) || undefined,
532
+ metadata: response.metadata && typeof response.metadata === "object" ? response.metadata : undefined
533
+ };
534
+ }
535
+ }
536
+ export class MswarmSelfHostedNodeClient {
537
+ gatewayBaseUrl;
538
+ fetchImpl;
539
+ timeoutMs;
540
+ constructor(input) {
541
+ this.gatewayBaseUrl = trimTrailingSlash(input.gatewayBaseUrl);
542
+ this.fetchImpl = input.fetchImpl || fetch;
543
+ this.timeoutMs = input.timeoutMs || DEFAULT_REQUEST_TIMEOUT_MS;
544
+ }
545
+ async enroll(nodeId, enrollmentToken) {
546
+ return fetchJson(this.fetchImpl, `${this.gatewayBaseUrl}/v1/swarm/self-hosted/node/enroll`, {
547
+ method: "POST",
548
+ headers: { "content-type": "application/json" },
549
+ body: JSON.stringify({ node_id: nodeId, enrollment_token: enrollmentToken })
550
+ }, this.timeoutMs);
551
+ }
552
+ async heartbeat(runtimeToken, payload) {
553
+ return fetchJson(this.fetchImpl, `${this.gatewayBaseUrl}/v1/swarm/self-hosted/node/heartbeat`, {
554
+ method: "POST",
555
+ headers: {
556
+ "content-type": "application/json",
557
+ authorization: `Bearer ${runtimeToken}`
558
+ },
559
+ body: JSON.stringify(payload)
560
+ }, this.timeoutMs);
561
+ }
562
+ async pushModels(runtimeToken, payload) {
563
+ return fetchJson(this.fetchImpl, `${this.gatewayBaseUrl}/v1/swarm/self-hosted/node/models`, {
564
+ method: "POST",
565
+ headers: {
566
+ "content-type": "application/json",
567
+ authorization: `Bearer ${runtimeToken}`
568
+ },
569
+ body: JSON.stringify(payload)
570
+ }, this.timeoutMs);
571
+ }
572
+ }
573
+ export class SelfHostedNodeRuntime {
574
+ config;
575
+ gateway;
576
+ mcoda;
577
+ mcodaExecutor;
578
+ ollama;
579
+ constructor(config, deps) {
580
+ this.config = config;
581
+ this.gateway =
582
+ deps?.gateway ||
583
+ new MswarmSelfHostedNodeClient({
584
+ gatewayBaseUrl: config.gatewayBaseUrl,
585
+ fetchImpl: deps?.fetchImpl,
586
+ timeoutMs: config.requestTimeoutMs
587
+ });
588
+ this.mcoda =
589
+ deps?.mcoda ||
590
+ new McodaAgentInventoryClient({
591
+ command: config.mcodaBin,
592
+ args: config.mcodaListArgs,
593
+ timeoutMs: config.requestTimeoutMs
594
+ });
595
+ this.mcodaExecutor =
596
+ deps?.mcodaExecutor ||
597
+ new McodaLocalAgentExecutor({
598
+ command: config.mcodaBin,
599
+ timeoutMs: config.requestTimeoutMs
600
+ });
601
+ this.ollama =
602
+ deps?.ollama ||
603
+ new OllamaClient({
604
+ baseUrl: config.ollamaBaseUrl,
605
+ fetchImpl: deps?.fetchImpl,
606
+ timeoutMs: config.requestTimeoutMs
607
+ });
608
+ }
609
+ async discoverModels() {
610
+ if (this.config.discoveryMode === "ollama") {
611
+ const [version, models] = await Promise.all([
612
+ this.ollama.getVersion(),
613
+ this.ollama.listModels(this.config)
614
+ ]);
615
+ return { source: "ollama", status: "online", models, version, failureCount: 0 };
616
+ }
617
+ const models = await this.mcoda.listAgents(this.config);
618
+ return { source: "mcoda", status: "online", models, version: null, failureCount: 0 };
619
+ }
620
+ async ensureEnrolled() {
621
+ const currentState = await readSelfHostedNodeState(this.config.statePath);
622
+ const persistedRuntimeToken = await readSelfHostedRuntimeToken(this.config.runtimeTokenPath);
623
+ const existingRuntimeToken = this.config.runtimeToken || persistedRuntimeToken || currentState.runtime_token;
624
+ if (existingRuntimeToken) {
625
+ return { runtimeToken: existingRuntimeToken, state: currentState, enrolled: false };
626
+ }
627
+ if (!this.config.enrollmentToken) {
628
+ throw new Error("No runtime token is stored and MSWARM_SELF_HOSTED_ENROLLMENT_TOKEN is missing");
629
+ }
630
+ const response = await this.gateway.enroll(this.config.nodeId, this.config.enrollmentToken);
631
+ const runtimeToken = optionalText(response.runtime_token);
632
+ if (!runtimeToken) {
633
+ throw new Error("Enrollment response did not include runtime_token");
634
+ }
635
+ const nextState = {
636
+ ...currentState,
637
+ node_id: this.config.nodeId,
638
+ runtime_token: undefined,
639
+ config_version: response.config_version,
640
+ heartbeat_interval_seconds: response.heartbeat_interval_seconds || this.config.heartbeatIntervalSeconds,
641
+ heartbeat_timeout_seconds: response.heartbeat_timeout_seconds,
642
+ enrolled_at: currentState.enrolled_at || new Date().toISOString(),
643
+ updated_at: new Date().toISOString(),
644
+ gateway_base_url: this.config.gatewayBaseUrl,
645
+ ollama_base_url: this.config.ollamaBaseUrl
646
+ };
647
+ await writeSelfHostedNodeState(this.config.statePath, nextState);
648
+ await writeSelfHostedRuntimeToken(this.config.runtimeTokenPath, runtimeToken);
649
+ return { runtimeToken, state: nextState, enrolled: true };
650
+ }
651
+ async executeJob(job) {
652
+ const startedAt = Date.now();
653
+ if (job.node_id !== this.config.nodeId) {
654
+ return {
655
+ job_id: job.job_id,
656
+ request_id: job.request_id,
657
+ status: "failed",
658
+ error: { code: "validation_failed", message: "job node_id does not match this node" }
659
+ };
660
+ }
661
+ if (job.openai_request.stream) {
662
+ return {
663
+ job_id: job.job_id,
664
+ request_id: job.request_id,
665
+ status: "failed",
666
+ error: { code: "validation_failed", message: "streaming relay jobs are not supported by this node yet" }
667
+ };
668
+ }
669
+ try {
670
+ if (job.provider === "ollama") {
671
+ const options = {};
672
+ if (job.openai_request.temperature !== undefined)
673
+ options.temperature = job.openai_request.temperature;
674
+ if (job.openai_request.top_p !== undefined)
675
+ options.top_p = job.openai_request.top_p;
676
+ if (job.openai_request.max_tokens !== undefined)
677
+ options.num_predict = job.openai_request.max_tokens;
678
+ if (job.openai_request.stop !== undefined)
679
+ options.stop = job.openai_request.stop;
680
+ const result = await this.ollama.chat({
681
+ model: job.model || job.openai_request.model,
682
+ messages: job.openai_request.messages,
683
+ options
684
+ });
685
+ return {
686
+ job_id: job.job_id,
687
+ request_id: job.request_id,
688
+ status: "success",
689
+ openai_response: buildOpenAIChatCompletion({
690
+ requestId: job.request_id,
691
+ model: job.openai_request.model,
692
+ content: result.content,
693
+ promptTokens: result.promptTokens,
694
+ completionTokens: result.completionTokens,
695
+ metadata: { provider: "ollama", raw: result.raw }
696
+ }),
697
+ timing: { local_latency_ms: Date.now() - startedAt }
698
+ };
699
+ }
700
+ const agentSlug = optionalText(job.source_agent_slug) || optionalText(job.model) || optionalText(job.agent_slug);
701
+ if (!agentSlug) {
702
+ throw new Error("mcoda source agent slug is required");
703
+ }
704
+ const prompt = messagesToPrompt(job.openai_request.messages);
705
+ if (!prompt) {
706
+ throw new Error("mcoda invocation prompt is empty");
707
+ }
708
+ const response = await this.mcodaExecutor.invoke(agentSlug, prompt);
709
+ const metadata = response.metadata || {};
710
+ const promptTokens = positiveInteger(metadata.tokensPrompt ?? metadata.tokens_prompt);
711
+ const completionTokens = positiveInteger(metadata.tokensCompletion ?? metadata.tokens_completion);
712
+ return {
713
+ job_id: job.job_id,
714
+ request_id: job.request_id,
715
+ status: "success",
716
+ openai_response: buildOpenAIChatCompletion({
717
+ requestId: job.request_id,
718
+ model: job.openai_request.model,
719
+ content: response.output,
720
+ promptTokens,
721
+ completionTokens,
722
+ metadata: {
723
+ provider: "mcoda",
724
+ adapter: response.adapter,
725
+ local_model: response.model,
726
+ mcoda_metadata: metadata
727
+ }
728
+ }),
729
+ timing: { local_latency_ms: Date.now() - startedAt }
730
+ };
731
+ }
732
+ catch (error) {
733
+ return {
734
+ job_id: job.job_id,
735
+ request_id: job.request_id,
736
+ status: "failed",
737
+ error: {
738
+ code: "upstream_error",
739
+ message: error instanceof Error ? error.message : String(error)
740
+ },
741
+ timing: { local_latency_ms: Date.now() - startedAt }
742
+ };
743
+ }
744
+ }
745
+ async runOnce() {
746
+ const enrollment = await this.ensureEnrolled();
747
+ let status = "online";
748
+ let version = null;
749
+ let models = [];
750
+ let discoverySource = this.config.discoveryMode;
751
+ let recentFailureCount = 0;
752
+ const startedAt = Date.now();
753
+ try {
754
+ const discovery = await this.discoverModels();
755
+ status = discovery.status;
756
+ version = discovery.version;
757
+ models = discovery.models;
758
+ discoverySource = discovery.source;
759
+ recentFailureCount = discovery.failureCount;
760
+ }
761
+ catch (error) {
762
+ status = "degraded";
763
+ recentFailureCount = 1;
764
+ models = [];
765
+ version = null;
766
+ }
767
+ const heartbeatPayload = {
768
+ node_id: this.config.nodeId,
769
+ node_version: this.config.nodeVersion,
770
+ config_version: enrollment.state.config_version ?? null,
771
+ status,
772
+ discovery: {
773
+ source: discoverySource,
774
+ mcoda_status: discoverySource === "mcoda" && status === "online" ? "ok" : status === "degraded" ? "error" : null
775
+ },
776
+ ollama: discoverySource === "ollama"
777
+ ? {
778
+ status: status === "online" ? "ok" : "error",
779
+ version
780
+ }
781
+ : {
782
+ status: null,
783
+ version: null
784
+ },
785
+ capacity: {
786
+ active_jobs: 0,
787
+ queued_jobs: 0
788
+ },
789
+ health: {
790
+ avg_latency_ms: Date.now() - startedAt,
791
+ recent_failure_count: recentFailureCount,
792
+ last_success_at: status === "online" ? new Date().toISOString() : null
793
+ },
794
+ models
795
+ };
796
+ const heartbeatResponse = await this.gateway.heartbeat(enrollment.runtimeToken, heartbeatPayload);
797
+ return {
798
+ enrolled: enrollment.enrolled,
799
+ status,
800
+ model_count: models.length,
801
+ discovery_source: discoverySource,
802
+ mcoda_agent_count: discoverySource === "mcoda" ? models.length : undefined,
803
+ ollama_version: version,
804
+ heartbeat_response: heartbeatResponse
805
+ };
806
+ }
807
+ async pushModelsOnly() {
808
+ const enrollment = await this.ensureEnrolled();
809
+ const discovery = await this.discoverModels();
810
+ const models = discovery.models;
811
+ const response = await this.gateway.pushModels(enrollment.runtimeToken, {
812
+ node_id: this.config.nodeId,
813
+ models
814
+ });
815
+ return { count: models.length, response };
816
+ }
817
+ startDaemon() {
818
+ let stopped = false;
819
+ let timer = null;
820
+ const schedule = () => {
821
+ if (stopped)
822
+ return;
823
+ timer = setTimeout(() => {
824
+ void this.runOnce()
825
+ .catch(() => undefined)
826
+ .finally(schedule);
827
+ }, this.config.heartbeatIntervalSeconds * 1000);
828
+ };
829
+ void this.runOnce()
830
+ .catch(() => undefined)
831
+ .finally(schedule);
832
+ return {
833
+ stop: () => {
834
+ stopped = true;
835
+ if (timer) {
836
+ clearTimeout(timer);
837
+ }
838
+ }
839
+ };
840
+ }
841
+ }
842
+ //# sourceMappingURL=runtime.js.map