@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/eval.js CHANGED
@@ -885,6 +885,16 @@ var init_credential_pool_types = __esm({
885
885
  });
886
886
 
887
887
  // src/internal/llm/retry.ts
888
+ function computeBackoffMs(opts) {
889
+ const base = opts.baseMs ?? DEFAULT_BASE_MS;
890
+ const cap = opts.capMs ?? DEFAULT_CAP_MS;
891
+ const rng = opts.rng ?? Math.random;
892
+ if (opts.retryAfterMs !== void 0 && opts.retryAfterMs >= 0) {
893
+ return Math.max(base, Math.min(cap, opts.retryAfterMs));
894
+ }
895
+ const ceiling = Math.min(cap, base * 2 ** opts.attempt);
896
+ return Math.floor(rng() * ceiling);
897
+ }
888
898
  function sleepWithAbort(ms, signal) {
889
899
  if (ms <= 0 || signal.aborted) return Promise.resolve();
890
900
  return new Promise((resolve3) => {
@@ -899,8 +909,11 @@ function sleepWithAbort(ms, signal) {
899
909
  signal.addEventListener("abort", onAbort, { once: true });
900
910
  });
901
911
  }
912
+ var DEFAULT_BASE_MS, DEFAULT_CAP_MS;
902
913
  var init_retry = __esm({
903
914
  "src/internal/llm/retry.ts"() {
915
+ DEFAULT_BASE_MS = 500;
916
+ DEFAULT_CAP_MS = 32e3;
904
917
  }
905
918
  });
906
919
 
@@ -4561,13 +4574,15 @@ function buildGitInfo() {
4561
4574
  init_errors();
4562
4575
 
4563
4576
  // src/internal/llm/sse.ts
4564
- async function* parseSseStream(body, signal) {
4577
+ init_errors();
4578
+ var DEFAULT_SSE_IDLE_MS = 6e4;
4579
+ async function* parseSseStream(body, signal, idleTimeoutMs = DEFAULT_SSE_IDLE_MS) {
4565
4580
  if (body === null) return;
4566
4581
  const reader = body.getReader();
4567
4582
  const decoder = new TextDecoder("utf-8");
4568
4583
  const state2 = { buffer: "", event: "message", data: "" };
4569
4584
  try {
4570
- for await (const chunk of readChunks(reader, signal)) {
4585
+ for await (const chunk of readChunks(reader, signal, idleTimeoutMs)) {
4571
4586
  state2.buffer += decoder.decode(chunk, { stream: true });
4572
4587
  for (const record of drainCompleteRecords(state2)) yield record;
4573
4588
  }
@@ -4577,14 +4592,38 @@ async function* parseSseStream(body, signal) {
4577
4592
  releaseReader(reader);
4578
4593
  }
4579
4594
  }
4580
- async function* readChunks(reader, signal) {
4595
+ async function* readChunks(reader, signal, idleTimeoutMs) {
4581
4596
  while (true) {
4582
4597
  if (signal.aborted) return;
4583
- const { value, done } = await reader.read();
4598
+ const { value, done } = await readWithIdleTimeout(reader, idleTimeoutMs);
4584
4599
  if (done) return;
4585
4600
  if (value !== void 0) yield value;
4586
4601
  }
4587
4602
  }
4603
+ function readWithIdleTimeout(reader, idleTimeoutMs) {
4604
+ if (idleTimeoutMs <= 0) return reader.read();
4605
+ return new Promise(
4606
+ (resolve3, reject) => {
4607
+ const timer = setTimeout(() => {
4608
+ reject(
4609
+ new NetworkError(`SSE stream idle for ${idleTimeoutMs}ms \u2014 upstream stalled`, {
4610
+ code: "stream_idle_timeout"
4611
+ })
4612
+ );
4613
+ }, idleTimeoutMs);
4614
+ reader.read().then(
4615
+ (result) => {
4616
+ clearTimeout(timer);
4617
+ resolve3(result);
4618
+ },
4619
+ (err) => {
4620
+ clearTimeout(timer);
4621
+ reject(err);
4622
+ }
4623
+ );
4624
+ }
4625
+ );
4626
+ }
4588
4627
  async function cancelReaderQuietly(reader) {
4589
4628
  try {
4590
4629
  await reader.cancel();
@@ -5278,6 +5317,9 @@ var PersonalityStore = class {
5278
5317
  }
5279
5318
  };
5280
5319
 
5320
+ // src/internal/plugins/manager.ts
5321
+ init_errors();
5322
+
5281
5323
  // src/internal/plugins/context.ts
5282
5324
  function createPluginContext() {
5283
5325
  const registrations = {
@@ -5339,6 +5381,9 @@ var PluginManager = class {
5339
5381
  memoryProviders: []
5340
5382
  };
5341
5383
  #initialized = false;
5384
+ // #68 — registrations of plugins added post-init via `register()`, keyed by
5385
+ // plugin name so a re-register REPLACES (not appends) the prior hooks.
5386
+ #byName = /* @__PURE__ */ new Map();
5342
5387
  async initialize(plugins) {
5343
5388
  if (this.#initialized) {
5344
5389
  throw new Error("PluginManager.initialize called twice \u2014 register only once per process");
@@ -5356,6 +5401,36 @@ var PluginManager = class {
5356
5401
  await this.#dispatchPlugin(plugin);
5357
5402
  }
5358
5403
  }
5404
+ /**
5405
+ * #68 — register a single `general` plugin AFTER `initialize()` has run.
5406
+ *
5407
+ * The bulk `initialize()` is single-shot (one call per process); late
5408
+ * registration is a distinct, named operation used by adapters that install
5409
+ * a plugin per-session/per-request (e.g. the ACP permission veto, which is
5410
+ * installed once the permission mode + connection are known — after the
5411
+ * agent's own plugins were already initialized).
5412
+ *
5413
+ * Idempotent by plugin NAME: re-registering a plugin with the same name
5414
+ * REPLACES its prior hooks/tools instead of appending duplicates (the ACP
5415
+ * permission plugin is re-installed on every prompt).
5416
+ *
5417
+ * Only `general` plugins may be registered late — model-provider / memory
5418
+ * plugins are resolved during the bulk init and cannot be added afterwards.
5419
+ */
5420
+ async register(plugin) {
5421
+ if (plugin.kind !== "general") {
5422
+ throw new ConfigurationError(
5423
+ `late register supports general plugins only (got "${plugin.kind}" for "${plugin.name}")`,
5424
+ { code: "plugin_late_register_kind" }
5425
+ );
5426
+ }
5427
+ const prior = this.#byName.get(plugin.name);
5428
+ if (prior !== void 0) this.#unmerge(prior);
5429
+ const { ctx, registrations } = createPluginContext();
5430
+ await plugin.register(ctx);
5431
+ this.#byName.set(plugin.name, registrations);
5432
+ this.#merge(registrations);
5433
+ }
5359
5434
  get aggregated() {
5360
5435
  return this.#aggregated;
5361
5436
  }
@@ -5436,6 +5511,64 @@ var PluginManager = class {
5436
5511
  }
5437
5512
  }
5438
5513
  }
5514
+ // #65 — the previously-dead hooks, now wired. Fire-and-forget hooks run
5515
+ // in order (per-handler errors logged, never thrown); transform hooks fold
5516
+ // over the payload (a handler returning a value replaces it).
5517
+ /** @internal */
5518
+ async #runFireAndForget(name, ctx) {
5519
+ for (const h of this.#aggregated.hooks.get(name) ?? []) {
5520
+ try {
5521
+ await h(ctx);
5522
+ } catch (err) {
5523
+ process.stderr.write(
5524
+ `[theokit-sdk] ${name} hook failed: ${err instanceof Error ? err.message : String(err)}
5525
+ `
5526
+ );
5527
+ }
5528
+ }
5529
+ }
5530
+ /** @internal — fold: each handler may return a replacement payload; a throw keeps the prior value. */
5531
+ async #runTransform(name, payload, ctx) {
5532
+ let current = payload;
5533
+ for (const h of this.#aggregated.hooks.get(name) ?? []) {
5534
+ try {
5535
+ const out = await h(current, ctx);
5536
+ if (out !== void 0) current = out;
5537
+ } catch (err) {
5538
+ process.stderr.write(
5539
+ `[theokit-sdk] ${name} hook failed: ${err instanceof Error ? err.message : String(err)}
5540
+ `
5541
+ );
5542
+ }
5543
+ }
5544
+ return current;
5545
+ }
5546
+ /** #65 — fired after a tool call completes. @internal */
5547
+ runPostToolCallHooks(ctx) {
5548
+ return this.#runFireAndForget("post_tool_call", ctx);
5549
+ }
5550
+ /** #65 — fired before / after each LLM turn. @internal */
5551
+ runPreLlmCallHooks(ctx) {
5552
+ return this.#runFireAndForget("pre_llm_call", ctx);
5553
+ }
5554
+ runPostLlmCallHooks(ctx) {
5555
+ return this.#runFireAndForget("post_llm_call", ctx);
5556
+ }
5557
+ /** #65 — fired at run start / end. @internal */
5558
+ runOnSessionStartHooks(ctx) {
5559
+ return this.#runFireAndForget("on_session_start", ctx);
5560
+ }
5561
+ runOnSessionEndHooks(ctx) {
5562
+ return this.#runFireAndForget("on_session_end", ctx);
5563
+ }
5564
+ /** #65/#57 — transform tool results before they reach the LLM (the #57 seam). @internal */
5565
+ runTransformToolResultHooks(results, ctx) {
5566
+ return this.#runTransform("transform_tool_result", results, ctx);
5567
+ }
5568
+ /** #65 — transform the LLM output text before it is consumed. @internal */
5569
+ runTransformLlmOutputHooks(output, ctx) {
5570
+ return this.#runTransform("transform_llm_output", output, ctx);
5571
+ }
5439
5572
  async #dispatchPlugin(plugin) {
5440
5573
  if (plugin.kind === "general") {
5441
5574
  const { ctx, registrations } = createPluginContext();
@@ -5463,7 +5596,29 @@ var PluginManager = class {
5463
5596
  }
5464
5597
  this.#aggregated.injected.push(...r.injected);
5465
5598
  }
5599
+ /**
5600
+ * #68 — inverse of #merge: remove a prior registration's contributions from
5601
+ * the aggregated view by object identity. Used by `register()` to replace a
5602
+ * same-named plugin's hooks/tools instead of accumulating duplicates.
5603
+ */
5604
+ #unmerge(r) {
5605
+ removeAll(this.#aggregated.tools, r.tools);
5606
+ removeAll(this.#aggregated.commands, r.commands);
5607
+ removeAll(this.#aggregated.injected, r.injected);
5608
+ for (const [hook, handlers] of r.hooks.entries()) {
5609
+ const existing = this.#aggregated.hooks.get(hook);
5610
+ if (existing === void 0) continue;
5611
+ removeAll(existing, handlers);
5612
+ if (existing.length === 0) this.#aggregated.hooks.delete(hook);
5613
+ }
5614
+ }
5466
5615
  };
5616
+ function removeAll(arr, toRemove) {
5617
+ for (const item of toRemove) {
5618
+ const idx = arr.indexOf(item);
5619
+ if (idx !== -1) arr.splice(idx, 1);
5620
+ }
5621
+ }
5467
5622
 
5468
5623
  // src/internal/telemetry/span-names.ts
5469
5624
  var SPAN_NAMES = {
@@ -5471,7 +5626,12 @@ var SPAN_NAMES = {
5471
5626
  AGENT_SEND: "agent.send",
5472
5627
  MEMORY_RECALL: "memory.recall"};
5473
5628
  var HISTOGRAM_NAMES = {
5474
- MEMORY_RECALL_DURATION_MS: "theokit_memory_recall_duration_ms"
5629
+ MEMORY_RECALL_DURATION_MS: "theokit_memory_recall_duration_ms",
5630
+ TOOL_CALL_DURATION_MS: "theokit_tool_call_duration_ms",
5631
+ LLM_CALL_DURATION_MS: "theokit_llm_call_duration_ms",
5632
+ LLM_TOKENS: "theokit_llm_tokens",
5633
+ /** M3 #66 — count of finishes where the provider omitted usage (silent undercount). */
5634
+ LLM_USAGE_MISSING: "theokit_llm_usage_missing"
5475
5635
  };
5476
5636
  function safeRequire(moduleName) {
5477
5637
  try {
@@ -5806,7 +5966,25 @@ function createTelemetry(settings) {
5806
5966
  enabled: true,
5807
5967
  includeContent: settings.includeContent === true,
5808
5968
  startSpan: startNewSpan,
5809
- startChildSpan: (_parent, name, attrs) => startNewSpan(name, attrs),
5969
+ // M3 #64 actually nest the child under its parent instead of discarding it.
5970
+ // The parent's SpanContext is set on a fresh OTel context so the child links
5971
+ // to it (traceId + parentSpanId), reconstructing the causal trace tree. Falls
5972
+ // back to a root span when the parent has no valid span id (telemetry off /
5973
+ // NOOP), preserving the pre-M3 behavior for parentless callers.
5974
+ startChildSpan: (parent, name, attrs) => {
5975
+ const redactedAttrs = attrs === void 0 ? void 0 : redactAttrs(attrs);
5976
+ const opts = redactedAttrs ? { attributes: redactedAttrs } : void 0;
5977
+ const pctx = safe(() => parent?.spanContext(), void 0);
5978
+ const span = safe(() => {
5979
+ if (pctx !== void 0 && pctx.spanId !== "0".repeat(16)) {
5980
+ const childCtx = otel.trace.setSpanContext(otel.context.active(), pctx);
5981
+ return tracer.startSpan(name, opts, childCtx);
5982
+ }
5983
+ return tracer.startSpan(name, opts);
5984
+ }, NOOP_SPAN);
5985
+ if (span !== NOOP_SPAN) openSpans.add(span);
5986
+ return wrapSpan(span, openSpans);
5987
+ },
5810
5988
  recordHistogram,
5811
5989
  endAll: () => {
5812
5990
  for (const span of openSpans) safe(() => span.end(), void 0);
@@ -5842,12 +6020,62 @@ function redactAttrs(attrs) {
5842
6020
  }
5843
6021
  return out;
5844
6022
  }
6023
+
6024
+ // src/internal/runtime/lifecycle/env-policy.ts
6025
+ var SECRET_PATTERNS = [
6026
+ /KEY/i,
6027
+ /SECRET/i,
6028
+ /TOKEN/i,
6029
+ /PASSWORD/i,
6030
+ /PASSWD/i,
6031
+ /PASSPHRASE/i,
6032
+ /[_-]PWD/i,
6033
+ /CREDENTIAL/i,
6034
+ /PRIVATE/i,
6035
+ /_AUTH/i
6036
+ ];
6037
+ var CORE_VARS = [
6038
+ "PATH",
6039
+ "HOME",
6040
+ "SHELL",
6041
+ "LANG",
6042
+ "LC_ALL",
6043
+ "LC_CTYPE",
6044
+ "TMPDIR",
6045
+ "TMP",
6046
+ "TEMP",
6047
+ "USER",
6048
+ "LOGNAME"
6049
+ ];
6050
+ function isSecretName(name) {
6051
+ return SECRET_PATTERNS.some((re) => re.test(name));
6052
+ }
6053
+ function inheritsUnderPolicy(name, policy) {
6054
+ if (policy === "all") return true;
6055
+ if (policy === "core") return CORE_VARS.includes(name);
6056
+ return !isSecretName(name);
6057
+ }
6058
+ function resolveChildEnv(options = {}) {
6059
+ const parent = options.parent ?? process.env;
6060
+ const policy = options.policy ?? "inherit-scrubbed";
6061
+ const base = {};
6062
+ for (const [name, value] of Object.entries(parent)) {
6063
+ if (value !== void 0 && inheritsUnderPolicy(name, policy)) base[name] = value;
6064
+ }
6065
+ for (const [name, value] of Object.entries(options.overrides ?? {})) {
6066
+ base[name] = value;
6067
+ }
6068
+ return base;
6069
+ }
6070
+
6071
+ // src/internal/runtime/lifecycle/spawn-collect.ts
5845
6072
  function spawnAndCollect(options) {
5846
6073
  return new Promise((resolve3) => {
5847
6074
  const timeoutMs = options.timeoutMs ?? 3e4;
5848
6075
  const spawnOptions = {
5849
6076
  cwd: options.cwd,
5850
- env: { ...process.env, ...options.env ?? {} }
6077
+ // #54 — scrub secret-like parent env by default; `options.env` still wins.
6078
+ env: resolveChildEnv({ policy: options.envPolicy, overrides: options.env })
5851
6079
  };
5852
6080
  const child = spawn(options.command, options.args ?? [], spawnOptions);
5853
6081
  let stdout = "";
@@ -6065,15 +6293,24 @@ function warnMalformed(agentId, line) {
6065
6293
  `
6066
6294
  );
6067
6295
  }
6296
+ function hydrateSessionLine(parsed) {
6297
+ if (typeof parsed.text !== "string" || parsed.role === void 0) return void 0;
6298
+ if (parsed.role === "user" || parsed.role === "assistant") {
6299
+ return { role: parsed.role, text: parsed.text };
6300
+ }
6301
+ if (parsed.role === "tool_call" || parsed.role === "tool_result") {
6302
+ const label = parsed.role === "tool_call" ? "tool call" : "tool result";
6303
+ return { role: "assistant", text: `[${label}] ${parsed.text}` };
6304
+ }
6305
+ return void 0;
6306
+ }
6068
6307
  async function readSessionFile(cwd, agentId) {
6069
6308
  const lines = await readJsonlLines(cwd, agentId);
6070
6309
  const messages = [];
6071
6310
  for (const line of lines) {
6072
6311
  try {
6073
- const parsed = JSON.parse(line);
6074
- if ((parsed.role === "user" || parsed.role === "assistant") && typeof parsed.text === "string") {
6075
- messages.push({ role: parsed.role, text: parsed.text });
6076
- }
6312
+ const msg = hydrateSessionLine(JSON.parse(line));
6313
+ if (msg !== void 0) messages.push(msg);
6077
6314
  } catch {
6078
6315
  warnMalformed(agentId, line);
6079
6316
  }
@@ -6100,24 +6337,72 @@ async function readAllPersistedMessages(cwd, agentId) {
6100
6337
  return messages;
6101
6338
  }
6102
6339
  async function appendAnyPersistedMessage(cwd, agentId, record) {
6103
- const path = sessionFilePath(cwd, agentId);
6104
- await mkdir(dirname(path), { recursive: true });
6105
- await appendFile(path, `${redactSecrets(JSON.stringify(record))}
6106
- `, "utf8");
6340
+ await appendPersistedMessages(cwd, agentId, [record]);
6107
6341
  }
6108
- async function compactSessionFile(cwd, agentId, maxTurns) {
6342
+ async function appendPersistedMessages(cwd, agentId, records) {
6343
+ if (records.length === 0) return;
6109
6344
  const path = sessionFilePath(cwd, agentId);
6110
- let raw;
6345
+ const payload = records.map((r) => `${redactSecrets(JSON.stringify(r))}
6346
+ `).join("");
6347
+ const dir = dirname(path);
6348
+ let written = false;
6349
+ const attempt = async () => {
6350
+ await mkdir(dir, { recursive: true });
6351
+ await withFileLock(path, async () => {
6352
+ await appendFile(path, payload, "utf8");
6353
+ written = true;
6354
+ });
6355
+ };
6111
6356
  try {
6112
- raw = await readFile(path, "utf8");
6113
- } catch {
6114
- return;
6357
+ await attempt();
6358
+ } catch (cause) {
6359
+ if (written || cause.code !== "ENOENT") throw cause;
6360
+ await attempt();
6115
6361
  }
6116
- const lines = raw.split("\n").filter((line) => line.length > 0);
6117
- if (lines.length <= maxTurns * 2) return;
6118
- const trimmed = `${lines.slice(-maxTurns).join("\n")}
6362
+ }
6363
+ async function rewriteLockedSession(path, transform) {
6364
+ await withFileLock(path, async () => {
6365
+ let raw;
6366
+ try {
6367
+ raw = await readFile(path, "utf8");
6368
+ } catch {
6369
+ return;
6370
+ }
6371
+ const lines = raw.split("\n").filter((line) => line.length > 0);
6372
+ const next = transform(lines);
6373
+ if (next === void 0) return;
6374
+ await replaceFileAtomic(path, next);
6375
+ });
6376
+ }
6377
+ async function compactSessionFile(cwd, agentId, maxTurns) {
6378
+ const path = sessionFilePath(cwd, agentId);
6379
+ if (!existsSync(path)) return;
6380
+ await rewriteLockedSession(
6381
+ path,
6382
+ (lines) => lines.length <= maxTurns * 2 ? void 0 : `${lines.slice(-maxTurns).join("\n")}
6383
+ `
6384
+ );
6385
+ }
6386
+ async function truncateSessionTo(cwd, agentId, keepCount) {
6387
+ const path = sessionFilePath(cwd, agentId);
6388
+ if (!existsSync(path)) return 0;
6389
+ let kept = 0;
6390
+ await rewriteLockedSession(path, (lines) => {
6391
+ const keep = Math.max(0, Math.min(keepCount, lines.length));
6392
+ kept = keep;
6393
+ if (keep === lines.length) return void 0;
6394
+ return keep === 0 ? "" : `${lines.slice(0, keep).join("\n")}
6119
6395
  `;
6120
- await replaceFileAtomic(path, trimmed);
6396
+ });
6397
+ return kept;
6398
+ }
6399
+
6400
+ // src/internal/persistence/pagination.ts
6401
+ function paginate(items, opts) {
6402
+ if (opts === void 0 || opts.offset === void 0 && opts.limit === void 0) return items;
6403
+ const start = Math.max(0, opts.offset ?? 0);
6404
+ const end = opts.limit === void 0 ? items.length : start + Math.max(0, opts.limit);
6405
+ return items.slice(start, end);
6121
6406
  }
6122
6407
 
6123
6408
  // src/internal/persistence/conversation-storage-fs.ts
@@ -6130,23 +6415,31 @@ var FileSystemConversationStorage = class {
6130
6415
  get root() {
6131
6416
  return this.#root;
6132
6417
  }
6133
- async getMessages(conversationId) {
6418
+ async getMessages(conversationId, opts) {
6134
6419
  const records = await readAllPersistedMessages(this.#root, conversationId);
6135
- return records.map(toStoredMessage);
6420
+ const all = records.map(toStoredMessage);
6421
+ return paginate(all, opts);
6136
6422
  }
6137
6423
  async appendMessage(conversationId, message) {
6138
- const record = {
6139
- role: message.role,
6140
- text: message.content,
6141
- at: message.at ?? Date.now()
6142
- };
6143
- await appendAnyPersistedMessage(this.#root, conversationId, record);
6424
+ await appendAnyPersistedMessage(this.#root, conversationId, toRecord(message));
6425
+ }
6426
+ async appendMessages(conversationId, messages) {
6427
+ await appendPersistedMessages(this.#root, conversationId, messages.map(toRecord));
6428
+ }
6429
+ async truncateConversation(conversationId, keepCount) {
6430
+ return truncateSessionTo(this.#root, conversationId, keepCount);
6144
6431
  }
6145
6432
  async deleteConversation(conversationId) {
6146
6433
  const safe3 = sanitizeIdentifier(conversationId, { maxLen: 128 });
6147
6434
  const dirPath = safePathJoin(this.#root, ".theokit", "agents", safe3);
6148
6435
  await rm(dirPath, { recursive: true, force: true });
6149
6436
  }
6437
+ async deleteScope(prefix) {
6438
+ const ids = await this.listConversationIds();
6439
+ const matching = ids.filter((id) => id.startsWith(prefix));
6440
+ for (const id of matching) await this.deleteConversation(id);
6441
+ return matching.length;
6442
+ }
6150
6443
  async listConversationIds(opts = {}) {
6151
6444
  const agentsRoot = safePathJoin(this.#root, ".theokit", "agents");
6152
6445
  let entries;
@@ -6172,6 +6465,9 @@ function toStoredMessage(record) {
6172
6465
  at: record.at
6173
6466
  };
6174
6467
  }
6468
+ function toRecord(message) {
6469
+ return { role: message.role, text: message.content, at: message.at ?? Date.now() };
6470
+ }
6175
6471
 
6176
6472
  // src/internal/runtime/session/agent-session.ts
6177
6473
  var DEFAULT_MAX_TURNS = 200;
@@ -6257,12 +6553,19 @@ async function readPersistedForCache(adapter, agentId) {
6257
6553
  const records = await adapter.getMessages(agentId);
6258
6554
  const out = [];
6259
6555
  for (const r of records) {
6260
- if (r.role === "user" || r.role === "assistant") {
6261
- out.push({ role: r.role, text: r.content });
6262
- }
6556
+ const folded = foldStoredToSession(r);
6557
+ if (folded !== void 0) out.push(folded);
6263
6558
  }
6264
6559
  return out;
6265
6560
  }
6561
+ function foldStoredToSession(r) {
6562
+ if (r.role === "user" || r.role === "assistant") return { role: r.role, text: r.content };
6563
+ if (r.role === "tool_call" || r.role === "tool_result") {
6564
+ const label = r.role === "tool_call" ? "tool call" : "tool result";
6565
+ return { role: "assistant", text: `[${label}] ${r.content}` };
6566
+ }
6567
+ return void 0;
6568
+ }
6266
6569
  async function flushSessionWrites() {
6267
6570
  while (pendingAppends.size > 0) {
6268
6571
  const all = Array.from(pendingAppends.values());
@@ -8612,11 +8915,32 @@ function reasoningEffortFromParams(params) {
8612
8915
  const thinking = params?.find((p) => p.id === "thinking");
8613
8916
  return thinking !== void 0 && thinking.value.length > 0 ? thinking.value : void 0;
8614
8917
  }
8918
+ function emitLlmMetrics(inputs, result, startAt) {
8919
+ inputs.telemetry?.recordHistogram(HISTOGRAM_NAMES.LLM_CALL_DURATION_MS, Date.now() - startAt, {
8920
+ provider: inputs.llm.name
8921
+ });
8922
+ if (result.inputTokens === void 0 && result.outputTokens === void 0) {
8923
+ inputs.telemetry?.recordHistogram(HISTOGRAM_NAMES.LLM_USAGE_MISSING, 1, {
8924
+ provider: inputs.llm.name
8925
+ });
8926
+ process.stderr.write(
8927
+ `[theokit-sdk] llm usage missing from ${inputs.llm.name} finish \u2014 budget may undercount
8928
+ `
8929
+ );
8930
+ return;
8931
+ }
8932
+ inputs.telemetry?.recordHistogram(
8933
+ HISTOGRAM_NAMES.LLM_TOKENS,
8934
+ (result.inputTokens ?? 0) + (result.outputTokens ?? 0),
8935
+ { provider: inputs.llm.name }
8936
+ );
8937
+ }
8615
8938
  async function streamLlmTurn(inputs, ctx) {
8616
- const llmSpan = inputs.telemetry?.startSpan("llm.call", {
8939
+ const llmSpan = inputs.telemetry?.startChildSpan(ctx.sendSpan, "llm.call", {
8617
8940
  "model.id": inputs.model.id ?? "auto",
8618
8941
  provider: inputs.llm.name
8619
8942
  });
8943
+ const startAt = Date.now();
8620
8944
  const signal = inputs.signal ?? new AbortController().signal;
8621
8945
  const generator = inputs.llm.stream(
8622
8946
  {
@@ -8659,6 +8983,7 @@ async function streamLlmTurn(inputs, ctx) {
8659
8983
  inputTokens: result.inputTokens ?? 0,
8660
8984
  outputTokens: result.outputTokens ?? 0
8661
8985
  });
8986
+ emitLlmMetrics(inputs, result, startAt);
8662
8987
  llmSpan?.end();
8663
8988
  const stripped = stripThinkBlocks(collected.accumulatedText);
8664
8989
  return {
@@ -8888,21 +9213,21 @@ async function executeTool(inputs, resolved, call) {
8888
9213
  }
8889
9214
  if (resolved.origin === "shell") return runShellTool(inputs, call);
8890
9215
  if (resolved.origin === "memory") return runMemoryTool(resolved, call);
8891
- if (resolved.origin === "custom") return runCustomTool(resolved, call);
9216
+ if (resolved.origin === "custom") return runCustomTool(resolved, call, inputs.signal);
8892
9217
  return runMcpTool(inputs, resolved, call);
8893
9218
  }
8894
9219
  async function runMemoryTool(resolved, call) {
8895
9220
  return runHandlerTool("memory", resolved.memoryHandler, call);
8896
9221
  }
8897
- async function runCustomTool(resolved, call) {
8898
- return runHandlerTool("custom", resolved.customHandler, call);
9222
+ async function runCustomTool(resolved, call, signal) {
9223
+ return runHandlerTool("custom", resolved.customHandler, call, signal);
8899
9224
  }
8900
- async function runHandlerTool(kind, handler, call) {
9225
+ async function runHandlerTool(kind, handler, call, signal) {
8901
9226
  if (handler === void 0) {
8902
9227
  return { stdout: "", stderr: `${kind} tool ${call.name} has no handler`, exitCode: 127 };
8903
9228
  }
8904
9229
  try {
8905
- const stdout = await handler(call.input);
9230
+ const stdout = await handler(call.input, { signal });
8906
9231
  return { stdout, stderr: "", exitCode: 0 };
8907
9232
  } catch (cause) {
8908
9233
  const message = cause instanceof Error ? cause.message : String(cause);
@@ -8948,22 +9273,66 @@ ${result.stderr}`.trim();
8948
9273
  return result.stdout.trim();
8949
9274
  }
8950
9275
 
9276
+ // src/internal/agent-loop/tool-timeout.ts
9277
+ var TOOL_ABORTED_EXIT = 124;
9278
+ function abortedResult(signal) {
9279
+ const reason = signal.reason;
9280
+ const timedOut = reason?.name === "TimeoutError";
9281
+ return {
9282
+ stdout: "",
9283
+ stderr: timedOut ? "tool execution timed out" : "tool execution aborted",
9284
+ exitCode: TOOL_ABORTED_EXIT
9285
+ };
9286
+ }
9287
+ function raceToolExecution(exec, opts) {
9288
+ const { signal, timeoutMs } = opts;
9289
+ if (signal === void 0 && timeoutMs === void 0) return exec;
9290
+ const signals = [];
9291
+ if (signal !== void 0) signals.push(signal);
9292
+ if (timeoutMs !== void 0) signals.push(AbortSignal.timeout(timeoutMs));
9293
+ const merged = signals.length === 1 ? signals[0] : AbortSignal.any(signals);
9294
+ if (merged.aborted) return Promise.resolve(abortedResult(merged));
9295
+ return new Promise((resolve3, reject) => {
9296
+ let settled = false;
9297
+ const onAbort = () => {
9298
+ if (settled) return;
9299
+ settled = true;
9300
+ resolve3(abortedResult(merged));
9301
+ };
9302
+ merged.addEventListener("abort", onAbort, { once: true });
9303
+ exec.then(
9304
+ (r) => {
9305
+ if (settled) return;
9306
+ settled = true;
9307
+ merged.removeEventListener("abort", onAbort);
9308
+ resolve3(r);
9309
+ },
9310
+ (e) => {
9311
+ if (settled) return;
9312
+ settled = true;
9313
+ merged.removeEventListener("abort", onAbort);
9314
+ reject(e);
9315
+ }
9316
+ );
9317
+ });
9318
+ }
9319
+
8951
9320
  // src/internal/agent-loop/tool-dispatch.ts
8952
- async function dispatchTools(inputs, tools, toolCalls, events) {
9321
+ async function dispatchTools(inputs, tools, toolCalls, events, parentSpan) {
8953
9322
  const maxConcurrent = inputs.maxConcurrentTools ?? 4;
8954
9323
  return mapWithConcurrency(
8955
9324
  toolCalls,
8956
9325
  maxConcurrent,
8957
- (call) => dispatchSingleCall(inputs, tools, call, events)
9326
+ (call) => dispatchSingleCall(inputs, tools, call, events, parentSpan)
8958
9327
  );
8959
9328
  }
8960
- async function dispatchSingleCall(inputs, tools, call, events) {
9329
+ async function dispatchSingleCall(inputs, tools, call, events, parentSpan) {
8961
9330
  const { call: workingCall, repairs } = applyRepairAndExtractCall(tools, call);
8962
9331
  const callId = generateCallId();
8963
9332
  const forkVeto = vetoFromForkWhitelist(inputs, workingCall, callId, events);
8964
9333
  if (forkVeto !== void 0) return forkVeto;
8965
9334
  const resolved = tools.find((tool) => tool.name === workingCall.name);
8966
- const toolSpan = startToolCallSpan(inputs, workingCall, resolved, callId, repairs);
9335
+ const toolSpan = startToolCallSpan(inputs, workingCall, resolved, callId, repairs, parentSpan);
8967
9336
  events.push(buildToolUseRunning(inputs, callId, workingCall));
8968
9337
  const pluginVeto = await vetoFromPluginPreHook(inputs, workingCall, callId, events);
8969
9338
  if (pluginVeto !== void 0) {
@@ -8980,6 +9349,13 @@ async function dispatchSingleCall(inputs, tools, call, events) {
8980
9349
  return fileVeto;
8981
9350
  }
8982
9351
  const result = await runToolWithLifecycle(inputs, resolved, workingCall, callId);
9352
+ await inputs.pluginManager?.runPostToolCallHooks({
9353
+ name: workingCall.name,
9354
+ args: workingCall.input,
9355
+ result: { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode },
9356
+ agentId: inputs.agentId,
9357
+ runId: inputs.runId
9358
+ });
8983
9359
  return finalizeSpanAndPostHook(inputs, workingCall, callId, result, events, toolSpan);
8984
9360
  }
8985
9361
  function applyRepairAndExtractCall(tools, call) {
@@ -9012,8 +9388,8 @@ function vetoFromForkWhitelist(inputs, call, callId, events) {
9012
9388
  content: `Tool blocked by fork whitelist: ${whitelistDecision.reason}`
9013
9389
  };
9014
9390
  }
9015
- function startToolCallSpan(inputs, call, resolved, callId, repairs) {
9016
- const toolSpan = inputs.telemetry?.startSpan("tool.call", {
9391
+ function startToolCallSpan(inputs, call, resolved, callId, repairs, parentSpan) {
9392
+ const toolSpan = inputs.telemetry?.startChildSpan(parentSpan, "tool.call", {
9017
9393
  "tool.name": call.name,
9018
9394
  "tool.origin": resolved?.origin ?? "unknown",
9019
9395
  callId
@@ -9077,8 +9453,14 @@ async function runToolWithLifecycle(inputs, resolved, call, callId) {
9077
9453
  conversationId: inputs.agentId,
9078
9454
  callId
9079
9455
  });
9080
- const result = await executeTool(inputs, resolved, call);
9456
+ const result = await raceToolExecution(executeTool(inputs, resolved, call), {
9457
+ signal: inputs.signal,
9458
+ timeoutMs: inputs.perToolTimeoutMs
9459
+ });
9081
9460
  const durationMs = Date.now() - startAt;
9461
+ inputs.telemetry?.recordHistogram(HISTOGRAM_NAMES.TOOL_CALL_DURATION_MS, durationMs, {
9462
+ "tool.name": call.name
9463
+ });
9082
9464
  if (result.exitCode !== void 0 && result.exitCode !== 0 && result.exitCode !== null) {
9083
9465
  await safeEmitToolHook(inputs.onToolError, {
9084
9466
  toolName: call.name,
@@ -9177,6 +9559,35 @@ function buildToolUseCompleted(inputs, callId, call, result) {
9177
9559
  };
9178
9560
  }
9179
9561
 
9562
+ // src/internal/agent-loop/tool-result-guard.ts
9563
+ var OPEN = "<untrusted-tool-output>";
9564
+ var CLOSE = "</untrusted-tool-output>";
9565
+ var PII_PATTERNS = [
9566
+ /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
9567
+ // email
9568
+ /\b(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g
9569
+ // phone
9570
+ ];
9571
+ function guardText(content, opts) {
9572
+ let out = content;
9573
+ if (opts.redactPii === true) {
9574
+ for (const re of PII_PATTERNS) out = out.replace(re, "[REDACTED]");
9575
+ }
9576
+ if (opts.delimit === true) {
9577
+ const safe3 = out.split(CLOSE).join("</ untrusted-tool-output>");
9578
+ out = `${OPEN}
9579
+ ${safe3}
9580
+ ${CLOSE}`;
9581
+ }
9582
+ return out;
9583
+ }
9584
+ function applyToolResultGuard(parts, opts) {
9585
+ if (opts.delimit !== true && opts.redactPii !== true) return parts;
9586
+ return parts.map(
9587
+ (p) => p.type === "tool_result" ? { ...p, content: guardText(p.content, opts) } : p
9588
+ );
9589
+ }
9590
+
9180
9591
  // src/internal/budget/pricing-data.json
9181
9592
  var pricing_data_default = {
9182
9593
  _meta: {
@@ -9527,6 +9938,11 @@ async function runAgentLoop(inputs) {
9527
9938
  try {
9528
9939
  const ctx = await initLoopContext(inputs);
9529
9940
  ctxRef = ctx;
9941
+ ctx.sendSpan = sendSpan;
9942
+ await inputs.pluginManager?.runOnSessionStartHooks({
9943
+ agentId: inputs.agentId,
9944
+ runId: inputs.runId
9945
+ });
9530
9946
  const budget = inputs.budget ?? new IterationBudget({ maxIterations: inputs.maxIterations ?? 8 });
9531
9947
  let lastTurnDecision;
9532
9948
  while (budget.shouldContinue()) {
@@ -9554,6 +9970,7 @@ async function runAgentLoop(inputs) {
9554
9970
  }
9555
9971
  budget.consume();
9556
9972
  inputs.budgetTracker?.nextIteration?.();
9973
+ if (inputs.signal?.aborted === true) break;
9557
9974
  }
9558
9975
  if (lastTurnDecision === "continue" && budget.shouldContinue() === false) {
9559
9976
  ctx.stoppedAtIterationLimit = true;
@@ -9593,6 +10010,10 @@ async function runAgentLoop(inputs) {
9593
10010
  ...ctx.stoppedByDoomLoop === true ? { stoppedByDoomLoop: true } : {}
9594
10011
  };
9595
10012
  } finally {
10013
+ await inputs.pluginManager?.runOnSessionEndHooks({
10014
+ agentId: inputs.agentId,
10015
+ runId: inputs.runId
10016
+ });
9596
10017
  if (ctxRef !== void 0 && ctxRef.memoryProviderHandle !== void 0 && inputs.memoryProvider !== void 0) {
9597
10018
  try {
9598
10019
  await inputs.memoryProvider.dispose(ctxRef.memoryProviderHandle);
@@ -9696,7 +10117,10 @@ async function finishOrReflect(inputs, ctx, llmOutput) {
9696
10117
  return "done";
9697
10118
  }
9698
10119
  async function runIteration(inputs, ctx) {
10120
+ const hookCtx = { agentId: inputs.agentId, runId: inputs.runId };
10121
+ await inputs.pluginManager?.runPreLlmCallHooks(hookCtx);
9699
10122
  const llmOutput = await streamLlmTurn(inputs, ctx);
10123
+ await inputs.pluginManager?.runPostLlmCallHooks(hookCtx);
9700
10124
  accumulateUsage(ctx.usage, llmOutput);
9701
10125
  if (inputs.budgetTracker !== void 0) {
9702
10126
  const modelId = inputs.model.id ?? "auto";
@@ -9722,6 +10146,13 @@ async function runIteration(inputs, ctx) {
9722
10146
  }
9723
10147
  return continueOrTerminate(inputs, ctx, llmOutput);
9724
10148
  }
10149
+ async function transformLlmOutputText(inputs, text, ctx) {
10150
+ return inputs.pluginManager !== void 0 ? inputs.pluginManager.runTransformLlmOutputHooks(text, ctx) : text;
10151
+ }
10152
+ async function guardAndTransformToolResults(inputs, raw, ctx) {
10153
+ const guarded = inputs.toolResultGuard !== void 0 ? applyToolResultGuard(raw, inputs.toolResultGuard) : raw;
10154
+ return inputs.pluginManager !== void 0 ? inputs.pluginManager.runTransformToolResultHooks(guarded, ctx) : guarded;
10155
+ }
9725
10156
  async function continueOrTerminate(inputs, ctx, llmOutput) {
9726
10157
  if (llmOutput.errored) return "error";
9727
10158
  if (llmOutput.text.length > 0) {
@@ -9730,8 +10161,18 @@ async function continueOrTerminate(inputs, ctx, llmOutput) {
9730
10161
  if (llmOutput.stopReason !== "tool_use" || llmOutput.toolCalls.length === 0) {
9731
10162
  return finishOrReflect(inputs, ctx, llmOutput);
9732
10163
  }
9733
- ctx.messages.push(buildAssistantTurn(llmOutput.text, llmOutput.toolCalls));
9734
- const toolResults = await dispatchTools(inputs, ctx.tools, llmOutput.toolCalls, ctx.events);
10164
+ const tCtx = { agentId: inputs.agentId, runId: inputs.runId };
10165
+ const outText = await transformLlmOutputText(inputs, llmOutput.text, tCtx);
10166
+ ctx.messages.push(buildAssistantTurn(outText, llmOutput.toolCalls));
10167
+ const rawResults = await dispatchTools(
10168
+ inputs,
10169
+ ctx.tools,
10170
+ llmOutput.toolCalls,
10171
+ ctx.events,
10172
+ ctx.sendSpan
10173
+ // M3 #64 — nest tool.call spans under agent.send
10174
+ );
10175
+ const toolResults = await guardAndTransformToolResults(inputs, rawResults, tCtx);
9735
10176
  ctx.messages.push({ role: "user", content: toolResults });
9736
10177
  if (inputs.onStep !== void 0) {
9737
10178
  const cb = inputs.onStep;
@@ -10161,6 +10602,56 @@ function mapAnthropicStatusToCode(status, body) {
10161
10602
  function formatMessage(status, code) {
10162
10603
  return `Anthropic API error: ${code} (HTTP ${status})`;
10163
10604
  }
10605
+ var cachedJsonrepair;
10606
+ function loadJsonrepair() {
10607
+ if (cachedJsonrepair === void 0) {
10608
+ const req = createRequire(import.meta.url);
10609
+ cachedJsonrepair = req("jsonrepair").jsonrepair;
10610
+ }
10611
+ return cachedJsonrepair;
10612
+ }
10613
+ function isPlainObject(v) {
10614
+ return v !== null && typeof v === "object" && !Array.isArray(v);
10615
+ }
10616
+ function toFiniteNumber(raw) {
10617
+ if (raw === "") return void 0;
10618
+ const n = Number(raw);
10619
+ return Number.isFinite(n) && String(n) === raw ? n : void 0;
10620
+ }
10621
+ function tryJson(raw, repair) {
10622
+ const t = raw.trimStart();
10623
+ if (!(t.startsWith("{") || t.startsWith("["))) return void 0;
10624
+ try {
10625
+ return JSON.parse(repair ? loadJsonrepair()(t) : t);
10626
+ } catch {
10627
+ return void 0;
10628
+ }
10629
+ }
10630
+ function heuristicCoerce(raw, repairJson) {
10631
+ if (raw === "true") return true;
10632
+ if (raw === "false") return false;
10633
+ if (raw === "null") return null;
10634
+ const n = toFiniteNumber(raw);
10635
+ if (n !== void 0) return n;
10636
+ const json = tryJson(raw, false) ?? (repairJson ? tryJson(raw, true) : void 0);
10637
+ return json === void 0 ? raw : json;
10638
+ }
10639
+ function coerceCandidates(raw, repairJson) {
10640
+ const out = [];
10641
+ if (raw === "true") out.push(true);
10642
+ else if (raw === "false") out.push(false);
10643
+ else if (raw === "null") out.push(null);
10644
+ const n = toFiniteNumber(raw);
10645
+ if (n !== void 0) out.push(n);
10646
+ const json = tryJson(raw, false) ?? (repairJson ? tryJson(raw, true) : void 0);
10647
+ if (json !== void 0) out.push(json);
10648
+ out.push(raw);
10649
+ return out;
10650
+ }
10651
+ function objectShape(schema) {
10652
+ const shape = schema?.shape;
10653
+ return shape !== null && typeof shape === "object" ? shape : void 0;
10654
+ }
10164
10655
 
10165
10656
  // src/internal/llm/finish.ts
10166
10657
  function collapseSystemText(system) {
@@ -10173,9 +10664,21 @@ function parseToolArguments(buffered) {
10173
10664
  try {
10174
10665
  return JSON.parse(buffered);
10175
10666
  } catch {
10667
+ const repaired = tryJson(buffered, true);
10668
+ if (isPlainObject(repaired)) return repaired;
10176
10669
  return { raw: buffered };
10177
10670
  }
10178
10671
  }
10672
+ function mapOpenAIFinish(reason) {
10673
+ switch (reason) {
10674
+ case "tool_calls":
10675
+ return "tool_use";
10676
+ case "length":
10677
+ return "max_tokens";
10678
+ default:
10679
+ return "end_turn";
10680
+ }
10681
+ }
10179
10682
  function makeLlmFinish(state2) {
10180
10683
  const finish = {
10181
10684
  stopReason: state2.stopReason,
@@ -11057,56 +11560,9 @@ function toOllamaTools(tools) {
11057
11560
  }
11058
11561
  }));
11059
11562
  }
11060
- var cachedJsonrepair;
11061
- function loadJsonrepair() {
11062
- if (cachedJsonrepair === void 0) {
11063
- const req = createRequire(import.meta.url);
11064
- cachedJsonrepair = req("jsonrepair").jsonrepair;
11065
- }
11066
- return cachedJsonrepair;
11067
- }
11068
- function isPlainObject(v) {
11069
- return v !== null && typeof v === "object" && !Array.isArray(v);
11070
- }
11071
- function toFiniteNumber(raw) {
11072
- if (raw === "") return void 0;
11073
- const n = Number(raw);
11074
- return Number.isFinite(n) && String(n) === raw ? n : void 0;
11075
- }
11076
- function tryJson(raw, repair) {
11077
- const t = raw.trimStart();
11078
- if (!(t.startsWith("{") || t.startsWith("["))) return void 0;
11079
- try {
11080
- return JSON.parse(repair ? loadJsonrepair()(t) : t);
11081
- } catch {
11082
- return void 0;
11083
- }
11084
- }
11085
- function heuristicCoerce(raw, repairJson) {
11086
- if (raw === "true") return true;
11087
- if (raw === "false") return false;
11088
- if (raw === "null") return null;
11089
- const n = toFiniteNumber(raw);
11090
- if (n !== void 0) return n;
11091
- const json = tryJson(raw, false) ?? (repairJson ? tryJson(raw, true) : void 0);
11092
- return json === void 0 ? raw : json;
11093
- }
11094
- function coerceCandidates(raw, repairJson) {
11095
- const out = [];
11096
- if (raw === "true") out.push(true);
11097
- else if (raw === "false") out.push(false);
11098
- else if (raw === "null") out.push(null);
11099
- const n = toFiniteNumber(raw);
11100
- if (n !== void 0) out.push(n);
11101
- const json = tryJson(raw, false) ?? (repairJson ? tryJson(raw, true) : void 0);
11102
- if (json !== void 0) out.push(json);
11103
- out.push(raw);
11104
- return out;
11105
- }
11106
- function objectShape(schema) {
11107
- const shape = schema?.shape;
11108
- return shape !== null && typeof shape === "object" ? shape : void 0;
11109
- }
11563
+
11564
+ // src/internal/llm/openai.ts
11565
+ init_errors();
11110
11566
 
11111
11567
  // src/sanitize/sanitize-tool-input.ts
11112
11568
  function applyTrim(key, value, ctx) {
@@ -11203,6 +11659,77 @@ function parseHermesParams(inner) {
11203
11659
  }
11204
11660
  return sanitizeToolInput(input, { trim: true }).value;
11205
11661
  }
11662
+ var STREAM_MARKER = "<function=";
11663
+ var DEFAULT_STREAM_BUFFER_CAP = 8192;
11664
+ var isStreamWs = (c) => c === " " || c === " " || c === "\n" || c === "\r";
11665
+ function streamToolCallBufferState(held, allowedToolNames, cap = DEFAULT_STREAM_BUFFER_CAP) {
11666
+ if (allowedToolNames.size === 0) return "impossible";
11667
+ const t = held.trimStart();
11668
+ if (t.length < STREAM_MARKER.length) {
11669
+ return STREAM_MARKER.startsWith(t) ? "possible" : "impossible";
11670
+ }
11671
+ if (!t.startsWith(STREAM_MARKER)) return "impossible";
11672
+ const parsed = parseStreamMarkerName(t);
11673
+ if (parsed === "building") return "possible";
11674
+ if (parsed === "invalid") return "impossible";
11675
+ const nameOk = parsed.complete ? allowedToolNames.has(parsed.name) : someToolNameStartsWith(allowedToolNames, parsed.name);
11676
+ if (!nameOk) return "impossible";
11677
+ return held.length > cap ? "impossible" : "possible";
11678
+ }
11679
+ function parseStreamMarkerName(t) {
11680
+ let cursor = STREAM_MARKER.length;
11681
+ while (cursor < t.length && isStreamWs(t[cursor])) cursor += 1;
11682
+ const nameStart = cursor;
11683
+ while (cursor < t.length && t[cursor] !== ">" && !isStreamWs(t[cursor])) cursor += 1;
11684
+ const name = t.slice(nameStart, cursor);
11685
+ if (name.length === 0) return cursor >= t.length ? "building" : "invalid";
11686
+ return { name, complete: cursor < t.length && t[cursor] === ">" };
11687
+ }
11688
+ function someToolNameStartsWith(allowedToolNames, prefix) {
11689
+ for (const name of allowedToolNames) {
11690
+ if (name.startsWith(prefix)) return true;
11691
+ }
11692
+ return false;
11693
+ }
11694
+ function firstPossibleMarkerStart(held, allowedToolNames) {
11695
+ for (let i = held.indexOf("<"); i !== -1; i = held.indexOf("<", i + 1)) {
11696
+ if (streamToolCallBufferState(held.slice(i), allowedToolNames) === "possible") return i;
11697
+ }
11698
+ return -1;
11699
+ }
11700
+ var StreamSuppressionBuffer = class {
11701
+ constructor(allowedToolNames) {
11702
+ this.allowedToolNames = allowedToolNames;
11703
+ }
11704
+ allowedToolNames;
11705
+ #held = "";
11706
+ /** Feed a content delta; returns the text to emit as a `text_delta` now, or `undefined` to hold. */
11707
+ push(content) {
11708
+ this.#held += content;
11709
+ if (streamToolCallBufferState(this.#held, this.allowedToolNames) === "possible")
11710
+ return void 0;
11711
+ const holdStart = firstPossibleMarkerStart(this.#held, this.allowedToolNames);
11712
+ if (holdStart > 0) {
11713
+ const flush2 = this.#held.slice(0, holdStart);
11714
+ this.#held = this.#held.slice(holdStart);
11715
+ return flush2;
11716
+ }
11717
+ const flush = this.#held;
11718
+ this.#held = "";
11719
+ return flush;
11720
+ }
11721
+ /** Drain the held buffer at stream end. `hasNativeCalls` mirrors `finish()`'s size-guard: when
11722
+ * native `tool_calls` exist, `finish()` won't strip the leaked block, so stream the held text WHOLE
11723
+ * (keeping `accumulatedText == finish.text`); otherwise strip the recoverable blocks. Idempotent. */
11724
+ drain(hasNativeCalls) {
11725
+ if (this.#held.length === 0) return void 0;
11726
+ const held = this.#held;
11727
+ this.#held = "";
11728
+ if (hasNativeCalls) return held;
11729
+ const residual = extractHermesToolCalls(held, () => "held", this.allowedToolNames).residualText;
11730
+ return residual.length > 0 ? residual : void 0;
11731
+ }
11732
+ };
11206
11733
 
11207
11734
  // src/internal/llm/openai.ts
11208
11735
  var OpenAIClient = class {
@@ -11280,8 +11807,12 @@ var OpenAIClient = class {
11280
11807
  // model was actually given. Empty set (no tools) recovers nothing.
11281
11808
  new Set(request.tools?.map((tool) => tool.name) ?? [])
11282
11809
  );
11810
+ let sawDone = false;
11283
11811
  for await (const record of parseSseStream(response.body, signal)) {
11284
- if (record.data === "[DONE]") break;
11812
+ if (record.data === "[DONE]") {
11813
+ sawDone = true;
11814
+ break;
11815
+ }
11285
11816
  let chunk;
11286
11817
  try {
11287
11818
  chunk = JSON.parse(record.data);
@@ -11301,6 +11832,13 @@ var OpenAIClient = class {
11301
11832
  const events = accumulator.consume(chunk);
11302
11833
  for (const event of events) yield event;
11303
11834
  }
11835
+ if (!sawDone && !accumulator.finishReasonSeen) {
11836
+ throw new NetworkError("SSE stream truncated (no finish_reason / [DONE])", {
11837
+ code: "stream_truncated"
11838
+ });
11839
+ }
11840
+ const drainEvent = accumulator.finalizeHeldText();
11841
+ if (drainEvent !== void 0) yield drainEvent;
11304
11842
  return accumulator.finish();
11305
11843
  }
11306
11844
  };
@@ -11316,6 +11854,7 @@ var OpenAIStreamAccumulator = class {
11316
11854
  this.extractFromContent = extractFromContent;
11317
11855
  this.providerName = providerName;
11318
11856
  this.allowedToolNames = allowedToolNames;
11857
+ this.suppress = extractFromContent && allowedToolNames !== void 0 && allowedToolNames.size > 0 ? new StreamSuppressionBuffer(allowedToolNames) : void 0;
11319
11858
  }
11320
11859
  extractFromContent;
11321
11860
  providerName;
@@ -11328,18 +11867,30 @@ var OpenAIStreamAccumulator = class {
11328
11867
  cacheWriteTokens;
11329
11868
  reasoningTokens;
11330
11869
  toolCalls = /* @__PURE__ */ new Map();
11870
+ /** R7: present only when recovery is enabled AND the request declares tools — holds suspected
11871
+ * leaked-dialect content back from the `text_delta` stream. `undefined` ⇒ stream immediately. */
11872
+ suppress;
11331
11873
  consume(chunk) {
11332
11874
  const events = [];
11333
11875
  this.applyUsage(chunk.usage);
11334
11876
  for (const choice of chunk.choices ?? []) {
11335
- const reasoningEvent = this.applyReasoningDelta(
11336
- choice.delta?.reasoning ?? choice.delta?.reasoning_content
11337
- );
11338
- if (reasoningEvent !== void 0) events.push(reasoningEvent);
11339
- const textEvent = this.applyContentDelta(choice.delta?.content);
11340
- if (textEvent !== void 0) events.push(textEvent);
11341
- this.mergeToolCallDeltas(choice.delta?.tool_calls);
11342
- this.applyFinishReason(choice.finish_reason);
11877
+ events.push(...this.applyChoice(choice));
11878
+ }
11879
+ return events;
11880
+ }
11881
+ applyChoice(choice) {
11882
+ const events = [];
11883
+ const reasoningEvent = this.applyReasoningDelta(
11884
+ choice.delta?.reasoning ?? choice.delta?.reasoning_content
11885
+ );
11886
+ if (reasoningEvent !== void 0) events.push(reasoningEvent);
11887
+ const textEvent = this.applyContentDelta(choice.delta?.content);
11888
+ if (textEvent !== void 0) events.push(textEvent);
11889
+ this.mergeToolCallDeltas(choice.delta?.tool_calls);
11890
+ this.applyFinishReason(choice.finish_reason);
11891
+ if (choice.finish_reason !== void 0 && choice.finish_reason !== null) {
11892
+ const flushEvent = this.finalizeHeldText();
11893
+ if (flushEvent !== void 0) events.push(flushEvent);
11343
11894
  }
11344
11895
  return events;
11345
11896
  }
@@ -11364,7 +11915,17 @@ var OpenAIStreamAccumulator = class {
11364
11915
  applyContentDelta(content) {
11365
11916
  if (typeof content !== "string" || content.length === 0) return void 0;
11366
11917
  this.text += content;
11367
- return { type: "text_delta", text: content };
11918
+ if (this.suppress === void 0) return { type: "text_delta", text: content };
11919
+ const emit = this.suppress.push(content);
11920
+ return emit !== void 0 ? { type: "text_delta", text: emit } : void 0;
11921
+ }
11922
+ /** R7 held-buffer finalizer, called at the `finish_reason` chunk (in `applyChoice`) AND after the
11923
+ * SSE loop in `stream()` — so a stream that omits a `finish_reason` terminal never silently drops
11924
+ * held text. `toolCalls.size > 0` (native calls present) makes `finish()` skip recovery, so the
11925
+ * buffer streams the held text whole. Idempotent once drained. */
11926
+ finalizeHeldText() {
11927
+ const emit = this.suppress?.drain(this.toolCalls.size > 0);
11928
+ return emit !== void 0 ? { type: "text_delta", text: emit } : void 0;
11368
11929
  }
11369
11930
  mergeToolCallDeltas(deltas) {
11370
11931
  for (const call of deltas ?? []) {
@@ -11375,8 +11936,15 @@ var OpenAIStreamAccumulator = class {
11375
11936
  this.toolCalls.set(call.index, existing);
11376
11937
  }
11377
11938
  }
11939
+ /** M2 #61 — true once any chunk carried a non-null `finish_reason` (else a
11940
+ * stream ending without `[DONE]` is a truncation, not a clean end). */
11941
+ sawFinishReason = false;
11942
+ get finishReasonSeen() {
11943
+ return this.sawFinishReason;
11944
+ }
11378
11945
  applyFinishReason(reason) {
11379
11946
  if (reason === void 0 || reason === null) return;
11947
+ this.sawFinishReason = true;
11380
11948
  this.stopReason = mapOpenAIFinish(reason);
11381
11949
  }
11382
11950
  finish() {
@@ -11421,18 +11989,6 @@ var OpenAIStreamAccumulator = class {
11421
11989
  });
11422
11990
  }
11423
11991
  };
11424
- function mapOpenAIFinish(reason) {
11425
- switch (reason) {
11426
- case "tool_calls":
11427
- return "tool_use";
11428
- case "length":
11429
- return "max_tokens";
11430
- case "stop":
11431
- return "end_turn";
11432
- default:
11433
- return "end_turn";
11434
- }
11435
- }
11436
11992
  function applyReasoningRequest(body, effort, providerName) {
11437
11993
  if (providerName === "openai") {
11438
11994
  body.reasoning_effort = effort;
@@ -11530,19 +12086,76 @@ function assistantMessage(message) {
11530
12086
 
11531
12087
  // src/internal/llm/pool-aware-client.ts
11532
12088
  init_errors();
12089
+
12090
+ // src/internal/resilience/circuit-breaker.ts
12091
+ var DEFAULT_MAX_TIMEOUTS = 3;
12092
+ var DEFAULT_COOLDOWN_MS2 = 6e4;
12093
+ var CircuitBreaker = class {
12094
+ constructor(opts = {}) {
12095
+ this.opts = opts;
12096
+ }
12097
+ opts;
12098
+ states = /* @__PURE__ */ new Map();
12099
+ /** @returns true when the breaker is open and the call should be skipped. */
12100
+ shouldSkip(key) {
12101
+ const state2 = this.states.get(key);
12102
+ if (state2 === void 0) return false;
12103
+ if (state2.cooldownUntilMs === 0) return false;
12104
+ if (this.now() < state2.cooldownUntilMs) return true;
12105
+ state2.cooldownUntilMs = 0;
12106
+ state2.consecutiveTimeouts = 0;
12107
+ return false;
12108
+ }
12109
+ recordSuccess(key) {
12110
+ const state2 = this.states.get(key);
12111
+ if (state2 === void 0) return;
12112
+ state2.consecutiveTimeouts = 0;
12113
+ state2.cooldownUntilMs = 0;
12114
+ }
12115
+ recordTimeout(key) {
12116
+ const state2 = this.states.get(key) ?? { consecutiveTimeouts: 0, cooldownUntilMs: 0 };
12117
+ state2.consecutiveTimeouts += 1;
12118
+ if (state2.consecutiveTimeouts >= (this.opts.maxTimeouts ?? DEFAULT_MAX_TIMEOUTS)) {
12119
+ state2.cooldownUntilMs = this.now() + (this.opts.cooldownMs ?? DEFAULT_COOLDOWN_MS2);
12120
+ }
12121
+ this.states.set(key, state2);
12122
+ }
12123
+ /** @internal — tests inspect counter state. */
12124
+ inspect(key) {
12125
+ return this.states.get(key) ?? { consecutiveTimeouts: 0, cooldownUntilMs: 0 };
12126
+ }
12127
+ now() {
12128
+ return this.opts.now?.() ?? Date.now();
12129
+ }
12130
+ };
12131
+
12132
+ // src/internal/llm/pool-aware-client.ts
12133
+ init_retry();
11533
12134
  var PoolAwareLlmClient = class {
11534
- constructor(pool, buildClient2, waitForAvailableMs = 3e4) {
12135
+ constructor(pool, buildClient2, waitForAvailableMs = 3e4, resilience = {}) {
11535
12136
  this.pool = pool;
11536
12137
  this.buildClient = buildClient2;
11537
12138
  this.waitForAvailableMs = waitForAvailableMs;
11538
12139
  this.name = `pool-aware:${pool.provider}`;
12140
+ this.breaker = resilience.breaker ?? new CircuitBreaker();
12141
+ this.backoffBaseMs = resilience.backoffBaseMs;
12142
+ this.rng = resilience.rng;
11539
12143
  }
11540
12144
  pool;
11541
12145
  buildClient;
11542
12146
  waitForAvailableMs;
11543
12147
  name;
12148
+ /** M2 #60 — provider-level circuit breaker (consecutive-failure). */
12149
+ breaker;
12150
+ backoffBaseMs;
12151
+ rng;
11544
12152
  // 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.
11545
12153
  async *stream(request, signal) {
12154
+ if (this.breaker.shouldSkip(this.pool.provider)) {
12155
+ throw new NetworkError(`${this.pool.provider} circuit open \u2014 failing fast`, {
12156
+ code: "circuit_open"
12157
+ });
12158
+ }
11546
12159
  let hasRetried429 = false;
11547
12160
  while (true) {
11548
12161
  if (signal.aborted) throw abortError2(signal);
@@ -11557,6 +12170,7 @@ var PoolAwareLlmClient = class {
11557
12170
  }
11558
12171
  }
11559
12172
  if (entry === null) {
12173
+ this.breaker.recordTimeout(this.pool.provider);
11560
12174
  throw new CredentialPoolExhaustedError(
11561
12175
  `All ${this.pool.provider} credentials exhausted; next retry available at ${this.nextRetryHint() ?? "unknown"}`,
11562
12176
  { provider: this.pool.provider, nextRetryAt: this.nextRetryHint() }
@@ -11566,10 +12180,19 @@ var PoolAwareLlmClient = class {
11566
12180
  const realClient = this.buildClient(entry.accessToken);
11567
12181
  const attempt = await tryFirstEvent(realClient, request, signal);
11568
12182
  if (attempt.kind === "ok") {
12183
+ this.breaker.recordSuccess(this.pool.provider);
11569
12184
  return yield* relayStream(attempt.generator, attempt.firstResult);
11570
12185
  }
11571
12186
  const decision = classifyAndDecide(attempt.error, hasRetried429);
11572
12187
  if (decision === "retry") {
12188
+ await sleepWithAbort(
12189
+ computeBackoffMs({
12190
+ attempt: 0,
12191
+ ...this.backoffBaseMs !== void 0 ? { baseMs: this.backoffBaseMs } : {},
12192
+ ...this.rng !== void 0 ? { rng: this.rng } : {}
12193
+ }),
12194
+ signal
12195
+ );
11573
12196
  hasRetried429 = true;
11574
12197
  continue;
11575
12198
  }
@@ -11589,6 +12212,7 @@ var PoolAwareLlmClient = class {
11589
12212
  hasRetried429 = false;
11590
12213
  continue;
11591
12214
  }
12215
+ this.breaker.recordTimeout(this.pool.provider);
11592
12216
  throw attempt.error;
11593
12217
  }
11594
12218
  }
@@ -12023,9 +12647,28 @@ function selectTransport(profile, apiKey) {
12023
12647
 
12024
12648
  // src/internal/mcp/client.ts
12025
12649
  init_errors();
12026
- function createMcpClient(name, config) {
12650
+ function createMcpClient(name, config, fetchImpl = fetch) {
12027
12651
  if (isStdio(config)) return new StdioMcpClient(name, config);
12028
- return new HttpMcpClient(name, config);
12652
+ return new HttpMcpClient(name, config, fetchImpl);
12653
+ }
12654
+ var DEFAULT_MCP_TIMEOUT_MS = 3e4;
12655
+ var MAX_STDIO_BUFFER_BYTES = 8 * 1024 * 1024;
12656
+ var RECONNECT_BASE_MS = 250;
12657
+ var MAX_RECONNECT_ATTEMPTS = 2;
12658
+ function reconnectDelay(attempt) {
12659
+ const ceiling = RECONNECT_BASE_MS * 2 ** attempt;
12660
+ const ms = Math.floor(Math.random() * ceiling);
12661
+ return ms <= 0 ? Promise.resolve() : new Promise((resolve3) => setTimeout(resolve3, ms));
12662
+ }
12663
+ function mcpTimeoutError(name, timeoutMs) {
12664
+ return new NetworkError(`MCP ${name} request timed out after ${timeoutMs}ms`, {
12665
+ code: "mcp_timeout"
12666
+ });
12667
+ }
12668
+ function isAbortLike(cause) {
12669
+ if (typeof cause !== "object" || cause === null || !("name" in cause)) return false;
12670
+ const name = cause.name;
12671
+ return name === "TimeoutError" || name === "AbortError";
12029
12672
  }
12030
12673
  async function rpcInitialize(request) {
12031
12674
  await request("initialize", {
@@ -12071,32 +12714,107 @@ var StdioMcpClient = class extends BaseMcpClient {
12071
12714
  name;
12072
12715
  child;
12073
12716
  nextId = 1;
12717
+ // #59 — pending requests carry a reject + timer so a silent server times out
12718
+ // (typed error), a late reply after timeout is a no-op, and close() settles them.
12074
12719
  pending = /* @__PURE__ */ new Map();
12075
12720
  buffer = "";
12076
- async initialize() {
12721
+ // M2 #59 — reconnect-after-drop state. `dropped` is set when the child exits
12722
+ // unexpectedly OR times out (not via close()); the next request re-spawns with
12723
+ // backoff. `reconnectPromise` is a SINGLE in-flight reconnect shared by every
12724
+ // concurrent request so parallel tool dispatch after a drop awaits one handshake
12725
+ // instead of racing (or spuriously failing with mcp_not_init).
12726
+ dropped = false;
12727
+ reconnectAttempts = 0;
12728
+ reconnectPromise;
12729
+ get timeoutMs() {
12730
+ return this.config.requestTimeoutMs ?? DEFAULT_MCP_TIMEOUT_MS;
12731
+ }
12732
+ /** Spawn the server child and wire stdout/stderr/error/exit handlers.
12733
+ * Shared by `initialize()` and the M2 #59 reconnect path. */
12734
+ spawnChild() {
12077
12735
  const resolvedCwd = resolveMcpCwd(this.config.cwd);
12078
12736
  const child = spawn(this.config.command, this.config.args ?? [], {
12079
12737
  cwd: resolvedCwd,
12080
- env: { ...process.env, ...this.config.env ?? {} }
12738
+ // #54 (F-H1) a third-party MCP server binary must not inherit host
12739
+ // secrets. Scrub secret-like vars by default; `config.env` still wins.
12740
+ env: resolveChildEnv({ policy: this.config.envPolicy, overrides: this.config.env })
12081
12741
  });
12082
12742
  this.child = child;
12083
12743
  child.stdout.on("data", (chunk) => this.consume(chunk));
12084
12744
  child.stderr.on("data", () => void 0);
12745
+ child.stdin.on("error", () => void 0);
12085
12746
  child.on("error", () => {
12086
- for (const resolver of this.pending.values()) {
12087
- resolver({ error: { message: "MCP process crashed" } });
12088
- }
12089
- this.pending.clear();
12747
+ this.rejectAllPending(
12748
+ new NetworkError(`MCP ${this.name} process crashed`, { code: "mcp_crashed" })
12749
+ );
12750
+ });
12751
+ child.on("exit", () => {
12752
+ if (this.child !== child) return;
12753
+ this.child = void 0;
12754
+ this.dropped = true;
12755
+ this.rejectAllPending(
12756
+ new NetworkError(`MCP ${this.name} disconnected`, { code: "mcp_disconnected" })
12757
+ );
12090
12758
  });
12759
+ }
12760
+ async initialize() {
12761
+ this.spawnChild();
12091
12762
  await super.initialize();
12092
12763
  }
12764
+ /** M2 #59 — ensure a live child before a request. Reconnect (bounded, with
12765
+ * full-jitter backoff) when the client was dropped; fail fast when never
12766
+ * initialized. Concurrent callers share ONE reconnect handshake. */
12767
+ ensureConnected() {
12768
+ if (this.child !== void 0) return Promise.resolve();
12769
+ if (!this.dropped) {
12770
+ return Promise.reject(
12771
+ new ConfigurationError(`MCP ${this.name} is not initialized`, { code: "mcp_not_init" })
12772
+ );
12773
+ }
12774
+ this.reconnectPromise ??= this.reconnect().finally(() => {
12775
+ this.reconnectPromise = void 0;
12776
+ });
12777
+ return this.reconnectPromise;
12778
+ }
12779
+ async reconnect() {
12780
+ if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
12781
+ throw new NetworkError(`MCP ${this.name} reconnect exhausted`, { code: "mcp_disconnected" });
12782
+ }
12783
+ await reconnectDelay(this.reconnectAttempts);
12784
+ this.reconnectAttempts += 1;
12785
+ this.spawnChild();
12786
+ await super.initialize();
12787
+ this.dropped = false;
12788
+ this.reconnectAttempts = 0;
12789
+ }
12093
12790
  async close() {
12094
- if (this.child === void 0) return;
12095
- this.child.kill("SIGTERM");
12791
+ this.rejectAllPending(new NetworkError(`MCP ${this.name} closed`, { code: "mcp_closed" }));
12792
+ const child = this.child;
12096
12793
  this.child = void 0;
12794
+ this.dropped = false;
12795
+ child?.kill("SIGTERM");
12796
+ }
12797
+ /** Reject + clear every pending request (crash / close). @internal */
12798
+ rejectAllPending(error) {
12799
+ for (const entry of this.pending.values()) {
12800
+ clearTimeout(entry.timer);
12801
+ entry.reject(error);
12802
+ }
12803
+ this.pending.clear();
12097
12804
  }
12098
12805
  consume(chunk) {
12099
12806
  this.buffer += chunk.toString("utf8");
12807
+ if (this.buffer.length > MAX_STDIO_BUFFER_BYTES) {
12808
+ this.buffer = "";
12809
+ this.rejectAllPending(
12810
+ new NetworkError(`MCP ${this.name} exceeded stdout buffer limit`, {
12811
+ code: "mcp_buffer_overflow"
12812
+ })
12813
+ );
12814
+ this.child?.kill("SIGKILL");
12815
+ this.child = void 0;
12816
+ return;
12817
+ }
12100
12818
  let newlineIndex = this.buffer.indexOf("\n");
12101
12819
  while (newlineIndex !== -1) {
12102
12820
  const line = this.buffer.slice(0, newlineIndex).trim();
@@ -12113,23 +12831,47 @@ var StdioMcpClient = class extends BaseMcpClient {
12113
12831
  return;
12114
12832
  }
12115
12833
  if (typeof message.id !== "number") return;
12116
- const resolver = this.pending.get(message.id);
12117
- if (resolver === void 0) return;
12834
+ const entry = this.pending.get(message.id);
12835
+ if (entry === void 0) return;
12118
12836
  this.pending.delete(message.id);
12119
- resolver(message);
12837
+ clearTimeout(entry.timer);
12838
+ entry.resolve(message);
12120
12839
  }
12121
12840
  request(method, params) {
12122
- if (this.child === void 0) {
12123
- return Promise.reject(
12124
- new ConfigurationError(`MCP ${this.name} is not initialized`, { code: "mcp_not_init" })
12125
- );
12841
+ const child = this.child;
12842
+ if (child !== void 0) return this.send(child, method, params);
12843
+ if (this.dropped) return this.reconnectAndRequest(method, params);
12844
+ return Promise.reject(
12845
+ new ConfigurationError(`MCP ${this.name} is not initialized`, { code: "mcp_not_init" })
12846
+ );
12847
+ }
12848
+ /** M2 #59 — reconnect a dropped client, then send. Separate async path so the
12849
+ * happy path above never pays an extra microtask tick. */
12850
+ async reconnectAndRequest(method, params) {
12851
+ await this.ensureConnected();
12852
+ const child = this.child;
12853
+ if (child === void 0) {
12854
+ throw new ConfigurationError(`MCP ${this.name} is not initialized`, { code: "mcp_not_init" });
12126
12855
  }
12856
+ return this.send(child, method, params);
12857
+ }
12858
+ send(child, method, params) {
12127
12859
  const id = this.nextId++;
12128
12860
  const payload = { jsonrpc: "2.0", id, method, params };
12129
- this.child.stdin.write(`${JSON.stringify(payload)}
12861
+ child.stdin.write(`${JSON.stringify(payload)}
12130
12862
  `);
12131
- return new Promise((resolve3) => {
12132
- this.pending.set(id, resolve3);
12863
+ return new Promise((resolve3, reject) => {
12864
+ const timer = setTimeout(() => {
12865
+ this.pending.delete(id);
12866
+ reject(mcpTimeoutError(this.name, this.timeoutMs));
12867
+ this.child?.kill("SIGKILL");
12868
+ this.child = void 0;
12869
+ this.dropped = true;
12870
+ this.rejectAllPending(
12871
+ new NetworkError(`MCP ${this.name} disconnected`, { code: "mcp_disconnected" })
12872
+ );
12873
+ }, this.timeoutMs);
12874
+ this.pending.set(id, { resolve: resolve3, reject, timer });
12133
12875
  });
12134
12876
  }
12135
12877
  };
@@ -12155,11 +12897,20 @@ var HttpMcpClient = class extends BaseMcpClient {
12155
12897
  accept: "application/json",
12156
12898
  ...this.config.headers ?? {}
12157
12899
  };
12158
- const response = await this.fetchImpl(this.config.url, {
12159
- method: "POST",
12160
- headers,
12161
- body: JSON.stringify(payload)
12162
- });
12900
+ const timeoutMs = this.config.requestTimeoutMs ?? DEFAULT_MCP_TIMEOUT_MS;
12901
+ let response;
12902
+ try {
12903
+ response = await this.fetchImpl(this.config.url, {
12904
+ method: "POST",
12905
+ headers,
12906
+ body: JSON.stringify(payload),
12907
+ // #59 — bound the request; a non-responding endpoint aborts here.
12908
+ signal: AbortSignal.timeout(timeoutMs)
12909
+ });
12910
+ } catch (cause) {
12911
+ if (isAbortLike(cause)) throw mcpTimeoutError(this.name, timeoutMs);
12912
+ throw cause;
12913
+ }
12163
12914
  if (!response.ok) {
12164
12915
  throw new NetworkError(`MCP ${this.name} returned ${response.status}`, {
12165
12916
  code: "mcp_http_error"
@@ -12274,11 +13025,33 @@ function resolveRunProvider(options) {
12274
13025
  );
12275
13026
  }
12276
13027
  const parsedModel = parseModelId(options.model?.id);
12277
- const inferredProvider = parsedModel.provider !== void 0 && getProviderProfile(parsedModel.provider) !== void 0 ? parsedModel.provider : void 0;
12278
- const primary = options.agentOptions.providers?.routes?.[0]?.provider ?? inferredProvider ?? detectPrimaryProvider();
12279
- const effectiveModelId = inferredProvider !== void 0 ? parsedModel.name : options.model?.id ?? "claude-sonnet-4-6";
13028
+ const modelInferredProvider = parsedModel.provider !== void 0 && getProviderProfile(parsedModel.provider) !== void 0 ? parsedModel.provider : void 0;
13029
+ const keyInferredProvider = inferProviderFromApiKey(options.agentOptions.apiKey);
13030
+ const primary = options.agentOptions.providers?.routes?.[0]?.provider ?? keyInferredProvider ?? modelInferredProvider ?? detectPrimaryProvider();
13031
+ const effectiveModelId = modelInferredProvider !== void 0 && modelInferredProvider === primary ? parsedModel.name : options.model?.id ?? "claude-sonnet-4-6";
12280
13032
  return { primary, effectiveModelId };
12281
13033
  }
13034
+ function inferProviderFromApiKey(apiKey) {
13035
+ if (apiKey === void 0 || apiKey.length === 0) return void 0;
13036
+ const byPrefix = [
13037
+ { provider: "openrouter", prefix: "sk-or-" },
13038
+ { provider: "anthropic", prefix: "sk-ant-" },
13039
+ { provider: "openai", prefix: "sk-" }
13040
+ ];
13041
+ for (const { provider, prefix } of byPrefix) {
13042
+ if (apiKey.startsWith(prefix) && getProviderProfile(provider) !== void 0) {
13043
+ return provider;
13044
+ }
13045
+ }
13046
+ return void 0;
13047
+ }
13048
+ function mergeExplicitApiKey(pools, primary, apiKey) {
13049
+ if (apiKey === void 0 || apiKey.length === 0) return pools;
13050
+ if (isFixtureApiKey(apiKey) || apiKey === LOCAL_RUNTIME_MOCK_KEY) return pools;
13051
+ const existing = pools?.[primary];
13052
+ if (existing !== void 0 && existing.length > 0) return pools;
13053
+ return { ...pools ?? {}, [primary]: [apiKey] };
13054
+ }
12282
13055
  function buildLoopInputs(options, runId, userText) {
12283
13056
  const maxIterations = options.sendOptions.maxIterations;
12284
13057
  if (maxIterations !== void 0 && (!Number.isInteger(maxIterations) || maxIterations < 1)) {
@@ -12289,7 +13062,11 @@ function buildLoopInputs(options, runId, userText) {
12289
13062
  }
12290
13063
  const { primary, effectiveModelId } = resolveRunProvider(options);
12291
13064
  const fallback = options.agentOptions.providers?.fallback;
12292
- const apiKeys = options.agentOptions.providers?.apiKeys;
13065
+ const apiKeys = mergeExplicitApiKey(
13066
+ options.agentOptions.providers?.apiKeys,
13067
+ primary,
13068
+ options.agentOptions.apiKey
13069
+ );
12293
13070
  const credentialPoolStrategy = options.agentOptions.providers?.credentialPoolStrategy;
12294
13071
  const extractToolCallsFromContent = options.agentOptions.providers?.routes?.[0]?.extractToolCallsFromContent;
12295
13072
  const chain = resolveProviderChain({
@@ -12333,6 +13110,10 @@ function buildLoopInputs(options, runId, userText) {
12333
13110
  // D318 — forward SendOptions.signal to the agent loop so streamLlmTurn
12334
13111
  // can attach it to the LLM `fetch({ signal })` call.
12335
13112
  ...options.sendOptions.signal !== void 0 ? { signal: options.sendOptions.signal } : {},
13113
+ // #58 / #57 — forward the per-tool timeout + tool-result guard so a consumer
13114
+ // can enable them via SendOptions (not only internal AgentLoopInputs).
13115
+ ...options.sendOptions.perToolTimeoutMs !== void 0 ? { perToolTimeoutMs: options.sendOptions.perToolTimeoutMs } : {},
13116
+ ...options.sendOptions.toolResultGuard !== void 0 ? { toolResultGuard: options.sendOptions.toolResultGuard } : {},
12336
13117
  // M1-2: per-send iteration ceiling (validated above). The loop reads
12337
13118
  // inputs.maxIterations (default 8 when unset).
12338
13119
  ...maxIterations !== void 0 ? { maxIterations } : {},
@@ -12649,7 +13430,12 @@ async function runActiveMemory(args) {
12649
13430
  hits: []
12650
13431
  });
12651
13432
  }
12652
- const cached2 = args.cache?.get(args.userText, cfg.queryMode);
13433
+ const tenantCtx = {
13434
+ namespace: args.namespace,
13435
+ userId: args.userId,
13436
+ scope: args.scope
13437
+ };
13438
+ const cached2 = args.cache?.get(args.userText, cfg.queryMode, tenantCtx);
12653
13439
  if (cached2 !== void 0) return endRecallSpan(span, args, cached2);
12654
13440
  const query = buildQuery(args.userText, args.priorMessages, cfg.queryMode, cfg.recentUserTurns);
12655
13441
  if (query.trim().length === 0) {
@@ -12734,7 +13520,12 @@ function notifyBreaker(breaker, key, status) {
12734
13520
  else if (status === "ok" || status === "no-recall") breaker.recordSuccess(key);
12735
13521
  }
12736
13522
  async function finalize(args, queryMode, result) {
12737
- args.cache?.set(args.userText, queryMode, result);
13523
+ const tenantCtx = {
13524
+ namespace: args.namespace,
13525
+ userId: args.userId,
13526
+ scope: args.scope
13527
+ };
13528
+ args.cache?.set(args.userText, queryMode, result, tenantCtx);
12738
13529
  if (args.persistTranscripts === true && args.cwd !== void 0) {
12739
13530
  await persistActiveMemoryTranscript(args.cwd, {
12740
13531
  runId: args.runId ?? `run-${Date.now()}`,
@@ -13336,48 +14127,6 @@ var MEMORY_EMBEDDING_ADAPTERS = {
13336
14127
  gemini: geminiMemoryEmbeddingProviderAdapter
13337
14128
  };
13338
14129
 
13339
- // src/internal/memory/circuit-breaker.ts
13340
- var DEFAULT_MAX_TIMEOUTS = 3;
13341
- var DEFAULT_COOLDOWN_MS2 = 6e4;
13342
- var CircuitBreaker = class {
13343
- constructor(opts = {}) {
13344
- this.opts = opts;
13345
- }
13346
- opts;
13347
- states = /* @__PURE__ */ new Map();
13348
- /** @returns true when the breaker is open and the call should be skipped. */
13349
- shouldSkip(key) {
13350
- const state2 = this.states.get(key);
13351
- if (state2 === void 0) return false;
13352
- if (state2.cooldownUntilMs === 0) return false;
13353
- if (this.now() < state2.cooldownUntilMs) return true;
13354
- state2.cooldownUntilMs = 0;
13355
- state2.consecutiveTimeouts = 0;
13356
- return false;
13357
- }
13358
- recordSuccess(key) {
13359
- const state2 = this.states.get(key);
13360
- if (state2 === void 0) return;
13361
- state2.consecutiveTimeouts = 0;
13362
- state2.cooldownUntilMs = 0;
13363
- }
13364
- recordTimeout(key) {
13365
- const state2 = this.states.get(key) ?? { consecutiveTimeouts: 0, cooldownUntilMs: 0 };
13366
- state2.consecutiveTimeouts += 1;
13367
- if (state2.consecutiveTimeouts >= (this.opts.maxTimeouts ?? DEFAULT_MAX_TIMEOUTS)) {
13368
- state2.cooldownUntilMs = this.now() + (this.opts.cooldownMs ?? DEFAULT_COOLDOWN_MS2);
13369
- }
13370
- this.states.set(key, state2);
13371
- }
13372
- /** @internal — tests inspect counter state. */
13373
- inspect(key) {
13374
- return this.states.get(key) ?? { consecutiveTimeouts: 0, cooldownUntilMs: 0 };
13375
- }
13376
- now() {
13377
- return this.opts.now?.() ?? Date.now();
13378
- }
13379
- };
13380
-
13381
14130
  // src/internal/persistence/fts5-sanitize.ts
13382
14131
  var PHRASE_OPEN = "";
13383
14132
  var PHRASE_CLOSE = "";
@@ -16611,7 +17360,9 @@ var SandboxBackend = class {
16611
17360
  this.config = {
16612
17361
  workDir: config.workDir ?? "/tmp",
16613
17362
  timeoutMs: config.timeoutMs ?? 3e4,
16614
- maxOutputBytes: config.maxOutputBytes ?? 5 * 1024 * 1024
17363
+ maxOutputBytes: config.maxOutputBytes ?? 5 * 1024 * 1024,
17364
+ // #54 — preserve the env policy so backends can scrub secrets.
17365
+ env: config.env ?? "inherit-scrubbed"
16615
17366
  };
16616
17367
  }
16617
17368
  async readFile(path) {
@@ -16681,7 +17432,9 @@ var LocalSandbox = class extends SandboxBackend {
16681
17432
  cwd: this.config.workDir,
16682
17433
  timeout,
16683
17434
  maxBuffer: max,
16684
- encoding: "utf-8"
17435
+ encoding: "utf-8",
17436
+ // #54 — scrub secret-like host env vars from the child by default.
17437
+ env: resolveChildEnv({ policy: this.config.env })
16685
17438
  },
16686
17439
  (error, stdout, stderr) => {
16687
17440
  resolve3(this.buildResult(error, stdout ?? "", stderr ?? ""));