@theokit/sdk 2.15.1 → 2.18.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 (75) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/a2a/index.cjs +981 -208
  3. package/dist/a2a/index.cjs.map +1 -1
  4. package/dist/a2a/index.js +982 -209
  5. package/dist/a2a/index.js.map +1 -1
  6. package/dist/{cron-BxLSz1UH.d.cts → cron-Bbg0mBOv.d.ts} +33 -3
  7. package/dist/{cron-DcaoP7aW.d.ts → cron-ZLSKbDbB.d.cts} +33 -3
  8. package/dist/cron.cjs +945 -196
  9. package/dist/cron.cjs.map +1 -1
  10. package/dist/cron.d.cts +2 -2
  11. package/dist/cron.d.ts +2 -2
  12. package/dist/cron.js +945 -196
  13. package/dist/cron.js.map +1 -1
  14. package/dist/define-tool.d.ts +9 -2
  15. package/dist/{errors-Bart0ptP.d.cts → errors-1tVcX3Fq.d.cts} +1 -1
  16. package/dist/{errors-DJuuubJK.d.ts → errors-qyVYfk9H.d.ts} +1 -1
  17. package/dist/errors.d.cts +2 -2
  18. package/dist/eval.cjs +951 -198
  19. package/dist/eval.cjs.map +1 -1
  20. package/dist/eval.js +951 -198
  21. package/dist/eval.js.map +1 -1
  22. package/dist/event-bus.d.ts +3 -0
  23. package/dist/index.cjs +1082 -224
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +122 -27
  26. package/dist/index.d.ts +122 -27
  27. package/dist/index.js +1082 -226
  28. package/dist/index.js.map +1 -1
  29. package/dist/internal/agent-loop/tool-dispatch.d.ts +3 -1
  30. package/dist/internal/agent-loop/tool-result-guard.d.ts +24 -0
  31. package/dist/internal/agent-loop/tool-timeout.d.ts +23 -0
  32. package/dist/internal/llm/openai.d.ts +13 -0
  33. package/dist/internal/llm/sse.d.ts +13 -1
  34. package/dist/internal/mcp/client.d.ts +1 -1
  35. package/dist/internal/memory/active-memory.d.ts +1 -1
  36. package/dist/internal/persistence/conversation-storage-fs.d.cts +7 -1
  37. package/dist/internal/persistence/conversation-storage-fs.d.ts +7 -1
  38. package/dist/internal/persistence/conversation-storage-memory.d.cts +7 -1
  39. package/dist/internal/persistence/conversation-storage-memory.d.ts +7 -1
  40. package/dist/internal/persistence/pagination.d.cts +8 -0
  41. package/dist/internal/persistence/pagination.d.ts +8 -0
  42. package/dist/internal/plugins/index.cjs +135 -0
  43. package/dist/internal/plugins/index.cjs.map +1 -1
  44. package/dist/internal/plugins/index.js +135 -0
  45. package/dist/internal/plugins/index.js.map +1 -1
  46. package/dist/internal/plugins/manager.d.cts +21 -1
  47. package/dist/internal/plugins/manager.d.ts +21 -1
  48. package/dist/internal/plugins/types.d.cts +40 -0
  49. package/dist/internal/plugins/types.d.ts +40 -0
  50. package/dist/internal/{memory → resilience}/circuit-breaker.d.ts +5 -1
  51. package/dist/internal/runtime/hooks/hooks-frontmatter.d.ts +1 -1
  52. package/dist/internal/runtime/lifecycle/env-policy.d.ts +30 -0
  53. package/dist/internal/runtime/session/agent-session-store.d.ts +1 -0
  54. package/dist/internal/telemetry/span-names.d.ts +7 -1
  55. package/dist/job-queue.d.ts +29 -7
  56. package/dist/permission-engine.d.ts +32 -7
  57. package/dist/{run-DXy_MVwz.d.cts → run-pE-34AAo.d.cts} +64 -3
  58. package/dist/{run-DXy_MVwz.d.ts → run-pE-34AAo.d.ts} +64 -3
  59. package/dist/sandbox/index.cjs +53 -2
  60. package/dist/sandbox/index.cjs.map +1 -1
  61. package/dist/sandbox/index.js +53 -2
  62. package/dist/sandbox/index.js.map +1 -1
  63. package/dist/sandbox/local-sandbox.d.cts +11 -3
  64. package/dist/sandbox/local-sandbox.d.ts +11 -3
  65. package/dist/sandbox/types.d.cts +7 -0
  66. package/dist/sandbox/types.d.ts +7 -0
  67. package/dist/types/agent-prims.d.ts +6 -2
  68. package/dist/types/conversation-storage.d.ts +32 -2
  69. package/dist/types/mcp.d.ts +20 -0
  70. package/dist/types/run.d.ts +17 -0
  71. package/dist/workflow.cjs +6 -3
  72. package/dist/workflow.cjs.map +1 -1
  73. package/dist/workflow.js +6 -3
  74. package/dist/workflow.js.map +1 -1
  75. package/package.json +1 -1
package/dist/cron.js CHANGED
@@ -886,6 +886,16 @@ var init_credential_pool_types = __esm({
886
886
  });
887
887
 
888
888
  // src/internal/llm/retry.ts
889
+ function computeBackoffMs(opts) {
890
+ const base = opts.baseMs ?? DEFAULT_BASE_MS;
891
+ const cap = opts.capMs ?? DEFAULT_CAP_MS;
892
+ const rng = opts.rng ?? Math.random;
893
+ if (opts.retryAfterMs !== void 0 && opts.retryAfterMs >= 0) {
894
+ return Math.max(base, Math.min(cap, opts.retryAfterMs));
895
+ }
896
+ const ceiling = Math.min(cap, base * 2 ** opts.attempt);
897
+ return Math.floor(rng() * ceiling);
898
+ }
889
899
  function sleepWithAbort(ms, signal) {
890
900
  if (ms <= 0 || signal.aborted) return Promise.resolve();
891
901
  return new Promise((resolve3) => {
@@ -900,8 +910,11 @@ function sleepWithAbort(ms, signal) {
900
910
  signal.addEventListener("abort", onAbort, { once: true });
901
911
  });
902
912
  }
913
+ var DEFAULT_BASE_MS, DEFAULT_CAP_MS;
903
914
  var init_retry = __esm({
904
915
  "src/internal/llm/retry.ts"() {
916
+ DEFAULT_BASE_MS = 500;
917
+ DEFAULT_CAP_MS = 32e3;
905
918
  }
906
919
  });
907
920
 
@@ -4566,13 +4579,15 @@ function buildGitInfo() {
4566
4579
  init_errors();
4567
4580
 
4568
4581
  // src/internal/llm/sse.ts
4569
- async function* parseSseStream(body, signal) {
4582
+ init_errors();
4583
+ var DEFAULT_SSE_IDLE_MS = 6e4;
4584
+ async function* parseSseStream(body, signal, idleTimeoutMs = DEFAULT_SSE_IDLE_MS) {
4570
4585
  if (body === null) return;
4571
4586
  const reader = body.getReader();
4572
4587
  const decoder = new TextDecoder("utf-8");
4573
4588
  const state3 = { buffer: "", event: "message", data: "" };
4574
4589
  try {
4575
- for await (const chunk of readChunks(reader, signal)) {
4590
+ for await (const chunk of readChunks(reader, signal, idleTimeoutMs)) {
4576
4591
  state3.buffer += decoder.decode(chunk, { stream: true });
4577
4592
  for (const record of drainCompleteRecords(state3)) yield record;
4578
4593
  }
@@ -4582,14 +4597,38 @@ async function* parseSseStream(body, signal) {
4582
4597
  releaseReader(reader);
4583
4598
  }
4584
4599
  }
4585
- async function* readChunks(reader, signal) {
4600
+ async function* readChunks(reader, signal, idleTimeoutMs) {
4586
4601
  while (true) {
4587
4602
  if (signal.aborted) return;
4588
- const { value, done } = await reader.read();
4603
+ const { value, done } = await readWithIdleTimeout(reader, idleTimeoutMs);
4589
4604
  if (done) return;
4590
4605
  if (value !== void 0) yield value;
4591
4606
  }
4592
4607
  }
4608
+ function readWithIdleTimeout(reader, idleTimeoutMs) {
4609
+ if (idleTimeoutMs <= 0) return reader.read();
4610
+ return new Promise(
4611
+ (resolve3, reject) => {
4612
+ const timer = setTimeout(() => {
4613
+ reject(
4614
+ new NetworkError(`SSE stream idle for ${idleTimeoutMs}ms \u2014 upstream stalled`, {
4615
+ code: "stream_idle_timeout"
4616
+ })
4617
+ );
4618
+ }, idleTimeoutMs);
4619
+ reader.read().then(
4620
+ (result) => {
4621
+ clearTimeout(timer);
4622
+ resolve3(result);
4623
+ },
4624
+ (err) => {
4625
+ clearTimeout(timer);
4626
+ reject(err);
4627
+ }
4628
+ );
4629
+ }
4630
+ );
4631
+ }
4593
4632
  async function cancelReaderQuietly(reader) {
4594
4633
  try {
4595
4634
  await reader.cancel();
@@ -5283,6 +5322,9 @@ var PersonalityStore = class {
5283
5322
  }
5284
5323
  };
5285
5324
 
5325
+ // src/internal/plugins/manager.ts
5326
+ init_errors();
5327
+
5286
5328
  // src/internal/plugins/context.ts
5287
5329
  function createPluginContext() {
5288
5330
  const registrations = {
@@ -5344,6 +5386,9 @@ var PluginManager = class {
5344
5386
  memoryProviders: []
5345
5387
  };
5346
5388
  #initialized = false;
5389
+ // #68 — registrations of plugins added post-init via `register()`, keyed by
5390
+ // plugin name so a re-register REPLACES (not appends) the prior hooks.
5391
+ #byName = /* @__PURE__ */ new Map();
5347
5392
  async initialize(plugins) {
5348
5393
  if (this.#initialized) {
5349
5394
  throw new Error("PluginManager.initialize called twice \u2014 register only once per process");
@@ -5361,6 +5406,36 @@ var PluginManager = class {
5361
5406
  await this.#dispatchPlugin(plugin);
5362
5407
  }
5363
5408
  }
5409
+ /**
5410
+ * #68 — register a single `general` plugin AFTER `initialize()` has run.
5411
+ *
5412
+ * The bulk `initialize()` is single-shot (one call per process); late
5413
+ * registration is a distinct, named operation used by adapters that install
5414
+ * a plugin per-session/per-request (e.g. the ACP permission veto, which is
5415
+ * installed once the permission mode + connection are known — after the
5416
+ * agent's own plugins were already initialized).
5417
+ *
5418
+ * Idempotent by plugin NAME: re-registering a plugin with the same name
5419
+ * REPLACES its prior hooks/tools instead of appending duplicates (the ACP
5420
+ * permission plugin is re-installed on every prompt).
5421
+ *
5422
+ * Only `general` plugins may be registered late — model-provider / memory
5423
+ * plugins are resolved during the bulk init and cannot be added afterwards.
5424
+ */
5425
+ async register(plugin) {
5426
+ if (plugin.kind !== "general") {
5427
+ throw new ConfigurationError(
5428
+ `late register supports general plugins only (got "${plugin.kind}" for "${plugin.name}")`,
5429
+ { code: "plugin_late_register_kind" }
5430
+ );
5431
+ }
5432
+ const prior = this.#byName.get(plugin.name);
5433
+ if (prior !== void 0) this.#unmerge(prior);
5434
+ const { ctx, registrations } = createPluginContext();
5435
+ await plugin.register(ctx);
5436
+ this.#byName.set(plugin.name, registrations);
5437
+ this.#merge(registrations);
5438
+ }
5364
5439
  get aggregated() {
5365
5440
  return this.#aggregated;
5366
5441
  }
@@ -5441,6 +5516,64 @@ var PluginManager = class {
5441
5516
  }
5442
5517
  }
5443
5518
  }
5519
+ // #65 — the previously-dead hooks, now wired. Fire-and-forget hooks run
5520
+ // in order (per-handler errors logged, never thrown); transform hooks fold
5521
+ // over the payload (a handler returning a value replaces it).
5522
+ /** @internal */
5523
+ async #runFireAndForget(name, ctx) {
5524
+ for (const h of this.#aggregated.hooks.get(name) ?? []) {
5525
+ try {
5526
+ await h(ctx);
5527
+ } catch (err) {
5528
+ process.stderr.write(
5529
+ `[theokit-sdk] ${name} hook failed: ${err instanceof Error ? err.message : String(err)}
5530
+ `
5531
+ );
5532
+ }
5533
+ }
5534
+ }
5535
+ /** @internal — fold: each handler may return a replacement payload; a throw keeps the prior value. */
5536
+ async #runTransform(name, payload, ctx) {
5537
+ let current = payload;
5538
+ for (const h of this.#aggregated.hooks.get(name) ?? []) {
5539
+ try {
5540
+ const out = await h(current, ctx);
5541
+ if (out !== void 0) current = out;
5542
+ } catch (err) {
5543
+ process.stderr.write(
5544
+ `[theokit-sdk] ${name} hook failed: ${err instanceof Error ? err.message : String(err)}
5545
+ `
5546
+ );
5547
+ }
5548
+ }
5549
+ return current;
5550
+ }
5551
+ /** #65 — fired after a tool call completes. @internal */
5552
+ runPostToolCallHooks(ctx) {
5553
+ return this.#runFireAndForget("post_tool_call", ctx);
5554
+ }
5555
+ /** #65 — fired before / after each LLM turn. @internal */
5556
+ runPreLlmCallHooks(ctx) {
5557
+ return this.#runFireAndForget("pre_llm_call", ctx);
5558
+ }
5559
+ runPostLlmCallHooks(ctx) {
5560
+ return this.#runFireAndForget("post_llm_call", ctx);
5561
+ }
5562
+ /** #65 — fired at run start / end. @internal */
5563
+ runOnSessionStartHooks(ctx) {
5564
+ return this.#runFireAndForget("on_session_start", ctx);
5565
+ }
5566
+ runOnSessionEndHooks(ctx) {
5567
+ return this.#runFireAndForget("on_session_end", ctx);
5568
+ }
5569
+ /** #65/#57 — transform tool results before they reach the LLM (the #57 seam). @internal */
5570
+ runTransformToolResultHooks(results, ctx) {
5571
+ return this.#runTransform("transform_tool_result", results, ctx);
5572
+ }
5573
+ /** #65 — transform the LLM output text before it is consumed. @internal */
5574
+ runTransformLlmOutputHooks(output, ctx) {
5575
+ return this.#runTransform("transform_llm_output", output, ctx);
5576
+ }
5444
5577
  async #dispatchPlugin(plugin) {
5445
5578
  if (plugin.kind === "general") {
5446
5579
  const { ctx, registrations } = createPluginContext();
@@ -5468,7 +5601,29 @@ var PluginManager = class {
5468
5601
  }
5469
5602
  this.#aggregated.injected.push(...r.injected);
5470
5603
  }
5604
+ /**
5605
+ * #68 — inverse of #merge: remove a prior registration's contributions from
5606
+ * the aggregated view by object identity. Used by `register()` to replace a
5607
+ * same-named plugin's hooks/tools instead of accumulating duplicates.
5608
+ */
5609
+ #unmerge(r) {
5610
+ removeAll(this.#aggregated.tools, r.tools);
5611
+ removeAll(this.#aggregated.commands, r.commands);
5612
+ removeAll(this.#aggregated.injected, r.injected);
5613
+ for (const [hook, handlers] of r.hooks.entries()) {
5614
+ const existing = this.#aggregated.hooks.get(hook);
5615
+ if (existing === void 0) continue;
5616
+ removeAll(existing, handlers);
5617
+ if (existing.length === 0) this.#aggregated.hooks.delete(hook);
5618
+ }
5619
+ }
5471
5620
  };
5621
+ function removeAll(arr, toRemove) {
5622
+ for (const item of toRemove) {
5623
+ const idx = arr.indexOf(item);
5624
+ if (idx !== -1) arr.splice(idx, 1);
5625
+ }
5626
+ }
5472
5627
 
5473
5628
  // src/internal/telemetry/span-names.ts
5474
5629
  var SPAN_NAMES = {
@@ -5476,7 +5631,12 @@ var SPAN_NAMES = {
5476
5631
  AGENT_SEND: "agent.send",
5477
5632
  MEMORY_RECALL: "memory.recall"};
5478
5633
  var HISTOGRAM_NAMES = {
5479
- MEMORY_RECALL_DURATION_MS: "theokit_memory_recall_duration_ms"
5634
+ MEMORY_RECALL_DURATION_MS: "theokit_memory_recall_duration_ms",
5635
+ TOOL_CALL_DURATION_MS: "theokit_tool_call_duration_ms",
5636
+ LLM_CALL_DURATION_MS: "theokit_llm_call_duration_ms",
5637
+ LLM_TOKENS: "theokit_llm_tokens",
5638
+ /** M3 #66 — count of finishes where the provider omitted usage (silent undercount). */
5639
+ LLM_USAGE_MISSING: "theokit_llm_usage_missing"
5480
5640
  };
5481
5641
  function safeRequire(moduleName) {
5482
5642
  try {
@@ -5811,7 +5971,25 @@ function createTelemetry(settings) {
5811
5971
  enabled: true,
5812
5972
  includeContent: settings.includeContent === true,
5813
5973
  startSpan: startNewSpan,
5814
- startChildSpan: (_parent, name, attrs) => startNewSpan(name, attrs),
5974
+ // M3 #64 actually nest the child under its parent instead of discarding it.
5975
+ // The parent's SpanContext is set on a fresh OTel context so the child links
5976
+ // to it (traceId + parentSpanId), reconstructing the causal trace tree. Falls
5977
+ // back to a root span when the parent has no valid span id (telemetry off /
5978
+ // NOOP), preserving the pre-M3 behavior for parentless callers.
5979
+ startChildSpan: (parent, name, attrs) => {
5980
+ const redactedAttrs = attrs === void 0 ? void 0 : redactAttrs(attrs);
5981
+ const opts = redactedAttrs ? { attributes: redactedAttrs } : void 0;
5982
+ const pctx = safe(() => parent?.spanContext(), void 0);
5983
+ const span = safe(() => {
5984
+ if (pctx !== void 0 && pctx.spanId !== "0".repeat(16)) {
5985
+ const childCtx = otel.trace.setSpanContext(otel.context.active(), pctx);
5986
+ return tracer.startSpan(name, opts, childCtx);
5987
+ }
5988
+ return tracer.startSpan(name, opts);
5989
+ }, NOOP_SPAN);
5990
+ if (span !== NOOP_SPAN) openSpans.add(span);
5991
+ return wrapSpan(span, openSpans);
5992
+ },
5815
5993
  recordHistogram,
5816
5994
  endAll: () => {
5817
5995
  for (const span of openSpans) safe(() => span.end(), void 0);
@@ -5847,12 +6025,62 @@ function redactAttrs(attrs) {
5847
6025
  }
5848
6026
  return out;
5849
6027
  }
6028
+
6029
+ // src/internal/runtime/lifecycle/env-policy.ts
6030
+ var SECRET_PATTERNS = [
6031
+ /KEY/i,
6032
+ /SECRET/i,
6033
+ /TOKEN/i,
6034
+ /PASSWORD/i,
6035
+ /PASSWD/i,
6036
+ /PASSPHRASE/i,
6037
+ /[_-]PWD/i,
6038
+ /CREDENTIAL/i,
6039
+ /PRIVATE/i,
6040
+ /_AUTH/i
6041
+ ];
6042
+ var CORE_VARS = [
6043
+ "PATH",
6044
+ "HOME",
6045
+ "SHELL",
6046
+ "LANG",
6047
+ "LC_ALL",
6048
+ "LC_CTYPE",
6049
+ "TMPDIR",
6050
+ "TMP",
6051
+ "TEMP",
6052
+ "USER",
6053
+ "LOGNAME"
6054
+ ];
6055
+ function isSecretName(name) {
6056
+ return SECRET_PATTERNS.some((re) => re.test(name));
6057
+ }
6058
+ function inheritsUnderPolicy(name, policy) {
6059
+ if (policy === "all") return true;
6060
+ if (policy === "core") return CORE_VARS.includes(name);
6061
+ return !isSecretName(name);
6062
+ }
6063
+ function resolveChildEnv(options = {}) {
6064
+ const parent = options.parent ?? process.env;
6065
+ const policy = options.policy ?? "inherit-scrubbed";
6066
+ const base = {};
6067
+ for (const [name, value] of Object.entries(parent)) {
6068
+ if (value !== void 0 && inheritsUnderPolicy(name, policy)) base[name] = value;
6069
+ }
6070
+ for (const [name, value] of Object.entries(options.overrides ?? {})) {
6071
+ base[name] = value;
6072
+ }
6073
+ return base;
6074
+ }
6075
+
6076
+ // src/internal/runtime/lifecycle/spawn-collect.ts
5850
6077
  function spawnAndCollect(options) {
5851
6078
  return new Promise((resolve3) => {
5852
6079
  const timeoutMs = options.timeoutMs ?? 3e4;
5853
6080
  const spawnOptions = {
5854
6081
  cwd: options.cwd,
5855
- env: { ...process.env, ...options.env ?? {} }
6082
+ // #54 — scrub secret-like parent env by default; `options.env` still wins.
6083
+ env: resolveChildEnv({ policy: options.envPolicy, overrides: options.env })
5856
6084
  };
5857
6085
  const child = spawn(options.command, options.args ?? [], spawnOptions);
5858
6086
  let stdout = "";
@@ -6070,15 +6298,24 @@ function warnMalformed(agentId, line) {
6070
6298
  `
6071
6299
  );
6072
6300
  }
6301
+ function hydrateSessionLine(parsed) {
6302
+ if (typeof parsed.text !== "string" || parsed.role === void 0) return void 0;
6303
+ if (parsed.role === "user" || parsed.role === "assistant") {
6304
+ return { role: parsed.role, text: parsed.text };
6305
+ }
6306
+ if (parsed.role === "tool_call" || parsed.role === "tool_result") {
6307
+ const label = parsed.role === "tool_call" ? "tool call" : "tool result";
6308
+ return { role: "assistant", text: `[${label}] ${parsed.text}` };
6309
+ }
6310
+ return void 0;
6311
+ }
6073
6312
  async function readSessionFile(cwd, agentId) {
6074
6313
  const lines = await readJsonlLines(cwd, agentId);
6075
6314
  const messages = [];
6076
6315
  for (const line of lines) {
6077
6316
  try {
6078
- const parsed = JSON.parse(line);
6079
- if ((parsed.role === "user" || parsed.role === "assistant") && typeof parsed.text === "string") {
6080
- messages.push({ role: parsed.role, text: parsed.text });
6081
- }
6317
+ const msg = hydrateSessionLine(JSON.parse(line));
6318
+ if (msg !== void 0) messages.push(msg);
6082
6319
  } catch {
6083
6320
  warnMalformed(agentId, line);
6084
6321
  }
@@ -6105,24 +6342,72 @@ async function readAllPersistedMessages(cwd, agentId) {
6105
6342
  return messages;
6106
6343
  }
6107
6344
  async function appendAnyPersistedMessage(cwd, agentId, record) {
6108
- const path = sessionFilePath(cwd, agentId);
6109
- await mkdir(dirname(path), { recursive: true });
6110
- await appendFile(path, `${redactSecrets(JSON.stringify(record))}
6111
- `, "utf8");
6345
+ await appendPersistedMessages(cwd, agentId, [record]);
6112
6346
  }
6113
- async function compactSessionFile(cwd, agentId, maxTurns) {
6347
+ async function appendPersistedMessages(cwd, agentId, records) {
6348
+ if (records.length === 0) return;
6114
6349
  const path = sessionFilePath(cwd, agentId);
6115
- let raw;
6350
+ const payload = records.map((r) => `${redactSecrets(JSON.stringify(r))}
6351
+ `).join("");
6352
+ const dir = dirname(path);
6353
+ let written = false;
6354
+ const attempt = async () => {
6355
+ await mkdir(dir, { recursive: true });
6356
+ await withFileLock(path, async () => {
6357
+ await appendFile(path, payload, "utf8");
6358
+ written = true;
6359
+ });
6360
+ };
6116
6361
  try {
6117
- raw = await readFile(path, "utf8");
6118
- } catch {
6119
- return;
6362
+ await attempt();
6363
+ } catch (cause) {
6364
+ if (written || cause.code !== "ENOENT") throw cause;
6365
+ await attempt();
6120
6366
  }
6121
- const lines = raw.split("\n").filter((line) => line.length > 0);
6122
- if (lines.length <= maxTurns * 2) return;
6123
- const trimmed = `${lines.slice(-maxTurns).join("\n")}
6367
+ }
6368
+ async function rewriteLockedSession(path, transform) {
6369
+ await withFileLock(path, async () => {
6370
+ let raw;
6371
+ try {
6372
+ raw = await readFile(path, "utf8");
6373
+ } catch {
6374
+ return;
6375
+ }
6376
+ const lines = raw.split("\n").filter((line) => line.length > 0);
6377
+ const next = transform(lines);
6378
+ if (next === void 0) return;
6379
+ await replaceFileAtomic(path, next);
6380
+ });
6381
+ }
6382
+ async function compactSessionFile(cwd, agentId, maxTurns) {
6383
+ const path = sessionFilePath(cwd, agentId);
6384
+ if (!existsSync(path)) return;
6385
+ await rewriteLockedSession(
6386
+ path,
6387
+ (lines) => lines.length <= maxTurns * 2 ? void 0 : `${lines.slice(-maxTurns).join("\n")}
6388
+ `
6389
+ );
6390
+ }
6391
+ async function truncateSessionTo(cwd, agentId, keepCount) {
6392
+ const path = sessionFilePath(cwd, agentId);
6393
+ if (!existsSync(path)) return 0;
6394
+ let kept = 0;
6395
+ await rewriteLockedSession(path, (lines) => {
6396
+ const keep = Math.max(0, Math.min(keepCount, lines.length));
6397
+ kept = keep;
6398
+ if (keep === lines.length) return void 0;
6399
+ return keep === 0 ? "" : `${lines.slice(0, keep).join("\n")}
6124
6400
  `;
6125
- await replaceFileAtomic(path, trimmed);
6401
+ });
6402
+ return kept;
6403
+ }
6404
+
6405
+ // src/internal/persistence/pagination.ts
6406
+ function paginate(items, opts) {
6407
+ if (opts === void 0 || opts.offset === void 0 && opts.limit === void 0) return items;
6408
+ const start = Math.max(0, opts.offset ?? 0);
6409
+ const end = opts.limit === void 0 ? items.length : start + Math.max(0, opts.limit);
6410
+ return items.slice(start, end);
6126
6411
  }
6127
6412
 
6128
6413
  // src/internal/persistence/conversation-storage-fs.ts
@@ -6135,23 +6420,31 @@ var FileSystemConversationStorage = class {
6135
6420
  get root() {
6136
6421
  return this.#root;
6137
6422
  }
6138
- async getMessages(conversationId) {
6423
+ async getMessages(conversationId, opts) {
6139
6424
  const records = await readAllPersistedMessages(this.#root, conversationId);
6140
- return records.map(toStoredMessage);
6425
+ const all = records.map(toStoredMessage);
6426
+ return paginate(all, opts);
6141
6427
  }
6142
6428
  async appendMessage(conversationId, message) {
6143
- const record = {
6144
- role: message.role,
6145
- text: message.content,
6146
- at: message.at ?? Date.now()
6147
- };
6148
- await appendAnyPersistedMessage(this.#root, conversationId, record);
6429
+ await appendAnyPersistedMessage(this.#root, conversationId, toRecord(message));
6430
+ }
6431
+ async appendMessages(conversationId, messages) {
6432
+ await appendPersistedMessages(this.#root, conversationId, messages.map(toRecord));
6433
+ }
6434
+ async truncateConversation(conversationId, keepCount) {
6435
+ return truncateSessionTo(this.#root, conversationId, keepCount);
6149
6436
  }
6150
6437
  async deleteConversation(conversationId) {
6151
6438
  const safe2 = sanitizeIdentifier(conversationId, { maxLen: 128 });
6152
6439
  const dirPath = safePathJoin(this.#root, ".theokit", "agents", safe2);
6153
6440
  await rm(dirPath, { recursive: true, force: true });
6154
6441
  }
6442
+ async deleteScope(prefix) {
6443
+ const ids = await this.listConversationIds();
6444
+ const matching = ids.filter((id) => id.startsWith(prefix));
6445
+ for (const id of matching) await this.deleteConversation(id);
6446
+ return matching.length;
6447
+ }
6155
6448
  async listConversationIds(opts = {}) {
6156
6449
  const agentsRoot = safePathJoin(this.#root, ".theokit", "agents");
6157
6450
  let entries;
@@ -6177,6 +6470,9 @@ function toStoredMessage(record) {
6177
6470
  at: record.at
6178
6471
  };
6179
6472
  }
6473
+ function toRecord(message) {
6474
+ return { role: message.role, text: message.content, at: message.at ?? Date.now() };
6475
+ }
6180
6476
 
6181
6477
  // src/internal/runtime/session/agent-session.ts
6182
6478
  var DEFAULT_MAX_TURNS = 200;
@@ -6262,12 +6558,19 @@ async function readPersistedForCache(adapter, agentId) {
6262
6558
  const records = await adapter.getMessages(agentId);
6263
6559
  const out = [];
6264
6560
  for (const r of records) {
6265
- if (r.role === "user" || r.role === "assistant") {
6266
- out.push({ role: r.role, text: r.content });
6267
- }
6561
+ const folded = foldStoredToSession(r);
6562
+ if (folded !== void 0) out.push(folded);
6268
6563
  }
6269
6564
  return out;
6270
6565
  }
6566
+ function foldStoredToSession(r) {
6567
+ if (r.role === "user" || r.role === "assistant") return { role: r.role, text: r.content };
6568
+ if (r.role === "tool_call" || r.role === "tool_result") {
6569
+ const label = r.role === "tool_call" ? "tool call" : "tool result";
6570
+ return { role: "assistant", text: `[${label}] ${r.content}` };
6571
+ }
6572
+ return void 0;
6573
+ }
6271
6574
  async function flushSessionWrites() {
6272
6575
  while (pendingAppends.size > 0) {
6273
6576
  const all = Array.from(pendingAppends.values());
@@ -8617,11 +8920,32 @@ function reasoningEffortFromParams(params) {
8617
8920
  const thinking = params?.find((p) => p.id === "thinking");
8618
8921
  return thinking !== void 0 && thinking.value.length > 0 ? thinking.value : void 0;
8619
8922
  }
8923
+ function emitLlmMetrics(inputs, result, startAt) {
8924
+ inputs.telemetry?.recordHistogram(HISTOGRAM_NAMES.LLM_CALL_DURATION_MS, Date.now() - startAt, {
8925
+ provider: inputs.llm.name
8926
+ });
8927
+ if (result.inputTokens === void 0 && result.outputTokens === void 0) {
8928
+ inputs.telemetry?.recordHistogram(HISTOGRAM_NAMES.LLM_USAGE_MISSING, 1, {
8929
+ provider: inputs.llm.name
8930
+ });
8931
+ process.stderr.write(
8932
+ `[theokit-sdk] llm usage missing from ${inputs.llm.name} finish \u2014 budget may undercount
8933
+ `
8934
+ );
8935
+ return;
8936
+ }
8937
+ inputs.telemetry?.recordHistogram(
8938
+ HISTOGRAM_NAMES.LLM_TOKENS,
8939
+ (result.inputTokens ?? 0) + (result.outputTokens ?? 0),
8940
+ { provider: inputs.llm.name }
8941
+ );
8942
+ }
8620
8943
  async function streamLlmTurn(inputs, ctx) {
8621
- const llmSpan = inputs.telemetry?.startSpan("llm.call", {
8944
+ const llmSpan = inputs.telemetry?.startChildSpan(ctx.sendSpan, "llm.call", {
8622
8945
  "model.id": inputs.model.id ?? "auto",
8623
8946
  provider: inputs.llm.name
8624
8947
  });
8948
+ const startAt = Date.now();
8625
8949
  const signal = inputs.signal ?? new AbortController().signal;
8626
8950
  const generator = inputs.llm.stream(
8627
8951
  {
@@ -8664,6 +8988,7 @@ async function streamLlmTurn(inputs, ctx) {
8664
8988
  inputTokens: result.inputTokens ?? 0,
8665
8989
  outputTokens: result.outputTokens ?? 0
8666
8990
  });
8991
+ emitLlmMetrics(inputs, result, startAt);
8667
8992
  llmSpan?.end();
8668
8993
  const stripped = stripThinkBlocks(collected.accumulatedText);
8669
8994
  return {
@@ -8893,21 +9218,21 @@ async function executeTool(inputs, resolved, call) {
8893
9218
  }
8894
9219
  if (resolved.origin === "shell") return runShellTool(inputs, call);
8895
9220
  if (resolved.origin === "memory") return runMemoryTool(resolved, call);
8896
- if (resolved.origin === "custom") return runCustomTool(resolved, call);
9221
+ if (resolved.origin === "custom") return runCustomTool(resolved, call, inputs.signal);
8897
9222
  return runMcpTool(inputs, resolved, call);
8898
9223
  }
8899
9224
  async function runMemoryTool(resolved, call) {
8900
9225
  return runHandlerTool("memory", resolved.memoryHandler, call);
8901
9226
  }
8902
- async function runCustomTool(resolved, call) {
8903
- return runHandlerTool("custom", resolved.customHandler, call);
9227
+ async function runCustomTool(resolved, call, signal) {
9228
+ return runHandlerTool("custom", resolved.customHandler, call, signal);
8904
9229
  }
8905
- async function runHandlerTool(kind, handler, call) {
9230
+ async function runHandlerTool(kind, handler, call, signal) {
8906
9231
  if (handler === void 0) {
8907
9232
  return { stdout: "", stderr: `${kind} tool ${call.name} has no handler`, exitCode: 127 };
8908
9233
  }
8909
9234
  try {
8910
- const stdout = await handler(call.input);
9235
+ const stdout = await handler(call.input, { signal });
8911
9236
  return { stdout, stderr: "", exitCode: 0 };
8912
9237
  } catch (cause) {
8913
9238
  const message = cause instanceof Error ? cause.message : String(cause);
@@ -8953,22 +9278,66 @@ ${result.stderr}`.trim();
8953
9278
  return result.stdout.trim();
8954
9279
  }
8955
9280
 
9281
+ // src/internal/agent-loop/tool-timeout.ts
9282
+ var TOOL_ABORTED_EXIT = 124;
9283
+ function abortedResult(signal) {
9284
+ const reason = signal.reason;
9285
+ const timedOut = reason?.name === "TimeoutError";
9286
+ return {
9287
+ stdout: "",
9288
+ stderr: timedOut ? "tool execution timed out" : "tool execution aborted",
9289
+ exitCode: TOOL_ABORTED_EXIT
9290
+ };
9291
+ }
9292
+ function raceToolExecution(exec, opts) {
9293
+ const { signal, timeoutMs } = opts;
9294
+ if (signal === void 0 && timeoutMs === void 0) return exec;
9295
+ const signals = [];
9296
+ if (signal !== void 0) signals.push(signal);
9297
+ if (timeoutMs !== void 0) signals.push(AbortSignal.timeout(timeoutMs));
9298
+ const merged = signals.length === 1 ? signals[0] : AbortSignal.any(signals);
9299
+ if (merged.aborted) return Promise.resolve(abortedResult(merged));
9300
+ return new Promise((resolve3, reject) => {
9301
+ let settled = false;
9302
+ const onAbort = () => {
9303
+ if (settled) return;
9304
+ settled = true;
9305
+ resolve3(abortedResult(merged));
9306
+ };
9307
+ merged.addEventListener("abort", onAbort, { once: true });
9308
+ exec.then(
9309
+ (r) => {
9310
+ if (settled) return;
9311
+ settled = true;
9312
+ merged.removeEventListener("abort", onAbort);
9313
+ resolve3(r);
9314
+ },
9315
+ (e) => {
9316
+ if (settled) return;
9317
+ settled = true;
9318
+ merged.removeEventListener("abort", onAbort);
9319
+ reject(e);
9320
+ }
9321
+ );
9322
+ });
9323
+ }
9324
+
8956
9325
  // src/internal/agent-loop/tool-dispatch.ts
8957
- async function dispatchTools(inputs, tools, toolCalls, events) {
9326
+ async function dispatchTools(inputs, tools, toolCalls, events, parentSpan) {
8958
9327
  const maxConcurrent = inputs.maxConcurrentTools ?? 4;
8959
9328
  return mapWithConcurrency(
8960
9329
  toolCalls,
8961
9330
  maxConcurrent,
8962
- (call) => dispatchSingleCall(inputs, tools, call, events)
9331
+ (call) => dispatchSingleCall(inputs, tools, call, events, parentSpan)
8963
9332
  );
8964
9333
  }
8965
- async function dispatchSingleCall(inputs, tools, call, events) {
9334
+ async function dispatchSingleCall(inputs, tools, call, events, parentSpan) {
8966
9335
  const { call: workingCall, repairs } = applyRepairAndExtractCall(tools, call);
8967
9336
  const callId = generateCallId();
8968
9337
  const forkVeto = vetoFromForkWhitelist(inputs, workingCall, callId, events);
8969
9338
  if (forkVeto !== void 0) return forkVeto;
8970
9339
  const resolved = tools.find((tool) => tool.name === workingCall.name);
8971
- const toolSpan = startToolCallSpan(inputs, workingCall, resolved, callId, repairs);
9340
+ const toolSpan = startToolCallSpan(inputs, workingCall, resolved, callId, repairs, parentSpan);
8972
9341
  events.push(buildToolUseRunning(inputs, callId, workingCall));
8973
9342
  const pluginVeto = await vetoFromPluginPreHook(inputs, workingCall, callId, events);
8974
9343
  if (pluginVeto !== void 0) {
@@ -8985,6 +9354,13 @@ async function dispatchSingleCall(inputs, tools, call, events) {
8985
9354
  return fileVeto;
8986
9355
  }
8987
9356
  const result = await runToolWithLifecycle(inputs, resolved, workingCall, callId);
9357
+ await inputs.pluginManager?.runPostToolCallHooks({
9358
+ name: workingCall.name,
9359
+ args: workingCall.input,
9360
+ result: { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode },
9361
+ agentId: inputs.agentId,
9362
+ runId: inputs.runId
9363
+ });
8988
9364
  return finalizeSpanAndPostHook(inputs, workingCall, callId, result, events, toolSpan);
8989
9365
  }
8990
9366
  function applyRepairAndExtractCall(tools, call) {
@@ -9017,8 +9393,8 @@ function vetoFromForkWhitelist(inputs, call, callId, events) {
9017
9393
  content: `Tool blocked by fork whitelist: ${whitelistDecision.reason}`
9018
9394
  };
9019
9395
  }
9020
- function startToolCallSpan(inputs, call, resolved, callId, repairs) {
9021
- const toolSpan = inputs.telemetry?.startSpan("tool.call", {
9396
+ function startToolCallSpan(inputs, call, resolved, callId, repairs, parentSpan) {
9397
+ const toolSpan = inputs.telemetry?.startChildSpan(parentSpan, "tool.call", {
9022
9398
  "tool.name": call.name,
9023
9399
  "tool.origin": resolved?.origin ?? "unknown",
9024
9400
  callId
@@ -9082,8 +9458,14 @@ async function runToolWithLifecycle(inputs, resolved, call, callId) {
9082
9458
  conversationId: inputs.agentId,
9083
9459
  callId
9084
9460
  });
9085
- const result = await executeTool(inputs, resolved, call);
9461
+ const result = await raceToolExecution(executeTool(inputs, resolved, call), {
9462
+ signal: inputs.signal,
9463
+ timeoutMs: inputs.perToolTimeoutMs
9464
+ });
9086
9465
  const durationMs = Date.now() - startAt;
9466
+ inputs.telemetry?.recordHistogram(HISTOGRAM_NAMES.TOOL_CALL_DURATION_MS, durationMs, {
9467
+ "tool.name": call.name
9468
+ });
9087
9469
  if (result.exitCode !== void 0 && result.exitCode !== 0 && result.exitCode !== null) {
9088
9470
  await safeEmitToolHook(inputs.onToolError, {
9089
9471
  toolName: call.name,
@@ -9182,6 +9564,35 @@ function buildToolUseCompleted(inputs, callId, call, result) {
9182
9564
  };
9183
9565
  }
9184
9566
 
9567
+ // src/internal/agent-loop/tool-result-guard.ts
9568
+ var OPEN = "<untrusted-tool-output>";
9569
+ var CLOSE = "</untrusted-tool-output>";
9570
+ var PII_PATTERNS = [
9571
+ /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
9572
+ // email
9573
+ /\b(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g
9574
+ // phone
9575
+ ];
9576
+ function guardText(content, opts) {
9577
+ let out = content;
9578
+ if (opts.redactPii === true) {
9579
+ for (const re of PII_PATTERNS) out = out.replace(re, "[REDACTED]");
9580
+ }
9581
+ if (opts.delimit === true) {
9582
+ const safe2 = out.split(CLOSE).join("</ untrusted-tool-output>");
9583
+ out = `${OPEN}
9584
+ ${safe2}
9585
+ ${CLOSE}`;
9586
+ }
9587
+ return out;
9588
+ }
9589
+ function applyToolResultGuard(parts, opts) {
9590
+ if (opts.delimit !== true && opts.redactPii !== true) return parts;
9591
+ return parts.map(
9592
+ (p) => p.type === "tool_result" ? { ...p, content: guardText(p.content, opts) } : p
9593
+ );
9594
+ }
9595
+
9185
9596
  // src/internal/budget/pricing-data.json
9186
9597
  var pricing_data_default = {
9187
9598
  _meta: {
@@ -9532,6 +9943,11 @@ async function runAgentLoop(inputs) {
9532
9943
  try {
9533
9944
  const ctx = await initLoopContext(inputs);
9534
9945
  ctxRef = ctx;
9946
+ ctx.sendSpan = sendSpan;
9947
+ await inputs.pluginManager?.runOnSessionStartHooks({
9948
+ agentId: inputs.agentId,
9949
+ runId: inputs.runId
9950
+ });
9535
9951
  const budget = inputs.budget ?? new IterationBudget({ maxIterations: inputs.maxIterations ?? 8 });
9536
9952
  let lastTurnDecision;
9537
9953
  while (budget.shouldContinue()) {
@@ -9559,6 +9975,7 @@ async function runAgentLoop(inputs) {
9559
9975
  }
9560
9976
  budget.consume();
9561
9977
  inputs.budgetTracker?.nextIteration?.();
9978
+ if (inputs.signal?.aborted === true) break;
9562
9979
  }
9563
9980
  if (lastTurnDecision === "continue" && budget.shouldContinue() === false) {
9564
9981
  ctx.stoppedAtIterationLimit = true;
@@ -9598,6 +10015,10 @@ async function runAgentLoop(inputs) {
9598
10015
  ...ctx.stoppedByDoomLoop === true ? { stoppedByDoomLoop: true } : {}
9599
10016
  };
9600
10017
  } finally {
10018
+ await inputs.pluginManager?.runOnSessionEndHooks({
10019
+ agentId: inputs.agentId,
10020
+ runId: inputs.runId
10021
+ });
9601
10022
  if (ctxRef !== void 0 && ctxRef.memoryProviderHandle !== void 0 && inputs.memoryProvider !== void 0) {
9602
10023
  try {
9603
10024
  await inputs.memoryProvider.dispose(ctxRef.memoryProviderHandle);
@@ -9701,7 +10122,10 @@ async function finishOrReflect(inputs, ctx, llmOutput) {
9701
10122
  return "done";
9702
10123
  }
9703
10124
  async function runIteration(inputs, ctx) {
10125
+ const hookCtx = { agentId: inputs.agentId, runId: inputs.runId };
10126
+ await inputs.pluginManager?.runPreLlmCallHooks(hookCtx);
9704
10127
  const llmOutput = await streamLlmTurn(inputs, ctx);
10128
+ await inputs.pluginManager?.runPostLlmCallHooks(hookCtx);
9705
10129
  accumulateUsage(ctx.usage, llmOutput);
9706
10130
  if (inputs.budgetTracker !== void 0) {
9707
10131
  const modelId = inputs.model.id ?? "auto";
@@ -9727,6 +10151,13 @@ async function runIteration(inputs, ctx) {
9727
10151
  }
9728
10152
  return continueOrTerminate(inputs, ctx, llmOutput);
9729
10153
  }
10154
+ async function transformLlmOutputText(inputs, text, ctx) {
10155
+ return inputs.pluginManager !== void 0 ? inputs.pluginManager.runTransformLlmOutputHooks(text, ctx) : text;
10156
+ }
10157
+ async function guardAndTransformToolResults(inputs, raw, ctx) {
10158
+ const guarded = inputs.toolResultGuard !== void 0 ? applyToolResultGuard(raw, inputs.toolResultGuard) : raw;
10159
+ return inputs.pluginManager !== void 0 ? inputs.pluginManager.runTransformToolResultHooks(guarded, ctx) : guarded;
10160
+ }
9730
10161
  async function continueOrTerminate(inputs, ctx, llmOutput) {
9731
10162
  if (llmOutput.errored) return "error";
9732
10163
  if (llmOutput.text.length > 0) {
@@ -9735,8 +10166,18 @@ async function continueOrTerminate(inputs, ctx, llmOutput) {
9735
10166
  if (llmOutput.stopReason !== "tool_use" || llmOutput.toolCalls.length === 0) {
9736
10167
  return finishOrReflect(inputs, ctx, llmOutput);
9737
10168
  }
9738
- ctx.messages.push(buildAssistantTurn(llmOutput.text, llmOutput.toolCalls));
9739
- const toolResults = await dispatchTools(inputs, ctx.tools, llmOutput.toolCalls, ctx.events);
10169
+ const tCtx = { agentId: inputs.agentId, runId: inputs.runId };
10170
+ const outText = await transformLlmOutputText(inputs, llmOutput.text, tCtx);
10171
+ ctx.messages.push(buildAssistantTurn(outText, llmOutput.toolCalls));
10172
+ const rawResults = await dispatchTools(
10173
+ inputs,
10174
+ ctx.tools,
10175
+ llmOutput.toolCalls,
10176
+ ctx.events,
10177
+ ctx.sendSpan
10178
+ // M3 #64 — nest tool.call spans under agent.send
10179
+ );
10180
+ const toolResults = await guardAndTransformToolResults(inputs, rawResults, tCtx);
9740
10181
  ctx.messages.push({ role: "user", content: toolResults });
9741
10182
  if (inputs.onStep !== void 0) {
9742
10183
  const cb = inputs.onStep;
@@ -10166,6 +10607,56 @@ function mapAnthropicStatusToCode(status, body) {
10166
10607
  function formatMessage(status, code) {
10167
10608
  return `Anthropic API error: ${code} (HTTP ${status})`;
10168
10609
  }
10610
+ var cachedJsonrepair;
10611
+ function loadJsonrepair() {
10612
+ if (cachedJsonrepair === void 0) {
10613
+ const req = createRequire(import.meta.url);
10614
+ cachedJsonrepair = req("jsonrepair").jsonrepair;
10615
+ }
10616
+ return cachedJsonrepair;
10617
+ }
10618
+ function isPlainObject(v) {
10619
+ return v !== null && typeof v === "object" && !Array.isArray(v);
10620
+ }
10621
+ function toFiniteNumber(raw) {
10622
+ if (raw === "") return void 0;
10623
+ const n = Number(raw);
10624
+ return Number.isFinite(n) && String(n) === raw ? n : void 0;
10625
+ }
10626
+ function tryJson(raw, repair) {
10627
+ const t = raw.trimStart();
10628
+ if (!(t.startsWith("{") || t.startsWith("["))) return void 0;
10629
+ try {
10630
+ return JSON.parse(repair ? loadJsonrepair()(t) : t);
10631
+ } catch {
10632
+ return void 0;
10633
+ }
10634
+ }
10635
+ function heuristicCoerce(raw, repairJson) {
10636
+ if (raw === "true") return true;
10637
+ if (raw === "false") return false;
10638
+ if (raw === "null") return null;
10639
+ const n = toFiniteNumber(raw);
10640
+ if (n !== void 0) return n;
10641
+ const json = tryJson(raw, false) ?? (repairJson ? tryJson(raw, true) : void 0);
10642
+ return json === void 0 ? raw : json;
10643
+ }
10644
+ function coerceCandidates(raw, repairJson) {
10645
+ const out = [];
10646
+ if (raw === "true") out.push(true);
10647
+ else if (raw === "false") out.push(false);
10648
+ else if (raw === "null") out.push(null);
10649
+ const n = toFiniteNumber(raw);
10650
+ if (n !== void 0) out.push(n);
10651
+ const json = tryJson(raw, false) ?? (repairJson ? tryJson(raw, true) : void 0);
10652
+ if (json !== void 0) out.push(json);
10653
+ out.push(raw);
10654
+ return out;
10655
+ }
10656
+ function objectShape(schema) {
10657
+ const shape = schema?.shape;
10658
+ return shape !== null && typeof shape === "object" ? shape : void 0;
10659
+ }
10169
10660
 
10170
10661
  // src/internal/llm/finish.ts
10171
10662
  function collapseSystemText(system) {
@@ -10178,9 +10669,21 @@ function parseToolArguments(buffered) {
10178
10669
  try {
10179
10670
  return JSON.parse(buffered);
10180
10671
  } catch {
10672
+ const repaired = tryJson(buffered, true);
10673
+ if (isPlainObject(repaired)) return repaired;
10181
10674
  return { raw: buffered };
10182
10675
  }
10183
10676
  }
10677
+ function mapOpenAIFinish(reason) {
10678
+ switch (reason) {
10679
+ case "tool_calls":
10680
+ return "tool_use";
10681
+ case "length":
10682
+ return "max_tokens";
10683
+ default:
10684
+ return "end_turn";
10685
+ }
10686
+ }
10184
10687
  function makeLlmFinish(state3) {
10185
10688
  const finish = {
10186
10689
  stopReason: state3.stopReason,
@@ -11062,56 +11565,9 @@ function toOllamaTools(tools) {
11062
11565
  }
11063
11566
  }));
11064
11567
  }
11065
- var cachedJsonrepair;
11066
- function loadJsonrepair() {
11067
- if (cachedJsonrepair === void 0) {
11068
- const req = createRequire(import.meta.url);
11069
- cachedJsonrepair = req("jsonrepair").jsonrepair;
11070
- }
11071
- return cachedJsonrepair;
11072
- }
11073
- function isPlainObject(v) {
11074
- return v !== null && typeof v === "object" && !Array.isArray(v);
11075
- }
11076
- function toFiniteNumber(raw) {
11077
- if (raw === "") return void 0;
11078
- const n = Number(raw);
11079
- return Number.isFinite(n) && String(n) === raw ? n : void 0;
11080
- }
11081
- function tryJson(raw, repair) {
11082
- const t = raw.trimStart();
11083
- if (!(t.startsWith("{") || t.startsWith("["))) return void 0;
11084
- try {
11085
- return JSON.parse(repair ? loadJsonrepair()(t) : t);
11086
- } catch {
11087
- return void 0;
11088
- }
11089
- }
11090
- function heuristicCoerce(raw, repairJson) {
11091
- if (raw === "true") return true;
11092
- if (raw === "false") return false;
11093
- if (raw === "null") return null;
11094
- const n = toFiniteNumber(raw);
11095
- if (n !== void 0) return n;
11096
- const json = tryJson(raw, false) ?? (repairJson ? tryJson(raw, true) : void 0);
11097
- return json === void 0 ? raw : json;
11098
- }
11099
- function coerceCandidates(raw, repairJson) {
11100
- const out = [];
11101
- if (raw === "true") out.push(true);
11102
- else if (raw === "false") out.push(false);
11103
- else if (raw === "null") out.push(null);
11104
- const n = toFiniteNumber(raw);
11105
- if (n !== void 0) out.push(n);
11106
- const json = tryJson(raw, false) ?? (repairJson ? tryJson(raw, true) : void 0);
11107
- if (json !== void 0) out.push(json);
11108
- out.push(raw);
11109
- return out;
11110
- }
11111
- function objectShape(schema) {
11112
- const shape = schema?.shape;
11113
- return shape !== null && typeof shape === "object" ? shape : void 0;
11114
- }
11568
+
11569
+ // src/internal/llm/openai.ts
11570
+ init_errors();
11115
11571
 
11116
11572
  // src/sanitize/sanitize-tool-input.ts
11117
11573
  function applyTrim(key, value, ctx) {
@@ -11208,6 +11664,77 @@ function parseHermesParams(inner) {
11208
11664
  }
11209
11665
  return sanitizeToolInput(input, { trim: true }).value;
11210
11666
  }
11667
+ var STREAM_MARKER = "<function=";
11668
+ var DEFAULT_STREAM_BUFFER_CAP = 8192;
11669
+ var isStreamWs = (c) => c === " " || c === " " || c === "\n" || c === "\r";
11670
+ function streamToolCallBufferState(held, allowedToolNames, cap = DEFAULT_STREAM_BUFFER_CAP) {
11671
+ if (allowedToolNames.size === 0) return "impossible";
11672
+ const t = held.trimStart();
11673
+ if (t.length < STREAM_MARKER.length) {
11674
+ return STREAM_MARKER.startsWith(t) ? "possible" : "impossible";
11675
+ }
11676
+ if (!t.startsWith(STREAM_MARKER)) return "impossible";
11677
+ const parsed = parseStreamMarkerName(t);
11678
+ if (parsed === "building") return "possible";
11679
+ if (parsed === "invalid") return "impossible";
11680
+ const nameOk = parsed.complete ? allowedToolNames.has(parsed.name) : someToolNameStartsWith(allowedToolNames, parsed.name);
11681
+ if (!nameOk) return "impossible";
11682
+ return held.length > cap ? "impossible" : "possible";
11683
+ }
11684
+ function parseStreamMarkerName(t) {
11685
+ let cursor = STREAM_MARKER.length;
11686
+ while (cursor < t.length && isStreamWs(t[cursor])) cursor += 1;
11687
+ const nameStart = cursor;
11688
+ while (cursor < t.length && t[cursor] !== ">" && !isStreamWs(t[cursor])) cursor += 1;
11689
+ const name = t.slice(nameStart, cursor);
11690
+ if (name.length === 0) return cursor >= t.length ? "building" : "invalid";
11691
+ return { name, complete: cursor < t.length && t[cursor] === ">" };
11692
+ }
11693
+ function someToolNameStartsWith(allowedToolNames, prefix) {
11694
+ for (const name of allowedToolNames) {
11695
+ if (name.startsWith(prefix)) return true;
11696
+ }
11697
+ return false;
11698
+ }
11699
+ function firstPossibleMarkerStart(held, allowedToolNames) {
11700
+ for (let i = held.indexOf("<"); i !== -1; i = held.indexOf("<", i + 1)) {
11701
+ if (streamToolCallBufferState(held.slice(i), allowedToolNames) === "possible") return i;
11702
+ }
11703
+ return -1;
11704
+ }
11705
+ var StreamSuppressionBuffer = class {
11706
+ constructor(allowedToolNames) {
11707
+ this.allowedToolNames = allowedToolNames;
11708
+ }
11709
+ allowedToolNames;
11710
+ #held = "";
11711
+ /** Feed a content delta; returns the text to emit as a `text_delta` now, or `undefined` to hold. */
11712
+ push(content) {
11713
+ this.#held += content;
11714
+ if (streamToolCallBufferState(this.#held, this.allowedToolNames) === "possible")
11715
+ return void 0;
11716
+ const holdStart = firstPossibleMarkerStart(this.#held, this.allowedToolNames);
11717
+ if (holdStart > 0) {
11718
+ const flush2 = this.#held.slice(0, holdStart);
11719
+ this.#held = this.#held.slice(holdStart);
11720
+ return flush2;
11721
+ }
11722
+ const flush = this.#held;
11723
+ this.#held = "";
11724
+ return flush;
11725
+ }
11726
+ /** Drain the held buffer at stream end. `hasNativeCalls` mirrors `finish()`'s size-guard: when
11727
+ * native `tool_calls` exist, `finish()` won't strip the leaked block, so stream the held text WHOLE
11728
+ * (keeping `accumulatedText == finish.text`); otherwise strip the recoverable blocks. Idempotent. */
11729
+ drain(hasNativeCalls) {
11730
+ if (this.#held.length === 0) return void 0;
11731
+ const held = this.#held;
11732
+ this.#held = "";
11733
+ if (hasNativeCalls) return held;
11734
+ const residual = extractHermesToolCalls(held, () => "held", this.allowedToolNames).residualText;
11735
+ return residual.length > 0 ? residual : void 0;
11736
+ }
11737
+ };
11211
11738
 
11212
11739
  // src/internal/llm/openai.ts
11213
11740
  var OpenAIClient = class {
@@ -11285,8 +11812,12 @@ var OpenAIClient = class {
11285
11812
  // model was actually given. Empty set (no tools) recovers nothing.
11286
11813
  new Set(request.tools?.map((tool) => tool.name) ?? [])
11287
11814
  );
11815
+ let sawDone = false;
11288
11816
  for await (const record of parseSseStream(response.body, signal)) {
11289
- if (record.data === "[DONE]") break;
11817
+ if (record.data === "[DONE]") {
11818
+ sawDone = true;
11819
+ break;
11820
+ }
11290
11821
  let chunk;
11291
11822
  try {
11292
11823
  chunk = JSON.parse(record.data);
@@ -11306,6 +11837,13 @@ var OpenAIClient = class {
11306
11837
  const events = accumulator.consume(chunk);
11307
11838
  for (const event of events) yield event;
11308
11839
  }
11840
+ if (!sawDone && !accumulator.finishReasonSeen) {
11841
+ throw new NetworkError("SSE stream truncated (no finish_reason / [DONE])", {
11842
+ code: "stream_truncated"
11843
+ });
11844
+ }
11845
+ const drainEvent = accumulator.finalizeHeldText();
11846
+ if (drainEvent !== void 0) yield drainEvent;
11309
11847
  return accumulator.finish();
11310
11848
  }
11311
11849
  };
@@ -11321,6 +11859,7 @@ var OpenAIStreamAccumulator = class {
11321
11859
  this.extractFromContent = extractFromContent;
11322
11860
  this.providerName = providerName;
11323
11861
  this.allowedToolNames = allowedToolNames;
11862
+ this.suppress = extractFromContent && allowedToolNames !== void 0 && allowedToolNames.size > 0 ? new StreamSuppressionBuffer(allowedToolNames) : void 0;
11324
11863
  }
11325
11864
  extractFromContent;
11326
11865
  providerName;
@@ -11333,18 +11872,30 @@ var OpenAIStreamAccumulator = class {
11333
11872
  cacheWriteTokens;
11334
11873
  reasoningTokens;
11335
11874
  toolCalls = /* @__PURE__ */ new Map();
11875
+ /** R7: present only when recovery is enabled AND the request declares tools — holds suspected
11876
+ * leaked-dialect content back from the `text_delta` stream. `undefined` ⇒ stream immediately. */
11877
+ suppress;
11336
11878
  consume(chunk) {
11337
11879
  const events = [];
11338
11880
  this.applyUsage(chunk.usage);
11339
11881
  for (const choice of chunk.choices ?? []) {
11340
- const reasoningEvent = this.applyReasoningDelta(
11341
- choice.delta?.reasoning ?? choice.delta?.reasoning_content
11342
- );
11343
- if (reasoningEvent !== void 0) events.push(reasoningEvent);
11344
- const textEvent = this.applyContentDelta(choice.delta?.content);
11345
- if (textEvent !== void 0) events.push(textEvent);
11346
- this.mergeToolCallDeltas(choice.delta?.tool_calls);
11347
- this.applyFinishReason(choice.finish_reason);
11882
+ events.push(...this.applyChoice(choice));
11883
+ }
11884
+ return events;
11885
+ }
11886
+ applyChoice(choice) {
11887
+ const events = [];
11888
+ const reasoningEvent = this.applyReasoningDelta(
11889
+ choice.delta?.reasoning ?? choice.delta?.reasoning_content
11890
+ );
11891
+ if (reasoningEvent !== void 0) events.push(reasoningEvent);
11892
+ const textEvent = this.applyContentDelta(choice.delta?.content);
11893
+ if (textEvent !== void 0) events.push(textEvent);
11894
+ this.mergeToolCallDeltas(choice.delta?.tool_calls);
11895
+ this.applyFinishReason(choice.finish_reason);
11896
+ if (choice.finish_reason !== void 0 && choice.finish_reason !== null) {
11897
+ const flushEvent = this.finalizeHeldText();
11898
+ if (flushEvent !== void 0) events.push(flushEvent);
11348
11899
  }
11349
11900
  return events;
11350
11901
  }
@@ -11369,7 +11920,17 @@ var OpenAIStreamAccumulator = class {
11369
11920
  applyContentDelta(content) {
11370
11921
  if (typeof content !== "string" || content.length === 0) return void 0;
11371
11922
  this.text += content;
11372
- return { type: "text_delta", text: content };
11923
+ if (this.suppress === void 0) return { type: "text_delta", text: content };
11924
+ const emit = this.suppress.push(content);
11925
+ return emit !== void 0 ? { type: "text_delta", text: emit } : void 0;
11926
+ }
11927
+ /** R7 held-buffer finalizer, called at the `finish_reason` chunk (in `applyChoice`) AND after the
11928
+ * SSE loop in `stream()` — so a stream that omits a `finish_reason` terminal never silently drops
11929
+ * held text. `toolCalls.size > 0` (native calls present) makes `finish()` skip recovery, so the
11930
+ * buffer streams the held text whole. Idempotent once drained. */
11931
+ finalizeHeldText() {
11932
+ const emit = this.suppress?.drain(this.toolCalls.size > 0);
11933
+ return emit !== void 0 ? { type: "text_delta", text: emit } : void 0;
11373
11934
  }
11374
11935
  mergeToolCallDeltas(deltas) {
11375
11936
  for (const call of deltas ?? []) {
@@ -11380,8 +11941,15 @@ var OpenAIStreamAccumulator = class {
11380
11941
  this.toolCalls.set(call.index, existing);
11381
11942
  }
11382
11943
  }
11944
+ /** M2 #61 — true once any chunk carried a non-null `finish_reason` (else a
11945
+ * stream ending without `[DONE]` is a truncation, not a clean end). */
11946
+ sawFinishReason = false;
11947
+ get finishReasonSeen() {
11948
+ return this.sawFinishReason;
11949
+ }
11383
11950
  applyFinishReason(reason) {
11384
11951
  if (reason === void 0 || reason === null) return;
11952
+ this.sawFinishReason = true;
11385
11953
  this.stopReason = mapOpenAIFinish(reason);
11386
11954
  }
11387
11955
  finish() {
@@ -11426,18 +11994,6 @@ var OpenAIStreamAccumulator = class {
11426
11994
  });
11427
11995
  }
11428
11996
  };
11429
- function mapOpenAIFinish(reason) {
11430
- switch (reason) {
11431
- case "tool_calls":
11432
- return "tool_use";
11433
- case "length":
11434
- return "max_tokens";
11435
- case "stop":
11436
- return "end_turn";
11437
- default:
11438
- return "end_turn";
11439
- }
11440
- }
11441
11997
  function applyReasoningRequest(body, effort, providerName) {
11442
11998
  if (providerName === "openai") {
11443
11999
  body.reasoning_effort = effort;
@@ -11535,19 +12091,76 @@ function assistantMessage(message) {
11535
12091
 
11536
12092
  // src/internal/llm/pool-aware-client.ts
11537
12093
  init_errors();
12094
+
12095
+ // src/internal/resilience/circuit-breaker.ts
12096
+ var DEFAULT_MAX_TIMEOUTS = 3;
12097
+ var DEFAULT_COOLDOWN_MS2 = 6e4;
12098
+ var CircuitBreaker = class {
12099
+ constructor(opts = {}) {
12100
+ this.opts = opts;
12101
+ }
12102
+ opts;
12103
+ states = /* @__PURE__ */ new Map();
12104
+ /** @returns true when the breaker is open and the call should be skipped. */
12105
+ shouldSkip(key) {
12106
+ const state3 = this.states.get(key);
12107
+ if (state3 === void 0) return false;
12108
+ if (state3.cooldownUntilMs === 0) return false;
12109
+ if (this.now() < state3.cooldownUntilMs) return true;
12110
+ state3.cooldownUntilMs = 0;
12111
+ state3.consecutiveTimeouts = 0;
12112
+ return false;
12113
+ }
12114
+ recordSuccess(key) {
12115
+ const state3 = this.states.get(key);
12116
+ if (state3 === void 0) return;
12117
+ state3.consecutiveTimeouts = 0;
12118
+ state3.cooldownUntilMs = 0;
12119
+ }
12120
+ recordTimeout(key) {
12121
+ const state3 = this.states.get(key) ?? { consecutiveTimeouts: 0, cooldownUntilMs: 0 };
12122
+ state3.consecutiveTimeouts += 1;
12123
+ if (state3.consecutiveTimeouts >= (this.opts.maxTimeouts ?? DEFAULT_MAX_TIMEOUTS)) {
12124
+ state3.cooldownUntilMs = this.now() + (this.opts.cooldownMs ?? DEFAULT_COOLDOWN_MS2);
12125
+ }
12126
+ this.states.set(key, state3);
12127
+ }
12128
+ /** @internal — tests inspect counter state. */
12129
+ inspect(key) {
12130
+ return this.states.get(key) ?? { consecutiveTimeouts: 0, cooldownUntilMs: 0 };
12131
+ }
12132
+ now() {
12133
+ return this.opts.now?.() ?? Date.now();
12134
+ }
12135
+ };
12136
+
12137
+ // src/internal/llm/pool-aware-client.ts
12138
+ init_retry();
11538
12139
  var PoolAwareLlmClient = class {
11539
- constructor(pool, buildClient2, waitForAvailableMs = 3e4) {
12140
+ constructor(pool, buildClient2, waitForAvailableMs = 3e4, resilience = {}) {
11540
12141
  this.pool = pool;
11541
12142
  this.buildClient = buildClient2;
11542
12143
  this.waitForAvailableMs = waitForAvailableMs;
11543
12144
  this.name = `pool-aware:${pool.provider}`;
12145
+ this.breaker = resilience.breaker ?? new CircuitBreaker();
12146
+ this.backoffBaseMs = resilience.backoffBaseMs;
12147
+ this.rng = resilience.rng;
11544
12148
  }
11545
12149
  pool;
11546
12150
  buildClient;
11547
12151
  waitForAvailableMs;
11548
12152
  name;
12153
+ /** M2 #60 — provider-level circuit breaker (consecutive-failure). */
12154
+ breaker;
12155
+ backoffBaseMs;
12156
+ rng;
11549
12157
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: stream() must serialize pool-select → build client → first-event probe → classify → retry/rotate/propagate. Extracting helpers fragments the linear narrative; the comments above each branch keep it readable.
11550
12158
  async *stream(request, signal) {
12159
+ if (this.breaker.shouldSkip(this.pool.provider)) {
12160
+ throw new NetworkError(`${this.pool.provider} circuit open \u2014 failing fast`, {
12161
+ code: "circuit_open"
12162
+ });
12163
+ }
11551
12164
  let hasRetried429 = false;
11552
12165
  while (true) {
11553
12166
  if (signal.aborted) throw abortError2(signal);
@@ -11562,6 +12175,7 @@ var PoolAwareLlmClient = class {
11562
12175
  }
11563
12176
  }
11564
12177
  if (entry === null) {
12178
+ this.breaker.recordTimeout(this.pool.provider);
11565
12179
  throw new CredentialPoolExhaustedError(
11566
12180
  `All ${this.pool.provider} credentials exhausted; next retry available at ${this.nextRetryHint() ?? "unknown"}`,
11567
12181
  { provider: this.pool.provider, nextRetryAt: this.nextRetryHint() }
@@ -11571,10 +12185,19 @@ var PoolAwareLlmClient = class {
11571
12185
  const realClient = this.buildClient(entry.accessToken);
11572
12186
  const attempt = await tryFirstEvent(realClient, request, signal);
11573
12187
  if (attempt.kind === "ok") {
12188
+ this.breaker.recordSuccess(this.pool.provider);
11574
12189
  return yield* relayStream(attempt.generator, attempt.firstResult);
11575
12190
  }
11576
12191
  const decision = classifyAndDecide(attempt.error, hasRetried429);
11577
12192
  if (decision === "retry") {
12193
+ await sleepWithAbort(
12194
+ computeBackoffMs({
12195
+ attempt: 0,
12196
+ ...this.backoffBaseMs !== void 0 ? { baseMs: this.backoffBaseMs } : {},
12197
+ ...this.rng !== void 0 ? { rng: this.rng } : {}
12198
+ }),
12199
+ signal
12200
+ );
11578
12201
  hasRetried429 = true;
11579
12202
  continue;
11580
12203
  }
@@ -11594,6 +12217,7 @@ var PoolAwareLlmClient = class {
11594
12217
  hasRetried429 = false;
11595
12218
  continue;
11596
12219
  }
12220
+ this.breaker.recordTimeout(this.pool.provider);
11597
12221
  throw attempt.error;
11598
12222
  }
11599
12223
  }
@@ -12028,9 +12652,28 @@ function selectTransport(profile, apiKey) {
12028
12652
 
12029
12653
  // src/internal/mcp/client.ts
12030
12654
  init_errors();
12031
- function createMcpClient(name, config) {
12655
+ function createMcpClient(name, config, fetchImpl = fetch) {
12032
12656
  if (isStdio(config)) return new StdioMcpClient(name, config);
12033
- return new HttpMcpClient(name, config);
12657
+ return new HttpMcpClient(name, config, fetchImpl);
12658
+ }
12659
+ var DEFAULT_MCP_TIMEOUT_MS = 3e4;
12660
+ var MAX_STDIO_BUFFER_BYTES = 8 * 1024 * 1024;
12661
+ var RECONNECT_BASE_MS = 250;
12662
+ var MAX_RECONNECT_ATTEMPTS = 2;
12663
+ function reconnectDelay(attempt) {
12664
+ const ceiling = RECONNECT_BASE_MS * 2 ** attempt;
12665
+ const ms = Math.floor(Math.random() * ceiling);
12666
+ return ms <= 0 ? Promise.resolve() : new Promise((resolve3) => setTimeout(resolve3, ms));
12667
+ }
12668
+ function mcpTimeoutError(name, timeoutMs) {
12669
+ return new NetworkError(`MCP ${name} request timed out after ${timeoutMs}ms`, {
12670
+ code: "mcp_timeout"
12671
+ });
12672
+ }
12673
+ function isAbortLike(cause) {
12674
+ if (typeof cause !== "object" || cause === null || !("name" in cause)) return false;
12675
+ const name = cause.name;
12676
+ return name === "TimeoutError" || name === "AbortError";
12034
12677
  }
12035
12678
  async function rpcInitialize(request) {
12036
12679
  await request("initialize", {
@@ -12076,32 +12719,107 @@ var StdioMcpClient = class extends BaseMcpClient {
12076
12719
  name;
12077
12720
  child;
12078
12721
  nextId = 1;
12722
+ // #59 — pending requests carry a reject + timer so a silent server times out
12723
+ // (typed error), a late reply after timeout is a no-op, and close() settles them.
12079
12724
  pending = /* @__PURE__ */ new Map();
12080
12725
  buffer = "";
12081
- async initialize() {
12726
+ // M2 #59 — reconnect-after-drop state. `dropped` is set when the child exits
12727
+ // unexpectedly OR times out (not via close()); the next request re-spawns with
12728
+ // backoff. `reconnectPromise` is a SINGLE in-flight reconnect shared by every
12729
+ // concurrent request so parallel tool dispatch after a drop awaits one handshake
12730
+ // instead of racing (or spuriously failing with mcp_not_init).
12731
+ dropped = false;
12732
+ reconnectAttempts = 0;
12733
+ reconnectPromise;
12734
+ get timeoutMs() {
12735
+ return this.config.requestTimeoutMs ?? DEFAULT_MCP_TIMEOUT_MS;
12736
+ }
12737
+ /** Spawn the server child and wire stdout/stderr/error/exit handlers.
12738
+ * Shared by `initialize()` and the M2 #59 reconnect path. */
12739
+ spawnChild() {
12082
12740
  const resolvedCwd = resolveMcpCwd(this.config.cwd);
12083
12741
  const child = spawn(this.config.command, this.config.args ?? [], {
12084
12742
  cwd: resolvedCwd,
12085
- env: { ...process.env, ...this.config.env ?? {} }
12743
+ // #54 (F-H1) a third-party MCP server binary must not inherit host
12744
+ // secrets. Scrub secret-like vars by default; `config.env` still wins.
12745
+ env: resolveChildEnv({ policy: this.config.envPolicy, overrides: this.config.env })
12086
12746
  });
12087
12747
  this.child = child;
12088
12748
  child.stdout.on("data", (chunk) => this.consume(chunk));
12089
12749
  child.stderr.on("data", () => void 0);
12750
+ child.stdin.on("error", () => void 0);
12090
12751
  child.on("error", () => {
12091
- for (const resolver of this.pending.values()) {
12092
- resolver({ error: { message: "MCP process crashed" } });
12093
- }
12094
- this.pending.clear();
12752
+ this.rejectAllPending(
12753
+ new NetworkError(`MCP ${this.name} process crashed`, { code: "mcp_crashed" })
12754
+ );
12095
12755
  });
12756
+ child.on("exit", () => {
12757
+ if (this.child !== child) return;
12758
+ this.child = void 0;
12759
+ this.dropped = true;
12760
+ this.rejectAllPending(
12761
+ new NetworkError(`MCP ${this.name} disconnected`, { code: "mcp_disconnected" })
12762
+ );
12763
+ });
12764
+ }
12765
+ async initialize() {
12766
+ this.spawnChild();
12096
12767
  await super.initialize();
12097
12768
  }
12769
+ /** M2 #59 — ensure a live child before a request. Reconnect (bounded, with
12770
+ * full-jitter backoff) when the client was dropped; fail fast when never
12771
+ * initialized. Concurrent callers share ONE reconnect handshake. */
12772
+ ensureConnected() {
12773
+ if (this.child !== void 0) return Promise.resolve();
12774
+ if (!this.dropped) {
12775
+ return Promise.reject(
12776
+ new ConfigurationError(`MCP ${this.name} is not initialized`, { code: "mcp_not_init" })
12777
+ );
12778
+ }
12779
+ this.reconnectPromise ??= this.reconnect().finally(() => {
12780
+ this.reconnectPromise = void 0;
12781
+ });
12782
+ return this.reconnectPromise;
12783
+ }
12784
+ async reconnect() {
12785
+ if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
12786
+ throw new NetworkError(`MCP ${this.name} reconnect exhausted`, { code: "mcp_disconnected" });
12787
+ }
12788
+ await reconnectDelay(this.reconnectAttempts);
12789
+ this.reconnectAttempts += 1;
12790
+ this.spawnChild();
12791
+ await super.initialize();
12792
+ this.dropped = false;
12793
+ this.reconnectAttempts = 0;
12794
+ }
12098
12795
  async close() {
12099
- if (this.child === void 0) return;
12100
- this.child.kill("SIGTERM");
12796
+ this.rejectAllPending(new NetworkError(`MCP ${this.name} closed`, { code: "mcp_closed" }));
12797
+ const child = this.child;
12101
12798
  this.child = void 0;
12799
+ this.dropped = false;
12800
+ child?.kill("SIGTERM");
12801
+ }
12802
+ /** Reject + clear every pending request (crash / close). @internal */
12803
+ rejectAllPending(error) {
12804
+ for (const entry of this.pending.values()) {
12805
+ clearTimeout(entry.timer);
12806
+ entry.reject(error);
12807
+ }
12808
+ this.pending.clear();
12102
12809
  }
12103
12810
  consume(chunk) {
12104
12811
  this.buffer += chunk.toString("utf8");
12812
+ if (this.buffer.length > MAX_STDIO_BUFFER_BYTES) {
12813
+ this.buffer = "";
12814
+ this.rejectAllPending(
12815
+ new NetworkError(`MCP ${this.name} exceeded stdout buffer limit`, {
12816
+ code: "mcp_buffer_overflow"
12817
+ })
12818
+ );
12819
+ this.child?.kill("SIGKILL");
12820
+ this.child = void 0;
12821
+ return;
12822
+ }
12105
12823
  let newlineIndex = this.buffer.indexOf("\n");
12106
12824
  while (newlineIndex !== -1) {
12107
12825
  const line = this.buffer.slice(0, newlineIndex).trim();
@@ -12118,23 +12836,47 @@ var StdioMcpClient = class extends BaseMcpClient {
12118
12836
  return;
12119
12837
  }
12120
12838
  if (typeof message.id !== "number") return;
12121
- const resolver = this.pending.get(message.id);
12122
- if (resolver === void 0) return;
12839
+ const entry = this.pending.get(message.id);
12840
+ if (entry === void 0) return;
12123
12841
  this.pending.delete(message.id);
12124
- resolver(message);
12842
+ clearTimeout(entry.timer);
12843
+ entry.resolve(message);
12125
12844
  }
12126
12845
  request(method, params) {
12127
- if (this.child === void 0) {
12128
- return Promise.reject(
12129
- new ConfigurationError(`MCP ${this.name} is not initialized`, { code: "mcp_not_init" })
12130
- );
12846
+ const child = this.child;
12847
+ if (child !== void 0) return this.send(child, method, params);
12848
+ if (this.dropped) return this.reconnectAndRequest(method, params);
12849
+ return Promise.reject(
12850
+ new ConfigurationError(`MCP ${this.name} is not initialized`, { code: "mcp_not_init" })
12851
+ );
12852
+ }
12853
+ /** M2 #59 — reconnect a dropped client, then send. Separate async path so the
12854
+ * happy path above never pays an extra microtask tick. */
12855
+ async reconnectAndRequest(method, params) {
12856
+ await this.ensureConnected();
12857
+ const child = this.child;
12858
+ if (child === void 0) {
12859
+ throw new ConfigurationError(`MCP ${this.name} is not initialized`, { code: "mcp_not_init" });
12131
12860
  }
12861
+ return this.send(child, method, params);
12862
+ }
12863
+ send(child, method, params) {
12132
12864
  const id = this.nextId++;
12133
12865
  const payload = { jsonrpc: "2.0", id, method, params };
12134
- this.child.stdin.write(`${JSON.stringify(payload)}
12866
+ child.stdin.write(`${JSON.stringify(payload)}
12135
12867
  `);
12136
- return new Promise((resolve3) => {
12137
- this.pending.set(id, resolve3);
12868
+ return new Promise((resolve3, reject) => {
12869
+ const timer = setTimeout(() => {
12870
+ this.pending.delete(id);
12871
+ reject(mcpTimeoutError(this.name, this.timeoutMs));
12872
+ this.child?.kill("SIGKILL");
12873
+ this.child = void 0;
12874
+ this.dropped = true;
12875
+ this.rejectAllPending(
12876
+ new NetworkError(`MCP ${this.name} disconnected`, { code: "mcp_disconnected" })
12877
+ );
12878
+ }, this.timeoutMs);
12879
+ this.pending.set(id, { resolve: resolve3, reject, timer });
12138
12880
  });
12139
12881
  }
12140
12882
  };
@@ -12160,11 +12902,20 @@ var HttpMcpClient = class extends BaseMcpClient {
12160
12902
  accept: "application/json",
12161
12903
  ...this.config.headers ?? {}
12162
12904
  };
12163
- const response = await this.fetchImpl(this.config.url, {
12164
- method: "POST",
12165
- headers,
12166
- body: JSON.stringify(payload)
12167
- });
12905
+ const timeoutMs = this.config.requestTimeoutMs ?? DEFAULT_MCP_TIMEOUT_MS;
12906
+ let response;
12907
+ try {
12908
+ response = await this.fetchImpl(this.config.url, {
12909
+ method: "POST",
12910
+ headers,
12911
+ body: JSON.stringify(payload),
12912
+ // #59 — bound the request; a non-responding endpoint aborts here.
12913
+ signal: AbortSignal.timeout(timeoutMs)
12914
+ });
12915
+ } catch (cause) {
12916
+ if (isAbortLike(cause)) throw mcpTimeoutError(this.name, timeoutMs);
12917
+ throw cause;
12918
+ }
12168
12919
  if (!response.ok) {
12169
12920
  throw new NetworkError(`MCP ${this.name} returned ${response.status}`, {
12170
12921
  code: "mcp_http_error"
@@ -12279,11 +13030,33 @@ function resolveRunProvider(options) {
12279
13030
  );
12280
13031
  }
12281
13032
  const parsedModel = parseModelId(options.model?.id);
12282
- const inferredProvider = parsedModel.provider !== void 0 && getProviderProfile(parsedModel.provider) !== void 0 ? parsedModel.provider : void 0;
12283
- const primary = options.agentOptions.providers?.routes?.[0]?.provider ?? inferredProvider ?? detectPrimaryProvider();
12284
- const effectiveModelId = inferredProvider !== void 0 ? parsedModel.name : options.model?.id ?? "claude-sonnet-4-6";
13033
+ const modelInferredProvider = parsedModel.provider !== void 0 && getProviderProfile(parsedModel.provider) !== void 0 ? parsedModel.provider : void 0;
13034
+ const keyInferredProvider = inferProviderFromApiKey(options.agentOptions.apiKey);
13035
+ const primary = options.agentOptions.providers?.routes?.[0]?.provider ?? keyInferredProvider ?? modelInferredProvider ?? detectPrimaryProvider();
13036
+ const effectiveModelId = modelInferredProvider !== void 0 && modelInferredProvider === primary ? parsedModel.name : options.model?.id ?? "claude-sonnet-4-6";
12285
13037
  return { primary, effectiveModelId };
12286
13038
  }
13039
+ function inferProviderFromApiKey(apiKey) {
13040
+ if (apiKey === void 0 || apiKey.length === 0) return void 0;
13041
+ const byPrefix = [
13042
+ { provider: "openrouter", prefix: "sk-or-" },
13043
+ { provider: "anthropic", prefix: "sk-ant-" },
13044
+ { provider: "openai", prefix: "sk-" }
13045
+ ];
13046
+ for (const { provider, prefix } of byPrefix) {
13047
+ if (apiKey.startsWith(prefix) && getProviderProfile(provider) !== void 0) {
13048
+ return provider;
13049
+ }
13050
+ }
13051
+ return void 0;
13052
+ }
13053
+ function mergeExplicitApiKey(pools, primary, apiKey) {
13054
+ if (apiKey === void 0 || apiKey.length === 0) return pools;
13055
+ if (isFixtureApiKey(apiKey) || apiKey === LOCAL_RUNTIME_MOCK_KEY) return pools;
13056
+ const existing = pools?.[primary];
13057
+ if (existing !== void 0 && existing.length > 0) return pools;
13058
+ return { ...pools ?? {}, [primary]: [apiKey] };
13059
+ }
12287
13060
  function buildLoopInputs(options, runId, userText) {
12288
13061
  const maxIterations = options.sendOptions.maxIterations;
12289
13062
  if (maxIterations !== void 0 && (!Number.isInteger(maxIterations) || maxIterations < 1)) {
@@ -12294,7 +13067,11 @@ function buildLoopInputs(options, runId, userText) {
12294
13067
  }
12295
13068
  const { primary, effectiveModelId } = resolveRunProvider(options);
12296
13069
  const fallback = options.agentOptions.providers?.fallback;
12297
- const apiKeys = options.agentOptions.providers?.apiKeys;
13070
+ const apiKeys = mergeExplicitApiKey(
13071
+ options.agentOptions.providers?.apiKeys,
13072
+ primary,
13073
+ options.agentOptions.apiKey
13074
+ );
12298
13075
  const credentialPoolStrategy = options.agentOptions.providers?.credentialPoolStrategy;
12299
13076
  const extractToolCallsFromContent = options.agentOptions.providers?.routes?.[0]?.extractToolCallsFromContent;
12300
13077
  const chain = resolveProviderChain({
@@ -12338,6 +13115,10 @@ function buildLoopInputs(options, runId, userText) {
12338
13115
  // D318 — forward SendOptions.signal to the agent loop so streamLlmTurn
12339
13116
  // can attach it to the LLM `fetch({ signal })` call.
12340
13117
  ...options.sendOptions.signal !== void 0 ? { signal: options.sendOptions.signal } : {},
13118
+ // #58 / #57 — forward the per-tool timeout + tool-result guard so a consumer
13119
+ // can enable them via SendOptions (not only internal AgentLoopInputs).
13120
+ ...options.sendOptions.perToolTimeoutMs !== void 0 ? { perToolTimeoutMs: options.sendOptions.perToolTimeoutMs } : {},
13121
+ ...options.sendOptions.toolResultGuard !== void 0 ? { toolResultGuard: options.sendOptions.toolResultGuard } : {},
12341
13122
  // M1-2: per-send iteration ceiling (validated above). The loop reads
12342
13123
  // inputs.maxIterations (default 8 when unset).
12343
13124
  ...maxIterations !== void 0 ? { maxIterations } : {},
@@ -12654,7 +13435,12 @@ async function runActiveMemory(args) {
12654
13435
  hits: []
12655
13436
  });
12656
13437
  }
12657
- const cached2 = args.cache?.get(args.userText, cfg.queryMode);
13438
+ const tenantCtx = {
13439
+ namespace: args.namespace,
13440
+ userId: args.userId,
13441
+ scope: args.scope
13442
+ };
13443
+ const cached2 = args.cache?.get(args.userText, cfg.queryMode, tenantCtx);
12658
13444
  if (cached2 !== void 0) return endRecallSpan(span, args, cached2);
12659
13445
  const query = buildQuery(args.userText, args.priorMessages, cfg.queryMode, cfg.recentUserTurns);
12660
13446
  if (query.trim().length === 0) {
@@ -12739,7 +13525,12 @@ function notifyBreaker(breaker, key, status) {
12739
13525
  else if (status === "ok" || status === "no-recall") breaker.recordSuccess(key);
12740
13526
  }
12741
13527
  async function finalize(args, queryMode, result) {
12742
- args.cache?.set(args.userText, queryMode, result);
13528
+ const tenantCtx = {
13529
+ namespace: args.namespace,
13530
+ userId: args.userId,
13531
+ scope: args.scope
13532
+ };
13533
+ args.cache?.set(args.userText, queryMode, result, tenantCtx);
12743
13534
  if (args.persistTranscripts === true && args.cwd !== void 0) {
12744
13535
  await persistActiveMemoryTranscript(args.cwd, {
12745
13536
  runId: args.runId ?? `run-${Date.now()}`,
@@ -13341,48 +14132,6 @@ var MEMORY_EMBEDDING_ADAPTERS = {
13341
14132
  gemini: geminiMemoryEmbeddingProviderAdapter
13342
14133
  };
13343
14134
 
13344
- // src/internal/memory/circuit-breaker.ts
13345
- var DEFAULT_MAX_TIMEOUTS = 3;
13346
- var DEFAULT_COOLDOWN_MS2 = 6e4;
13347
- var CircuitBreaker = class {
13348
- constructor(opts = {}) {
13349
- this.opts = opts;
13350
- }
13351
- opts;
13352
- states = /* @__PURE__ */ new Map();
13353
- /** @returns true when the breaker is open and the call should be skipped. */
13354
- shouldSkip(key) {
13355
- const state3 = this.states.get(key);
13356
- if (state3 === void 0) return false;
13357
- if (state3.cooldownUntilMs === 0) return false;
13358
- if (this.now() < state3.cooldownUntilMs) return true;
13359
- state3.cooldownUntilMs = 0;
13360
- state3.consecutiveTimeouts = 0;
13361
- return false;
13362
- }
13363
- recordSuccess(key) {
13364
- const state3 = this.states.get(key);
13365
- if (state3 === void 0) return;
13366
- state3.consecutiveTimeouts = 0;
13367
- state3.cooldownUntilMs = 0;
13368
- }
13369
- recordTimeout(key) {
13370
- const state3 = this.states.get(key) ?? { consecutiveTimeouts: 0, cooldownUntilMs: 0 };
13371
- state3.consecutiveTimeouts += 1;
13372
- if (state3.consecutiveTimeouts >= (this.opts.maxTimeouts ?? DEFAULT_MAX_TIMEOUTS)) {
13373
- state3.cooldownUntilMs = this.now() + (this.opts.cooldownMs ?? DEFAULT_COOLDOWN_MS2);
13374
- }
13375
- this.states.set(key, state3);
13376
- }
13377
- /** @internal — tests inspect counter state. */
13378
- inspect(key) {
13379
- return this.states.get(key) ?? { consecutiveTimeouts: 0, cooldownUntilMs: 0 };
13380
- }
13381
- now() {
13382
- return this.opts.now?.() ?? Date.now();
13383
- }
13384
- };
13385
-
13386
14135
  // src/internal/persistence/fts5-sanitize.ts
13387
14136
  var PHRASE_OPEN = "";
13388
14137
  var PHRASE_CLOSE = "";