@kolisachint/hoocode-agent 0.4.70 → 0.4.72

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 (45) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/cli/args.d.ts +2 -0
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +11 -0
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/core/agent-session-services.d.ts +1 -0
  7. package/dist/core/agent-session-services.d.ts.map +1 -1
  8. package/dist/core/agent-session-services.js +1 -0
  9. package/dist/core/agent-session-services.js.map +1 -1
  10. package/dist/core/agent-session.d.ts +17 -1
  11. package/dist/core/agent-session.d.ts.map +1 -1
  12. package/dist/core/agent-session.js +217 -21
  13. package/dist/core/agent-session.js.map +1 -1
  14. package/dist/core/model-registry.d.ts +6 -0
  15. package/dist/core/model-registry.d.ts.map +1 -1
  16. package/dist/core/model-registry.js +34 -0
  17. package/dist/core/model-registry.js.map +1 -1
  18. package/dist/core/routing/local-inference.d.ts +129 -0
  19. package/dist/core/routing/local-inference.d.ts.map +1 -0
  20. package/dist/core/routing/local-inference.js +154 -0
  21. package/dist/core/routing/local-inference.js.map +1 -0
  22. package/dist/core/routing/metrics.d.ts +27 -0
  23. package/dist/core/routing/metrics.d.ts.map +1 -0
  24. package/dist/core/routing/metrics.js +35 -0
  25. package/dist/core/routing/metrics.js.map +1 -0
  26. package/dist/core/routing/mlx-server.d.ts +43 -0
  27. package/dist/core/routing/mlx-server.d.ts.map +1 -0
  28. package/dist/core/routing/mlx-server.js +115 -0
  29. package/dist/core/routing/mlx-server.js.map +1 -0
  30. package/dist/core/routing/tool-result-prompts.d.ts +26 -0
  31. package/dist/core/routing/tool-result-prompts.d.ts.map +1 -0
  32. package/dist/core/routing/tool-result-prompts.js +45 -0
  33. package/dist/core/routing/tool-result-prompts.js.map +1 -0
  34. package/dist/core/sdk.d.ts +6 -0
  35. package/dist/core/sdk.d.ts.map +1 -1
  36. package/dist/core/sdk.js +1 -0
  37. package/dist/core/sdk.js.map +1 -1
  38. package/dist/main.d.ts.map +1 -1
  39. package/dist/main.js +1 -0
  40. package/dist/main.js.map +1 -1
  41. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  42. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  43. package/examples/extensions/sandbox/package.json +1 -1
  44. package/examples/extensions/with-deps/package.json +1 -1
  45. package/package.json +4 -4
@@ -14,21 +14,26 @@
14
14
  */
15
15
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
16
  import { basename, dirname, resolve } from "node:path";
17
- import { clampThinkingLevel, cleanupSessionResources, getSupportedThinkingLevels, isContextOverflow, modelsAreEqual, resetApiProviders, } from "@kolisachint/hoocode-ai";
17
+ import { clampThinkingLevel, cleanupSessionResources, completeSimple, getSupportedThinkingLevels, isContextOverflow, modelsAreEqual, resetApiProviders, } from "@kolisachint/hoocode-ai";
18
18
  import { theme } from "../modes/interactive/theme/theme.js";
19
19
  import { stripFrontmatter } from "../utils/frontmatter.js";
20
20
  import { sleep } from "../utils/sleep.js";
21
21
  import { loadAgentRegistry } from "./agent-registry.js";
22
22
  import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.js";
23
23
  import { executeBashWithOperations } from "./bash-executor.js";
24
- import { calculateContextTokens, collectEntriesForBranchSummary, compact, estimateContextTokens, generateBranchSummary, prepareCompaction, shouldCompact, } from "./compaction/index.js";
24
+ import { calculateContextTokens, collectEntriesForBranchSummary, compact, estimateContextTokens, generateBranchSummary, prepareCompaction, serializeConversation, shouldCompact, } from "./compaction/index.js";
25
25
  import { DEFAULT_THINKING_LEVEL } from "./defaults.js";
26
26
  import { exportSessionToHtml } from "./export-html/index.js";
27
27
  import { createToolHtmlRenderer } from "./export-html/tool-renderer.js";
28
28
  import { ExtensionRunner, wrapRegisteredTools, } from "./extensions/index.js";
29
29
  import { emitSessionShutdownEvent } from "./extensions/runner.js";
30
+ import { convertToLlm } from "./messages.js";
30
31
  import { expandPromptTemplate, tryExpandPromptTemplate } from "./prompt-templates.js";
31
32
  import { clearProviderExhaustion, isProviderQuotaError, markProviderExhausted } from "./provider-health.js";
33
+ import { LocalInferenceRouter, resolveRoutingMode } from "./routing/local-inference.js";
34
+ import { logLocalInferenceFallback, logRoutingMetrics } from "./routing/metrics.js";
35
+ import { MlxServerManager } from "./routing/mlx-server.js";
36
+ import { buildToolResultPrompt, stripThinkTags, TOOL_RESULT_SYSTEM_PROMPT } from "./routing/tool-result-prompts.js";
32
37
  import { CURRENT_SESSION_VERSION, getLatestCompactionEntry } from "./session-manager.js";
33
38
  import { createSyntheticSourceInfo } from "./source-info.js";
34
39
  import { updateSubagentSkillPaths } from "./subagent-pool-instance.js";
@@ -108,6 +113,12 @@ export class AgentSession {
108
113
  _extensionErrorUnsubscriber;
109
114
  // Model registry for API key resolution
110
115
  _modelRegistry;
116
+ // Local-inference routing (opt-in via enableLocalInference). Lazily built.
117
+ _enableLocalInference;
118
+ _localRouter;
119
+ _localRouterBuilt = false;
120
+ _mlxServer;
121
+ _mlxServerBuilt = false;
111
122
  // Tool registry for extension getTools/setTools
112
123
  _toolRegistry = new Map();
113
124
  _toolDefinitions = new Map();
@@ -126,6 +137,7 @@ export class AgentSession {
126
137
  this._customTools = config.customTools ?? [];
127
138
  this._cwd = config.cwd;
128
139
  this._modelRegistry = config.modelRegistry;
140
+ this._enableLocalInference = config.enableLocalInference ?? false;
129
141
  this._extensionRunnerRef = config.extensionRunnerRef;
130
142
  this._initialActiveToolNames = config.initialActiveToolNames;
131
143
  this._allowedToolNames = config.allowedToolNames ? new Set(config.allowedToolNames) : undefined;
@@ -148,6 +160,57 @@ export class AgentSession {
148
160
  get modelRegistry() {
149
161
  return this._modelRegistry;
150
162
  }
163
+ /**
164
+ * Local-inference router, built lazily from the flag + env + models.json
165
+ * routing config. Returns undefined when routing is not active.
166
+ */
167
+ get localRouter() {
168
+ if (!this._localRouterBuilt) {
169
+ this._localRouterBuilt = true;
170
+ const mode = resolveRoutingMode({
171
+ enableFlag: this._enableLocalInference,
172
+ envMode: process.env.HOOCODE_ROUTING_MODE,
173
+ configMode: this._modelRegistry.getRoutingConfig()?.mode,
174
+ });
175
+ this._localRouter =
176
+ mode === "primary-only"
177
+ ? undefined
178
+ : LocalInferenceRouter.create({
179
+ mode,
180
+ config: this._modelRegistry.getRoutingConfig(),
181
+ registry: this._modelRegistry,
182
+ });
183
+ }
184
+ return this._localRouter;
185
+ }
186
+ /**
187
+ * Ensure the harness-managed executor server (if configured) is healthy before
188
+ * the executor is used. Returns false when routing is inactive or the server
189
+ * could not be started (caller degrades to primary/raw). Reuses an externally
190
+ * started server when one is already healthy.
191
+ */
192
+ async _ensureExecutorServer(signal) {
193
+ const router = this.localRouter;
194
+ if (!router?.isExecutorAvailable())
195
+ return false;
196
+ const serverConfig = router.getExecutorConfig()?.server;
197
+ if (!serverConfig)
198
+ return true; // user-managed server; assume available
199
+ if (!this._mlxServerBuilt) {
200
+ this._mlxServerBuilt = true;
201
+ const executor = router.getExecutorModel();
202
+ if (executor) {
203
+ this._mlxServer = new MlxServerManager({
204
+ config: serverConfig,
205
+ modelId: executor.id,
206
+ baseUrl: executor.baseUrl,
207
+ });
208
+ }
209
+ }
210
+ if (!this._mlxServer)
211
+ return false;
212
+ return this._mlxServer.ensureStarted(signal);
213
+ }
151
214
  async _getRequiredRequestAuth(model) {
152
215
  const result = await this._modelRegistry.getApiKeyAndHeaders(model);
153
216
  if (!result.ok) {
@@ -199,28 +262,114 @@ export class AgentSession {
199
262
  };
200
263
  this.agent.afterToolCall = async ({ toolCall, args, result, isError }) => {
201
264
  const runner = this._extensionRunner;
202
- if (!runner.hasHandlers("tool_result")) {
203
- return undefined;
265
+ let content = result.content;
266
+ let details = result.details;
267
+ let resolvedIsError = isError;
268
+ let changed = false;
269
+ // Extensions see the real (uncompressed) tool result first.
270
+ if (runner.hasHandlers("tool_result")) {
271
+ const hookResult = await runner.emitToolResult({
272
+ type: "tool_result",
273
+ toolName: toolCall.name,
274
+ toolCallId: toolCall.id,
275
+ input: args,
276
+ content: result.content,
277
+ details: result.details,
278
+ isError,
279
+ });
280
+ if (hookResult) {
281
+ content = hookResult.content ?? content;
282
+ details = hookResult.details ?? details;
283
+ resolvedIsError = hookResult.isError ?? isError;
284
+ changed = true;
285
+ }
204
286
  }
205
- const hookResult = await runner.emitToolResult({
206
- type: "tool_result",
207
- toolName: toolCall.name,
208
- toolCallId: toolCall.id,
209
- input: args,
210
- content: result.content,
211
- details: result.details,
212
- isError,
213
- });
214
- if (!hookResult) {
215
- return undefined;
287
+ // Optionally compress large bash output before it enters context.
288
+ // Only when local-inference tool-result routing is active; on any
289
+ // failure the original (uncompressed) content is kept (fallback to raw).
290
+ if (!resolvedIsError) {
291
+ const compressed = await this._maybeCompressToolResult(toolCall.name, content);
292
+ if (compressed) {
293
+ content = compressed;
294
+ changed = true;
295
+ }
216
296
  }
217
- return {
218
- content: hookResult.content,
219
- details: hookResult.details,
220
- isError: hookResult.isError ?? isError,
221
- };
297
+ if (!changed)
298
+ return undefined;
299
+ return { content, details, isError: resolvedIsError };
222
300
  };
223
301
  }
302
+ /**
303
+ * Compress a tool result via the local executor when routing is active and the
304
+ * tool/size qualify. Returns the compressed content blocks, or undefined to
305
+ * keep the original (no routing, not compressible, outside the size band, or
306
+ * any failure).
307
+ */
308
+ async _maybeCompressToolResult(toolName, content) {
309
+ const router = this.localRouter;
310
+ if (!router)
311
+ return undefined;
312
+ const text = extractTextContent(content);
313
+ if (text === undefined)
314
+ return undefined;
315
+ const bytes = Buffer.byteLength(text, "utf8");
316
+ if (!router.shouldCompressToolResult(toolName, bytes))
317
+ return undefined;
318
+ const executor = router.getExecutorModel();
319
+ const prompt = buildToolResultPrompt(toolName, text);
320
+ if (!executor || !prompt)
321
+ return undefined;
322
+ const startedAt = Date.now();
323
+ try {
324
+ if (!(await this._ensureExecutorServer())) {
325
+ throw new Error("executor server unavailable");
326
+ }
327
+ const { apiKey, headers } = await this._getRequiredRequestAuth(executor);
328
+ const response = await completeSimple(executor, {
329
+ systemPrompt: TOOL_RESULT_SYSTEM_PROMPT,
330
+ messages: [
331
+ {
332
+ role: "user",
333
+ content: [{ type: "text", text: prompt }],
334
+ timestamp: Date.now(),
335
+ },
336
+ ],
337
+ }, { apiKey, headers });
338
+ if (response.stopReason === "error") {
339
+ throw new Error(response.errorMessage || "executor error");
340
+ }
341
+ const rawSummary = response.content
342
+ .filter((c) => c.type === "text")
343
+ .map((c) => c.text)
344
+ .join("\n");
345
+ const summary = stripThinkTags(rawSummary);
346
+ if (summary === "")
347
+ throw new Error("empty compression");
348
+ const compressedBytes = Buffer.byteLength(summary, "utf8");
349
+ // Guard: if the model failed to compress (output not smaller), keep raw.
350
+ if (compressedBytes >= bytes)
351
+ return undefined;
352
+ logRoutingMetrics({
353
+ turnKind: "tool-result",
354
+ provider: executor.provider,
355
+ model: executor.id,
356
+ latencyMs: Date.now() - startedAt,
357
+ bytesBefore: bytes,
358
+ bytesAfter: compressedBytes,
359
+ fallback: false,
360
+ });
361
+ return [
362
+ {
363
+ type: "text",
364
+ text: `${summary}\n\n[compressed from ${bytes} to ${compressedBytes} bytes by local executor]`,
365
+ },
366
+ ];
367
+ }
368
+ catch (error) {
369
+ logLocalInferenceFallback("tool-result", error);
370
+ return undefined;
371
+ }
372
+ }
224
373
  // =========================================================================
225
374
  // Event Subscription
226
375
  // =========================================================================
@@ -531,6 +680,7 @@ export class AgentSession {
531
680
  this._extensionRunner.invalidate("This extension ctx is stale after session replacement or reload. Do not use a captured extension API or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().");
532
681
  this._disconnectFromAgent();
533
682
  this._eventListeners = [];
683
+ this._mlxServer?.stop();
534
684
  cleanupSessionResources(this.sessionId);
535
685
  }
536
686
  // =========================================================================
@@ -1317,7 +1467,7 @@ export class AgentSession {
1317
1467
  }
1318
1468
  }
1319
1469
  const generated = extensionCompaction ??
1320
- (await compact(preparation, model, apiKey, headers, customInstructions, signal, this.thinkingLevel));
1470
+ (await this._compactWithRouting(preparation, model, apiKey, headers, customInstructions, signal));
1321
1471
  if (signal.aborted) {
1322
1472
  return { status: "cancelled" };
1323
1473
  }
@@ -1338,6 +1488,37 @@ export class AgentSession {
1338
1488
  result: { summary, firstKeptEntryId, tokensBefore, tokensAfter: tokensAfter ?? tokensBefore, details },
1339
1489
  };
1340
1490
  }
1491
+ /**
1492
+ * Run compaction, routing the summarization to the local executor model when
1493
+ * local-inference routing is active for summarization. Any executor
1494
+ * failure falls back to the primary model so compaction never hard-fails.
1495
+ */
1496
+ async _compactWithRouting(preparation, primaryModel, apiKey, headers, customInstructions, signal) {
1497
+ const router = this.localRouter;
1498
+ const executor = router?.selectModel("summarization", primaryModel);
1499
+ // Size band guards local inference globally: only route conversations within
1500
+ // the configured byte band to the executor. Oversized conversations are slow
1501
+ // locally and can OOM small machines, so they fall back to the primary model.
1502
+ const conversationBytes = Buffer.byteLength(serializeConversation(convertToLlm(preparation.messagesToSummarize)), "utf8");
1503
+ if (router && executor && executor !== primaryModel && router.withinSizeBand(conversationBytes)) {
1504
+ try {
1505
+ if (!(await this._ensureExecutorServer(signal))) {
1506
+ throw new Error("executor server unavailable");
1507
+ }
1508
+ const { apiKey: exKey, headers: exHeaders } = await this._getRequiredRequestAuth(executor);
1509
+ return await compact(preparation, executor, exKey, exHeaders, customInstructions, signal,
1510
+ // Executor runs without thinking (validated config); summary quality
1511
+ // relies on /no_think via the model's promptSuffix compat setting.
1512
+ "off");
1513
+ }
1514
+ catch (error) {
1515
+ if (signal.aborted)
1516
+ throw error;
1517
+ logLocalInferenceFallback("summarization", error);
1518
+ }
1519
+ }
1520
+ return compact(preparation, primaryModel, apiKey, headers, customInstructions, signal, this.thinkingLevel);
1521
+ }
1341
1522
  /**
1342
1523
  * Manually compact the session context.
1343
1524
  * Aborts current agent operation first.
@@ -2523,4 +2704,19 @@ export class AgentSession {
2523
2704
  return this._extensionRunner;
2524
2705
  }
2525
2706
  }
2707
+ /**
2708
+ * Join all text blocks of a tool result. Returns undefined if any non-text
2709
+ * (e.g. image) content is present, since those must not be compressed.
2710
+ */
2711
+ function extractTextContent(content) {
2712
+ if (content.length === 0)
2713
+ return undefined;
2714
+ const texts = [];
2715
+ for (const block of content) {
2716
+ if (block.type !== "text")
2717
+ return undefined;
2718
+ texts.push(block.text);
2719
+ }
2720
+ return texts.join("\n");
2721
+ }
2526
2722
  //# sourceMappingURL=agent-session.js.map