@theokit/sdk 2.15.2 → 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 +65 -0
  2. package/dist/a2a/index.cjs +875 -198
  3. package/dist/a2a/index.cjs.map +1 -1
  4. package/dist/a2a/index.js +876 -199
  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 +840 -187
  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 +840 -187
  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 +846 -189
  19. package/dist/eval.cjs.map +1 -1
  20. package/dist/eval.js +846 -189
  21. package/dist/eval.js.map +1 -1
  22. package/dist/event-bus.d.ts +3 -0
  23. package/dist/index.cjs +977 -215
  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 +977 -217
  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 +4 -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/a2a/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, realpathSync, mkdirSync, lstatSync, readlinkSync, readFileSync, readdirSync } from 'fs';
2
2
  import { randomUUID, createHash, randomBytes } from 'crypto';
3
3
  import { join, dirname, resolve, sep, relative, isAbsolute } from 'path';
4
- import { stat, mkdir, readFile, rename, rm, readdir, appendFile, open, unlink, statfs, access } from 'fs/promises';
4
+ import { stat, mkdir, readFile, rename, rm, readdir, open, unlink, appendFile, statfs, access } from 'fs/promises';
5
5
  import { homedir } from 'os';
6
6
  import { z, toJSONSchema } from 'zod';
7
7
  import { createRequire } from 'module';
@@ -2545,13 +2545,13 @@ var init_cloud_run = __esm({
2545
2545
  });
2546
2546
 
2547
2547
  // src/internal/llm/sse.ts
2548
- async function* parseSseStream(body, signal) {
2548
+ async function* parseSseStream(body, signal, idleTimeoutMs = DEFAULT_SSE_IDLE_MS) {
2549
2549
  if (body === null) return;
2550
2550
  const reader = body.getReader();
2551
2551
  const decoder = new TextDecoder("utf-8");
2552
2552
  const state2 = { buffer: "", event: "message", data: "" };
2553
2553
  try {
2554
- for await (const chunk of readChunks(reader, signal)) {
2554
+ for await (const chunk of readChunks(reader, signal, idleTimeoutMs)) {
2555
2555
  state2.buffer += decoder.decode(chunk, { stream: true });
2556
2556
  for (const record of drainCompleteRecords(state2)) yield record;
2557
2557
  }
@@ -2561,14 +2561,38 @@ async function* parseSseStream(body, signal) {
2561
2561
  releaseReader(reader);
2562
2562
  }
2563
2563
  }
2564
- async function* readChunks(reader, signal) {
2564
+ async function* readChunks(reader, signal, idleTimeoutMs) {
2565
2565
  while (true) {
2566
2566
  if (signal.aborted) return;
2567
- const { value, done } = await reader.read();
2567
+ const { value, done } = await readWithIdleTimeout(reader, idleTimeoutMs);
2568
2568
  if (done) return;
2569
2569
  if (value !== void 0) yield value;
2570
2570
  }
2571
2571
  }
2572
+ function readWithIdleTimeout(reader, idleTimeoutMs) {
2573
+ if (idleTimeoutMs <= 0) return reader.read();
2574
+ return new Promise(
2575
+ (resolve3, reject) => {
2576
+ const timer = setTimeout(() => {
2577
+ reject(
2578
+ new NetworkError(`SSE stream idle for ${idleTimeoutMs}ms \u2014 upstream stalled`, {
2579
+ code: "stream_idle_timeout"
2580
+ })
2581
+ );
2582
+ }, idleTimeoutMs);
2583
+ reader.read().then(
2584
+ (result) => {
2585
+ clearTimeout(timer);
2586
+ resolve3(result);
2587
+ },
2588
+ (err) => {
2589
+ clearTimeout(timer);
2590
+ reject(err);
2591
+ }
2592
+ );
2593
+ }
2594
+ );
2595
+ }
2572
2596
  async function cancelReaderQuietly(reader) {
2573
2597
  try {
2574
2598
  await reader.cancel();
@@ -2619,8 +2643,11 @@ function releaseReader(reader) {
2619
2643
  } catch {
2620
2644
  }
2621
2645
  }
2646
+ var DEFAULT_SSE_IDLE_MS;
2622
2647
  var init_sse = __esm({
2623
2648
  "src/internal/llm/sse.ts"() {
2649
+ init_errors();
2650
+ DEFAULT_SSE_IDLE_MS = 6e4;
2624
2651
  }
2625
2652
  });
2626
2653
 
@@ -3569,9 +3596,16 @@ var init_context = __esm({
3569
3596
  });
3570
3597
 
3571
3598
  // src/internal/plugins/manager.ts
3599
+ function removeAll(arr, toRemove) {
3600
+ for (const item of toRemove) {
3601
+ const idx = arr.indexOf(item);
3602
+ if (idx !== -1) arr.splice(idx, 1);
3603
+ }
3604
+ }
3572
3605
  var PluginManager;
3573
3606
  var init_manager = __esm({
3574
3607
  "src/internal/plugins/manager.ts"() {
3608
+ init_errors();
3575
3609
  init_context();
3576
3610
  PluginManager = class {
3577
3611
  #aggregated = {
@@ -3583,6 +3617,9 @@ var init_manager = __esm({
3583
3617
  memoryProviders: []
3584
3618
  };
3585
3619
  #initialized = false;
3620
+ // #68 — registrations of plugins added post-init via `register()`, keyed by
3621
+ // plugin name so a re-register REPLACES (not appends) the prior hooks.
3622
+ #byName = /* @__PURE__ */ new Map();
3586
3623
  async initialize(plugins) {
3587
3624
  if (this.#initialized) {
3588
3625
  throw new Error("PluginManager.initialize called twice \u2014 register only once per process");
@@ -3600,6 +3637,36 @@ var init_manager = __esm({
3600
3637
  await this.#dispatchPlugin(plugin);
3601
3638
  }
3602
3639
  }
3640
+ /**
3641
+ * #68 — register a single `general` plugin AFTER `initialize()` has run.
3642
+ *
3643
+ * The bulk `initialize()` is single-shot (one call per process); late
3644
+ * registration is a distinct, named operation used by adapters that install
3645
+ * a plugin per-session/per-request (e.g. the ACP permission veto, which is
3646
+ * installed once the permission mode + connection are known — after the
3647
+ * agent's own plugins were already initialized).
3648
+ *
3649
+ * Idempotent by plugin NAME: re-registering a plugin with the same name
3650
+ * REPLACES its prior hooks/tools instead of appending duplicates (the ACP
3651
+ * permission plugin is re-installed on every prompt).
3652
+ *
3653
+ * Only `general` plugins may be registered late — model-provider / memory
3654
+ * plugins are resolved during the bulk init and cannot be added afterwards.
3655
+ */
3656
+ async register(plugin) {
3657
+ if (plugin.kind !== "general") {
3658
+ throw new ConfigurationError(
3659
+ `late register supports general plugins only (got "${plugin.kind}" for "${plugin.name}")`,
3660
+ { code: "plugin_late_register_kind" }
3661
+ );
3662
+ }
3663
+ const prior = this.#byName.get(plugin.name);
3664
+ if (prior !== void 0) this.#unmerge(prior);
3665
+ const { ctx, registrations } = createPluginContext();
3666
+ await plugin.register(ctx);
3667
+ this.#byName.set(plugin.name, registrations);
3668
+ this.#merge(registrations);
3669
+ }
3603
3670
  get aggregated() {
3604
3671
  return this.#aggregated;
3605
3672
  }
@@ -3680,6 +3747,64 @@ var init_manager = __esm({
3680
3747
  }
3681
3748
  }
3682
3749
  }
3750
+ // #65 — the previously-dead hooks, now wired. Fire-and-forget hooks run
3751
+ // in order (per-handler errors logged, never thrown); transform hooks fold
3752
+ // over the payload (a handler returning a value replaces it).
3753
+ /** @internal */
3754
+ async #runFireAndForget(name, ctx) {
3755
+ for (const h of this.#aggregated.hooks.get(name) ?? []) {
3756
+ try {
3757
+ await h(ctx);
3758
+ } catch (err) {
3759
+ process.stderr.write(
3760
+ `[theokit-sdk] ${name} hook failed: ${err instanceof Error ? err.message : String(err)}
3761
+ `
3762
+ );
3763
+ }
3764
+ }
3765
+ }
3766
+ /** @internal — fold: each handler may return a replacement payload; a throw keeps the prior value. */
3767
+ async #runTransform(name, payload, ctx) {
3768
+ let current = payload;
3769
+ for (const h of this.#aggregated.hooks.get(name) ?? []) {
3770
+ try {
3771
+ const out = await h(current, ctx);
3772
+ if (out !== void 0) current = out;
3773
+ } catch (err) {
3774
+ process.stderr.write(
3775
+ `[theokit-sdk] ${name} hook failed: ${err instanceof Error ? err.message : String(err)}
3776
+ `
3777
+ );
3778
+ }
3779
+ }
3780
+ return current;
3781
+ }
3782
+ /** #65 — fired after a tool call completes. @internal */
3783
+ runPostToolCallHooks(ctx) {
3784
+ return this.#runFireAndForget("post_tool_call", ctx);
3785
+ }
3786
+ /** #65 — fired before / after each LLM turn. @internal */
3787
+ runPreLlmCallHooks(ctx) {
3788
+ return this.#runFireAndForget("pre_llm_call", ctx);
3789
+ }
3790
+ runPostLlmCallHooks(ctx) {
3791
+ return this.#runFireAndForget("post_llm_call", ctx);
3792
+ }
3793
+ /** #65 — fired at run start / end. @internal */
3794
+ runOnSessionStartHooks(ctx) {
3795
+ return this.#runFireAndForget("on_session_start", ctx);
3796
+ }
3797
+ runOnSessionEndHooks(ctx) {
3798
+ return this.#runFireAndForget("on_session_end", ctx);
3799
+ }
3800
+ /** #65/#57 — transform tool results before they reach the LLM (the #57 seam). @internal */
3801
+ runTransformToolResultHooks(results, ctx) {
3802
+ return this.#runTransform("transform_tool_result", results, ctx);
3803
+ }
3804
+ /** #65 — transform the LLM output text before it is consumed. @internal */
3805
+ runTransformLlmOutputHooks(output, ctx) {
3806
+ return this.#runTransform("transform_llm_output", output, ctx);
3807
+ }
3683
3808
  async #dispatchPlugin(plugin) {
3684
3809
  if (plugin.kind === "general") {
3685
3810
  const { ctx, registrations } = createPluginContext();
@@ -3707,6 +3832,22 @@ var init_manager = __esm({
3707
3832
  }
3708
3833
  this.#aggregated.injected.push(...r.injected);
3709
3834
  }
3835
+ /**
3836
+ * #68 — inverse of #merge: remove a prior registration's contributions from
3837
+ * the aggregated view by object identity. Used by `register()` to replace a
3838
+ * same-named plugin's hooks/tools instead of accumulating duplicates.
3839
+ */
3840
+ #unmerge(r) {
3841
+ removeAll(this.#aggregated.tools, r.tools);
3842
+ removeAll(this.#aggregated.commands, r.commands);
3843
+ removeAll(this.#aggregated.injected, r.injected);
3844
+ for (const [hook, handlers] of r.hooks.entries()) {
3845
+ const existing = this.#aggregated.hooks.get(hook);
3846
+ if (existing === void 0) continue;
3847
+ removeAll(existing, handlers);
3848
+ if (existing.length === 0) this.#aggregated.hooks.delete(hook);
3849
+ }
3850
+ }
3710
3851
  };
3711
3852
  }
3712
3853
  });
@@ -3731,7 +3872,12 @@ var init_span_names = __esm({
3731
3872
  LLM_CALL: "llm.call"
3732
3873
  };
3733
3874
  HISTOGRAM_NAMES = {
3734
- MEMORY_RECALL_DURATION_MS: "theokit_memory_recall_duration_ms"
3875
+ MEMORY_RECALL_DURATION_MS: "theokit_memory_recall_duration_ms",
3876
+ TOOL_CALL_DURATION_MS: "theokit_tool_call_duration_ms",
3877
+ LLM_CALL_DURATION_MS: "theokit_llm_call_duration_ms",
3878
+ LLM_TOKENS: "theokit_llm_tokens",
3879
+ /** M3 #66 — count of finishes where the provider omitted usage (silent undercount). */
3880
+ LLM_USAGE_MISSING: "theokit_llm_usage_missing"
3735
3881
  };
3736
3882
  }
3737
3883
  });
@@ -4096,7 +4242,25 @@ function createTelemetry(settings) {
4096
4242
  enabled: true,
4097
4243
  includeContent: settings.includeContent === true,
4098
4244
  startSpan: startNewSpan,
4099
- startChildSpan: (_parent, name, attrs) => startNewSpan(name, attrs),
4245
+ // M3 #64 actually nest the child under its parent instead of discarding it.
4246
+ // The parent's SpanContext is set on a fresh OTel context so the child links
4247
+ // to it (traceId + parentSpanId), reconstructing the causal trace tree. Falls
4248
+ // back to a root span when the parent has no valid span id (telemetry off /
4249
+ // NOOP), preserving the pre-M3 behavior for parentless callers.
4250
+ startChildSpan: (parent, name, attrs) => {
4251
+ const redactedAttrs = attrs === void 0 ? void 0 : redactAttrs(attrs);
4252
+ const opts = redactedAttrs ? { attributes: redactedAttrs } : void 0;
4253
+ const pctx = safe(() => parent?.spanContext(), void 0);
4254
+ const span = safe(() => {
4255
+ if (pctx !== void 0 && pctx.spanId !== "0".repeat(16)) {
4256
+ const childCtx = otel.trace.setSpanContext(otel.context.active(), pctx);
4257
+ return tracer.startSpan(name, opts, childCtx);
4258
+ }
4259
+ return tracer.startSpan(name, opts);
4260
+ }, NOOP_SPAN);
4261
+ if (span !== NOOP_SPAN) openSpans.add(span);
4262
+ return wrapSpan(span, openSpans);
4263
+ },
4100
4264
  recordHistogram,
4101
4265
  endAll: () => {
4102
4266
  for (const span of openSpans) safe(() => span.end(), void 0);
@@ -4166,12 +4330,65 @@ var init_tracer = __esm({
4166
4330
  warnedOnce = false;
4167
4331
  }
4168
4332
  });
4333
+
4334
+ // src/internal/runtime/lifecycle/env-policy.ts
4335
+ function isSecretName(name) {
4336
+ return SECRET_PATTERNS.some((re) => re.test(name));
4337
+ }
4338
+ function inheritsUnderPolicy(name, policy) {
4339
+ if (policy === "all") return true;
4340
+ if (policy === "core") return CORE_VARS.includes(name);
4341
+ return !isSecretName(name);
4342
+ }
4343
+ function resolveChildEnv(options = {}) {
4344
+ const parent = options.parent ?? process.env;
4345
+ const policy = options.policy ?? "inherit-scrubbed";
4346
+ const base = {};
4347
+ for (const [name, value] of Object.entries(parent)) {
4348
+ if (value !== void 0 && inheritsUnderPolicy(name, policy)) base[name] = value;
4349
+ }
4350
+ for (const [name, value] of Object.entries(options.overrides ?? {})) {
4351
+ base[name] = value;
4352
+ }
4353
+ return base;
4354
+ }
4355
+ var SECRET_PATTERNS, CORE_VARS;
4356
+ var init_env_policy = __esm({
4357
+ "src/internal/runtime/lifecycle/env-policy.ts"() {
4358
+ SECRET_PATTERNS = [
4359
+ /KEY/i,
4360
+ /SECRET/i,
4361
+ /TOKEN/i,
4362
+ /PASSWORD/i,
4363
+ /PASSWD/i,
4364
+ /PASSPHRASE/i,
4365
+ /[_-]PWD/i,
4366
+ /CREDENTIAL/i,
4367
+ /PRIVATE/i,
4368
+ /_AUTH/i
4369
+ ];
4370
+ CORE_VARS = [
4371
+ "PATH",
4372
+ "HOME",
4373
+ "SHELL",
4374
+ "LANG",
4375
+ "LC_ALL",
4376
+ "LC_CTYPE",
4377
+ "TMPDIR",
4378
+ "TMP",
4379
+ "TEMP",
4380
+ "USER",
4381
+ "LOGNAME"
4382
+ ];
4383
+ }
4384
+ });
4169
4385
  function spawnAndCollect(options) {
4170
4386
  return new Promise((resolve3) => {
4171
4387
  const timeoutMs = options.timeoutMs ?? 3e4;
4172
4388
  const spawnOptions = {
4173
4389
  cwd: options.cwd,
4174
- env: { ...process.env, ...options.env ?? {} }
4390
+ // #54 — scrub secret-like parent env by default; `options.env` still wins.
4391
+ env: resolveChildEnv({ policy: options.envPolicy, overrides: options.env })
4175
4392
  };
4176
4393
  const child = spawn(options.command, options.args ?? [], spawnOptions);
4177
4394
  let stdout = "";
@@ -4212,6 +4429,7 @@ function spawnAndCollect(options) {
4212
4429
  }
4213
4430
  var init_spawn_collect = __esm({
4214
4431
  "src/internal/runtime/lifecycle/spawn-collect.ts"() {
4432
+ init_env_policy();
4215
4433
  }
4216
4434
  });
4217
4435
 
@@ -4400,15 +4618,24 @@ function warnMalformed(agentId, line) {
4400
4618
  `
4401
4619
  );
4402
4620
  }
4621
+ function hydrateSessionLine(parsed) {
4622
+ if (typeof parsed.text !== "string" || parsed.role === void 0) return void 0;
4623
+ if (parsed.role === "user" || parsed.role === "assistant") {
4624
+ return { role: parsed.role, text: parsed.text };
4625
+ }
4626
+ if (parsed.role === "tool_call" || parsed.role === "tool_result") {
4627
+ const label = parsed.role === "tool_call" ? "tool call" : "tool result";
4628
+ return { role: "assistant", text: `[${label}] ${parsed.text}` };
4629
+ }
4630
+ return void 0;
4631
+ }
4403
4632
  async function readSessionFile(cwd, agentId) {
4404
4633
  const lines = await readJsonlLines(cwd, agentId);
4405
4634
  const messages = [];
4406
4635
  for (const line of lines) {
4407
4636
  try {
4408
- const parsed = JSON.parse(line);
4409
- if ((parsed.role === "user" || parsed.role === "assistant") && typeof parsed.text === "string") {
4410
- messages.push({ role: parsed.role, text: parsed.text });
4411
- }
4637
+ const msg = hydrateSessionLine(JSON.parse(line));
4638
+ if (msg !== void 0) messages.push(msg);
4412
4639
  } catch {
4413
4640
  warnMalformed(agentId, line);
4414
4641
  }
@@ -4435,29 +4662,70 @@ async function readAllPersistedMessages(cwd, agentId) {
4435
4662
  return messages;
4436
4663
  }
4437
4664
  async function appendAnyPersistedMessage(cwd, agentId, record) {
4438
- const path = sessionFilePath(cwd, agentId);
4439
- await mkdir(dirname(path), { recursive: true });
4440
- await appendFile(path, `${redactSecrets(JSON.stringify(record))}
4441
- `, "utf8");
4665
+ await appendPersistedMessages(cwd, agentId, [record]);
4442
4666
  }
4443
- async function compactSessionFile(cwd, agentId, maxTurns) {
4667
+ async function appendPersistedMessages(cwd, agentId, records) {
4668
+ if (records.length === 0) return;
4444
4669
  const path = sessionFilePath(cwd, agentId);
4445
- let raw;
4670
+ const payload = records.map((r) => `${redactSecrets(JSON.stringify(r))}
4671
+ `).join("");
4672
+ const dir = dirname(path);
4673
+ let written = false;
4674
+ const attempt = async () => {
4675
+ await mkdir(dir, { recursive: true });
4676
+ await withFileLock(path, async () => {
4677
+ await appendFile(path, payload, "utf8");
4678
+ written = true;
4679
+ });
4680
+ };
4446
4681
  try {
4447
- raw = await readFile(path, "utf8");
4448
- } catch {
4449
- return;
4682
+ await attempt();
4683
+ } catch (cause) {
4684
+ if (written || cause.code !== "ENOENT") throw cause;
4685
+ await attempt();
4450
4686
  }
4451
- const lines = raw.split("\n").filter((line) => line.length > 0);
4452
- if (lines.length <= maxTurns * 2) return;
4453
- const trimmed = `${lines.slice(-maxTurns).join("\n")}
4687
+ }
4688
+ async function rewriteLockedSession(path, transform) {
4689
+ await withFileLock(path, async () => {
4690
+ let raw;
4691
+ try {
4692
+ raw = await readFile(path, "utf8");
4693
+ } catch {
4694
+ return;
4695
+ }
4696
+ const lines = raw.split("\n").filter((line) => line.length > 0);
4697
+ const next = transform(lines);
4698
+ if (next === void 0) return;
4699
+ await replaceFileAtomic(path, next);
4700
+ });
4701
+ }
4702
+ async function compactSessionFile(cwd, agentId, maxTurns) {
4703
+ const path = sessionFilePath(cwd, agentId);
4704
+ if (!existsSync(path)) return;
4705
+ await rewriteLockedSession(
4706
+ path,
4707
+ (lines) => lines.length <= maxTurns * 2 ? void 0 : `${lines.slice(-maxTurns).join("\n")}
4708
+ `
4709
+ );
4710
+ }
4711
+ async function truncateSessionTo(cwd, agentId, keepCount) {
4712
+ const path = sessionFilePath(cwd, agentId);
4713
+ if (!existsSync(path)) return 0;
4714
+ let kept = 0;
4715
+ await rewriteLockedSession(path, (lines) => {
4716
+ const keep = Math.max(0, Math.min(keepCount, lines.length));
4717
+ kept = keep;
4718
+ if (keep === lines.length) return void 0;
4719
+ return keep === 0 ? "" : `${lines.slice(0, keep).join("\n")}
4454
4720
  `;
4455
- await replaceFileAtomic(path, trimmed);
4721
+ });
4722
+ return kept;
4456
4723
  }
4457
4724
  var VALID_ROLES;
4458
4725
  var init_agent_session_store = __esm({
4459
4726
  "src/internal/runtime/session/agent-session-store.ts"() {
4460
4727
  init_atomic_write();
4728
+ init_file_lock();
4461
4729
  init_security();
4462
4730
  VALID_ROLES = /* @__PURE__ */ new Set([
4463
4731
  "user",
@@ -4468,6 +4736,18 @@ var init_agent_session_store = __esm({
4468
4736
  ]);
4469
4737
  }
4470
4738
  });
4739
+
4740
+ // src/internal/persistence/pagination.ts
4741
+ function paginate(items, opts) {
4742
+ if (opts === void 0 || opts.offset === void 0 && opts.limit === void 0) return items;
4743
+ const start = Math.max(0, opts.offset ?? 0);
4744
+ const end = opts.limit === void 0 ? items.length : start + Math.max(0, opts.limit);
4745
+ return items.slice(start, end);
4746
+ }
4747
+ var init_pagination = __esm({
4748
+ "src/internal/persistence/pagination.ts"() {
4749
+ }
4750
+ });
4471
4751
  function toStoredMessage(record) {
4472
4752
  return {
4473
4753
  role: record.role,
@@ -4475,11 +4755,15 @@ function toStoredMessage(record) {
4475
4755
  at: record.at
4476
4756
  };
4477
4757
  }
4758
+ function toRecord(message) {
4759
+ return { role: message.role, text: message.content, at: message.at ?? Date.now() };
4760
+ }
4478
4761
  var FileSystemConversationStorage;
4479
4762
  var init_conversation_storage_fs = __esm({
4480
4763
  "src/internal/persistence/conversation-storage-fs.ts"() {
4481
4764
  init_agent_session_store();
4482
4765
  init_security();
4766
+ init_pagination();
4483
4767
  FileSystemConversationStorage = class {
4484
4768
  #root;
4485
4769
  constructor(opts = {}) {
@@ -4489,23 +4773,31 @@ var init_conversation_storage_fs = __esm({
4489
4773
  get root() {
4490
4774
  return this.#root;
4491
4775
  }
4492
- async getMessages(conversationId) {
4776
+ async getMessages(conversationId, opts) {
4493
4777
  const records = await readAllPersistedMessages(this.#root, conversationId);
4494
- return records.map(toStoredMessage);
4778
+ const all = records.map(toStoredMessage);
4779
+ return paginate(all, opts);
4495
4780
  }
4496
4781
  async appendMessage(conversationId, message) {
4497
- const record = {
4498
- role: message.role,
4499
- text: message.content,
4500
- at: message.at ?? Date.now()
4501
- };
4502
- await appendAnyPersistedMessage(this.#root, conversationId, record);
4782
+ await appendAnyPersistedMessage(this.#root, conversationId, toRecord(message));
4783
+ }
4784
+ async appendMessages(conversationId, messages) {
4785
+ await appendPersistedMessages(this.#root, conversationId, messages.map(toRecord));
4786
+ }
4787
+ async truncateConversation(conversationId, keepCount) {
4788
+ return truncateSessionTo(this.#root, conversationId, keepCount);
4503
4789
  }
4504
4790
  async deleteConversation(conversationId) {
4505
4791
  const safe2 = sanitizeIdentifier(conversationId, { maxLen: 128 });
4506
4792
  const dirPath = safePathJoin(this.#root, ".theokit", "agents", safe2);
4507
4793
  await rm(dirPath, { recursive: true, force: true });
4508
4794
  }
4795
+ async deleteScope(prefix) {
4796
+ const ids = await this.listConversationIds();
4797
+ const matching = ids.filter((id) => id.startsWith(prefix));
4798
+ for (const id of matching) await this.deleteConversation(id);
4799
+ return matching.length;
4800
+ }
4509
4801
  async listConversationIds(opts = {}) {
4510
4802
  const agentsRoot = safePathJoin(this.#root, ".theokit", "agents");
4511
4803
  let entries;
@@ -4602,12 +4894,19 @@ async function readPersistedForCache(adapter, agentId) {
4602
4894
  const records = await adapter.getMessages(agentId);
4603
4895
  const out = [];
4604
4896
  for (const r of records) {
4605
- if (r.role === "user" || r.role === "assistant") {
4606
- out.push({ role: r.role, text: r.content });
4607
- }
4897
+ const folded = foldStoredToSession(r);
4898
+ if (folded !== void 0) out.push(folded);
4608
4899
  }
4609
4900
  return out;
4610
4901
  }
4902
+ function foldStoredToSession(r) {
4903
+ if (r.role === "user" || r.role === "assistant") return { role: r.role, text: r.content };
4904
+ if (r.role === "tool_call" || r.role === "tool_result") {
4905
+ const label = r.role === "tool_call" ? "tool call" : "tool result";
4906
+ return { role: "assistant", text: `[${label}] ${r.content}` };
4907
+ }
4908
+ return void 0;
4909
+ }
4611
4910
  async function flushSessionWrites() {
4612
4911
  while (pendingAppends.size > 0) {
4613
4912
  const all = Array.from(pendingAppends.values());
@@ -7178,11 +7477,32 @@ function reasoningEffortFromParams(params) {
7178
7477
  const thinking = params?.find((p) => p.id === "thinking");
7179
7478
  return thinking !== void 0 && thinking.value.length > 0 ? thinking.value : void 0;
7180
7479
  }
7480
+ function emitLlmMetrics(inputs, result, startAt) {
7481
+ inputs.telemetry?.recordHistogram(HISTOGRAM_NAMES.LLM_CALL_DURATION_MS, Date.now() - startAt, {
7482
+ provider: inputs.llm.name
7483
+ });
7484
+ if (result.inputTokens === void 0 && result.outputTokens === void 0) {
7485
+ inputs.telemetry?.recordHistogram(HISTOGRAM_NAMES.LLM_USAGE_MISSING, 1, {
7486
+ provider: inputs.llm.name
7487
+ });
7488
+ process.stderr.write(
7489
+ `[theokit-sdk] llm usage missing from ${inputs.llm.name} finish \u2014 budget may undercount
7490
+ `
7491
+ );
7492
+ return;
7493
+ }
7494
+ inputs.telemetry?.recordHistogram(
7495
+ HISTOGRAM_NAMES.LLM_TOKENS,
7496
+ (result.inputTokens ?? 0) + (result.outputTokens ?? 0),
7497
+ { provider: inputs.llm.name }
7498
+ );
7499
+ }
7181
7500
  async function streamLlmTurn(inputs, ctx) {
7182
- const llmSpan = inputs.telemetry?.startSpan("llm.call", {
7501
+ const llmSpan = inputs.telemetry?.startChildSpan(ctx.sendSpan, "llm.call", {
7183
7502
  "model.id": inputs.model.id ?? "auto",
7184
7503
  provider: inputs.llm.name
7185
7504
  });
7505
+ const startAt = Date.now();
7186
7506
  const signal = inputs.signal ?? new AbortController().signal;
7187
7507
  const generator = inputs.llm.stream(
7188
7508
  {
@@ -7225,6 +7545,7 @@ async function streamLlmTurn(inputs, ctx) {
7225
7545
  inputTokens: result.inputTokens ?? 0,
7226
7546
  outputTokens: result.outputTokens ?? 0
7227
7547
  });
7548
+ emitLlmMetrics(inputs, result, startAt);
7228
7549
  llmSpan?.end();
7229
7550
  const stripped = stripThinkBlocks(collected.accumulatedText);
7230
7551
  return {
@@ -7321,6 +7642,7 @@ async function emitReasoningDeltaCallback(inputs, text) {
7321
7642
  var init_loop_llm_stream = __esm({
7322
7643
  "src/internal/agent-loop/loop-llm-stream.ts"() {
7323
7644
  init_safe_call();
7645
+ init_span_names();
7324
7646
  init_strip_think();
7325
7647
  init_message_builders();
7326
7648
  }
@@ -7539,21 +7861,21 @@ async function executeTool(inputs, resolved, call) {
7539
7861
  }
7540
7862
  if (resolved.origin === "shell") return runShellTool(inputs, call);
7541
7863
  if (resolved.origin === "memory") return runMemoryTool(resolved, call);
7542
- if (resolved.origin === "custom") return runCustomTool(resolved, call);
7864
+ if (resolved.origin === "custom") return runCustomTool(resolved, call, inputs.signal);
7543
7865
  return runMcpTool(inputs, resolved, call);
7544
7866
  }
7545
7867
  async function runMemoryTool(resolved, call) {
7546
7868
  return runHandlerTool("memory", resolved.memoryHandler, call);
7547
7869
  }
7548
- async function runCustomTool(resolved, call) {
7549
- return runHandlerTool("custom", resolved.customHandler, call);
7870
+ async function runCustomTool(resolved, call, signal) {
7871
+ return runHandlerTool("custom", resolved.customHandler, call, signal);
7550
7872
  }
7551
- async function runHandlerTool(kind, handler, call) {
7873
+ async function runHandlerTool(kind, handler, call, signal) {
7552
7874
  if (handler === void 0) {
7553
7875
  return { stdout: "", stderr: `${kind} tool ${call.name} has no handler`, exitCode: 127 };
7554
7876
  }
7555
7877
  try {
7556
- const stdout = await handler(call.input);
7878
+ const stdout = await handler(call.input, { signal });
7557
7879
  return { stdout, stderr: "", exitCode: 0 };
7558
7880
  } catch (cause) {
7559
7881
  const message = cause instanceof Error ? cause.message : String(cause);
@@ -7604,22 +7926,71 @@ var init_tool_executors = __esm({
7604
7926
  }
7605
7927
  });
7606
7928
 
7929
+ // src/internal/agent-loop/tool-timeout.ts
7930
+ function abortedResult(signal) {
7931
+ const reason = signal.reason;
7932
+ const timedOut = reason?.name === "TimeoutError";
7933
+ return {
7934
+ stdout: "",
7935
+ stderr: timedOut ? "tool execution timed out" : "tool execution aborted",
7936
+ exitCode: TOOL_ABORTED_EXIT
7937
+ };
7938
+ }
7939
+ function raceToolExecution(exec, opts) {
7940
+ const { signal, timeoutMs } = opts;
7941
+ if (signal === void 0 && timeoutMs === void 0) return exec;
7942
+ const signals = [];
7943
+ if (signal !== void 0) signals.push(signal);
7944
+ if (timeoutMs !== void 0) signals.push(AbortSignal.timeout(timeoutMs));
7945
+ const merged = signals.length === 1 ? signals[0] : AbortSignal.any(signals);
7946
+ if (merged.aborted) return Promise.resolve(abortedResult(merged));
7947
+ return new Promise((resolve3, reject) => {
7948
+ let settled = false;
7949
+ const onAbort = () => {
7950
+ if (settled) return;
7951
+ settled = true;
7952
+ resolve3(abortedResult(merged));
7953
+ };
7954
+ merged.addEventListener("abort", onAbort, { once: true });
7955
+ exec.then(
7956
+ (r) => {
7957
+ if (settled) return;
7958
+ settled = true;
7959
+ merged.removeEventListener("abort", onAbort);
7960
+ resolve3(r);
7961
+ },
7962
+ (e) => {
7963
+ if (settled) return;
7964
+ settled = true;
7965
+ merged.removeEventListener("abort", onAbort);
7966
+ reject(e);
7967
+ }
7968
+ );
7969
+ });
7970
+ }
7971
+ var TOOL_ABORTED_EXIT;
7972
+ var init_tool_timeout = __esm({
7973
+ "src/internal/agent-loop/tool-timeout.ts"() {
7974
+ TOOL_ABORTED_EXIT = 124;
7975
+ }
7976
+ });
7977
+
7607
7978
  // src/internal/agent-loop/tool-dispatch.ts
7608
- async function dispatchTools(inputs, tools, toolCalls, events) {
7979
+ async function dispatchTools(inputs, tools, toolCalls, events, parentSpan) {
7609
7980
  const maxConcurrent = inputs.maxConcurrentTools ?? 4;
7610
7981
  return mapWithConcurrency(
7611
7982
  toolCalls,
7612
7983
  maxConcurrent,
7613
- (call) => dispatchSingleCall(inputs, tools, call, events)
7984
+ (call) => dispatchSingleCall(inputs, tools, call, events, parentSpan)
7614
7985
  );
7615
7986
  }
7616
- async function dispatchSingleCall(inputs, tools, call, events) {
7987
+ async function dispatchSingleCall(inputs, tools, call, events, parentSpan) {
7617
7988
  const { call: workingCall, repairs } = applyRepairAndExtractCall(tools, call);
7618
7989
  const callId = generateCallId();
7619
7990
  const forkVeto = vetoFromForkWhitelist(inputs, workingCall, callId, events);
7620
7991
  if (forkVeto !== void 0) return forkVeto;
7621
7992
  const resolved = tools.find((tool) => tool.name === workingCall.name);
7622
- const toolSpan = startToolCallSpan(inputs, workingCall, resolved, callId, repairs);
7993
+ const toolSpan = startToolCallSpan(inputs, workingCall, resolved, callId, repairs, parentSpan);
7623
7994
  events.push(buildToolUseRunning(inputs, callId, workingCall));
7624
7995
  const pluginVeto = await vetoFromPluginPreHook(inputs, workingCall, callId, events);
7625
7996
  if (pluginVeto !== void 0) {
@@ -7636,6 +8007,13 @@ async function dispatchSingleCall(inputs, tools, call, events) {
7636
8007
  return fileVeto;
7637
8008
  }
7638
8009
  const result = await runToolWithLifecycle(inputs, resolved, workingCall, callId);
8010
+ await inputs.pluginManager?.runPostToolCallHooks({
8011
+ name: workingCall.name,
8012
+ args: workingCall.input,
8013
+ result: { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode },
8014
+ agentId: inputs.agentId,
8015
+ runId: inputs.runId
8016
+ });
7639
8017
  return finalizeSpanAndPostHook(inputs, workingCall, callId, result, events, toolSpan);
7640
8018
  }
7641
8019
  function applyRepairAndExtractCall(tools, call) {
@@ -7668,8 +8046,8 @@ function vetoFromForkWhitelist(inputs, call, callId, events) {
7668
8046
  content: `Tool blocked by fork whitelist: ${whitelistDecision.reason}`
7669
8047
  };
7670
8048
  }
7671
- function startToolCallSpan(inputs, call, resolved, callId, repairs) {
7672
- const toolSpan = inputs.telemetry?.startSpan("tool.call", {
8049
+ function startToolCallSpan(inputs, call, resolved, callId, repairs, parentSpan) {
8050
+ const toolSpan = inputs.telemetry?.startChildSpan(parentSpan, "tool.call", {
7673
8051
  "tool.name": call.name,
7674
8052
  "tool.origin": resolved?.origin ?? "unknown",
7675
8053
  callId
@@ -7733,8 +8111,14 @@ async function runToolWithLifecycle(inputs, resolved, call, callId) {
7733
8111
  conversationId: inputs.agentId,
7734
8112
  callId
7735
8113
  });
7736
- const result = await executeTool(inputs, resolved, call);
8114
+ const result = await raceToolExecution(executeTool(inputs, resolved, call), {
8115
+ signal: inputs.signal,
8116
+ timeoutMs: inputs.perToolTimeoutMs
8117
+ });
7737
8118
  const durationMs = Date.now() - startAt;
8119
+ inputs.telemetry?.recordHistogram(HISTOGRAM_NAMES.TOOL_CALL_DURATION_MS, durationMs, {
8120
+ "tool.name": call.name
8121
+ });
7738
8122
  if (result.exitCode !== void 0 && result.exitCode !== 0 && result.exitCode !== null) {
7739
8123
  await safeEmitToolHook(inputs.onToolError, {
7740
8124
  toolName: call.name,
@@ -7837,8 +8221,44 @@ var init_tool_dispatch = __esm({
7837
8221
  init_ids();
7838
8222
  init_async_local_storage();
7839
8223
  init_map_with_concurrency();
8224
+ init_span_names();
7840
8225
  init_repair_middleware();
7841
8226
  init_tool_executors();
8227
+ init_tool_timeout();
8228
+ }
8229
+ });
8230
+
8231
+ // src/internal/agent-loop/tool-result-guard.ts
8232
+ function guardText(content, opts) {
8233
+ let out = content;
8234
+ if (opts.redactPii === true) {
8235
+ for (const re of PII_PATTERNS) out = out.replace(re, "[REDACTED]");
8236
+ }
8237
+ if (opts.delimit === true) {
8238
+ const safe2 = out.split(CLOSE).join("</ untrusted-tool-output>");
8239
+ out = `${OPEN}
8240
+ ${safe2}
8241
+ ${CLOSE}`;
8242
+ }
8243
+ return out;
8244
+ }
8245
+ function applyToolResultGuard(parts, opts) {
8246
+ if (opts.delimit !== true && opts.redactPii !== true) return parts;
8247
+ return parts.map(
8248
+ (p) => p.type === "tool_result" ? { ...p, content: guardText(p.content, opts) } : p
8249
+ );
8250
+ }
8251
+ var OPEN, CLOSE, PII_PATTERNS;
8252
+ var init_tool_result_guard = __esm({
8253
+ "src/internal/agent-loop/tool-result-guard.ts"() {
8254
+ OPEN = "<untrusted-tool-output>";
8255
+ CLOSE = "</untrusted-tool-output>";
8256
+ PII_PATTERNS = [
8257
+ /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
8258
+ // email
8259
+ /\b(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g
8260
+ // phone
8261
+ ];
7842
8262
  }
7843
8263
  });
7844
8264
 
@@ -8222,6 +8642,11 @@ async function runAgentLoop(inputs) {
8222
8642
  try {
8223
8643
  const ctx = await initLoopContext(inputs);
8224
8644
  ctxRef = ctx;
8645
+ ctx.sendSpan = sendSpan;
8646
+ await inputs.pluginManager?.runOnSessionStartHooks({
8647
+ agentId: inputs.agentId,
8648
+ runId: inputs.runId
8649
+ });
8225
8650
  const budget = inputs.budget ?? new IterationBudget({ maxIterations: inputs.maxIterations ?? 8 });
8226
8651
  let lastTurnDecision;
8227
8652
  while (budget.shouldContinue()) {
@@ -8249,6 +8674,7 @@ async function runAgentLoop(inputs) {
8249
8674
  }
8250
8675
  budget.consume();
8251
8676
  inputs.budgetTracker?.nextIteration?.();
8677
+ if (inputs.signal?.aborted === true) break;
8252
8678
  }
8253
8679
  if (lastTurnDecision === "continue" && budget.shouldContinue() === false) {
8254
8680
  ctx.stoppedAtIterationLimit = true;
@@ -8288,6 +8714,10 @@ async function runAgentLoop(inputs) {
8288
8714
  ...ctx.stoppedByDoomLoop === true ? { stoppedByDoomLoop: true } : {}
8289
8715
  };
8290
8716
  } finally {
8717
+ await inputs.pluginManager?.runOnSessionEndHooks({
8718
+ agentId: inputs.agentId,
8719
+ runId: inputs.runId
8720
+ });
8291
8721
  if (ctxRef !== void 0 && ctxRef.memoryProviderHandle !== void 0 && inputs.memoryProvider !== void 0) {
8292
8722
  try {
8293
8723
  await inputs.memoryProvider.dispose(ctxRef.memoryProviderHandle);
@@ -8391,7 +8821,10 @@ async function finishOrReflect(inputs, ctx, llmOutput) {
8391
8821
  return "done";
8392
8822
  }
8393
8823
  async function runIteration(inputs, ctx) {
8824
+ const hookCtx = { agentId: inputs.agentId, runId: inputs.runId };
8825
+ await inputs.pluginManager?.runPreLlmCallHooks(hookCtx);
8394
8826
  const llmOutput = await streamLlmTurn(inputs, ctx);
8827
+ await inputs.pluginManager?.runPostLlmCallHooks(hookCtx);
8395
8828
  accumulateUsage(ctx.usage, llmOutput);
8396
8829
  if (inputs.budgetTracker !== void 0) {
8397
8830
  const modelId = inputs.model.id ?? "auto";
@@ -8417,6 +8850,13 @@ async function runIteration(inputs, ctx) {
8417
8850
  }
8418
8851
  return continueOrTerminate(inputs, ctx, llmOutput);
8419
8852
  }
8853
+ async function transformLlmOutputText(inputs, text, ctx) {
8854
+ return inputs.pluginManager !== void 0 ? inputs.pluginManager.runTransformLlmOutputHooks(text, ctx) : text;
8855
+ }
8856
+ async function guardAndTransformToolResults(inputs, raw, ctx) {
8857
+ const guarded = inputs.toolResultGuard !== void 0 ? applyToolResultGuard(raw, inputs.toolResultGuard) : raw;
8858
+ return inputs.pluginManager !== void 0 ? inputs.pluginManager.runTransformToolResultHooks(guarded, ctx) : guarded;
8859
+ }
8420
8860
  async function continueOrTerminate(inputs, ctx, llmOutput) {
8421
8861
  if (llmOutput.errored) return "error";
8422
8862
  if (llmOutput.text.length > 0) {
@@ -8425,8 +8865,18 @@ async function continueOrTerminate(inputs, ctx, llmOutput) {
8425
8865
  if (llmOutput.stopReason !== "tool_use" || llmOutput.toolCalls.length === 0) {
8426
8866
  return finishOrReflect(inputs, ctx, llmOutput);
8427
8867
  }
8428
- ctx.messages.push(buildAssistantTurn(llmOutput.text, llmOutput.toolCalls));
8429
- const toolResults = await dispatchTools(inputs, ctx.tools, llmOutput.toolCalls, ctx.events);
8868
+ const tCtx = { agentId: inputs.agentId, runId: inputs.runId };
8869
+ const outText = await transformLlmOutputText(inputs, llmOutput.text, tCtx);
8870
+ ctx.messages.push(buildAssistantTurn(outText, llmOutput.toolCalls));
8871
+ const rawResults = await dispatchTools(
8872
+ inputs,
8873
+ ctx.tools,
8874
+ llmOutput.toolCalls,
8875
+ ctx.events,
8876
+ ctx.sendSpan
8877
+ // M3 #64 — nest tool.call spans under agent.send
8878
+ );
8879
+ const toolResults = await guardAndTransformToolResults(inputs, rawResults, tCtx);
8430
8880
  ctx.messages.push({ role: "user", content: toolResults });
8431
8881
  if (inputs.onStep !== void 0) {
8432
8882
  const cb = inputs.onStep;
@@ -8476,6 +8926,7 @@ var init_loop = __esm({
8476
8926
  init_loop_llm_stream();
8477
8927
  init_message_builders();
8478
8928
  init_tool_dispatch();
8929
+ init_tool_result_guard();
8479
8930
  init_usage_and_cost();
8480
8931
  MAX_NUDGE_ATTEMPTS = 2;
8481
8932
  MAX_STOP_FEEDBACK_ATTEMPTS = 2;
@@ -8965,6 +9416,60 @@ var init_anthropic2 = __esm({
8965
9416
  init_shared();
8966
9417
  }
8967
9418
  });
9419
+ function loadJsonrepair() {
9420
+ if (cachedJsonrepair === void 0) {
9421
+ const req = createRequire(import.meta.url);
9422
+ cachedJsonrepair = req("jsonrepair").jsonrepair;
9423
+ }
9424
+ return cachedJsonrepair;
9425
+ }
9426
+ function isPlainObject(v) {
9427
+ return v !== null && typeof v === "object" && !Array.isArray(v);
9428
+ }
9429
+ function toFiniteNumber(raw) {
9430
+ if (raw === "") return void 0;
9431
+ const n = Number(raw);
9432
+ return Number.isFinite(n) && String(n) === raw ? n : void 0;
9433
+ }
9434
+ function tryJson(raw, repair) {
9435
+ const t = raw.trimStart();
9436
+ if (!(t.startsWith("{") || t.startsWith("["))) return void 0;
9437
+ try {
9438
+ return JSON.parse(repair ? loadJsonrepair()(t) : t);
9439
+ } catch {
9440
+ return void 0;
9441
+ }
9442
+ }
9443
+ function heuristicCoerce(raw, repairJson) {
9444
+ if (raw === "true") return true;
9445
+ if (raw === "false") return false;
9446
+ if (raw === "null") return null;
9447
+ const n = toFiniteNumber(raw);
9448
+ if (n !== void 0) return n;
9449
+ const json = tryJson(raw, false) ?? (repairJson ? tryJson(raw, true) : void 0);
9450
+ return json === void 0 ? raw : json;
9451
+ }
9452
+ function coerceCandidates(raw, repairJson) {
9453
+ const out = [];
9454
+ if (raw === "true") out.push(true);
9455
+ else if (raw === "false") out.push(false);
9456
+ else if (raw === "null") out.push(null);
9457
+ const n = toFiniteNumber(raw);
9458
+ if (n !== void 0) out.push(n);
9459
+ const json = tryJson(raw, false) ?? (repairJson ? tryJson(raw, true) : void 0);
9460
+ if (json !== void 0) out.push(json);
9461
+ out.push(raw);
9462
+ return out;
9463
+ }
9464
+ function objectShape(schema) {
9465
+ const shape = schema?.shape;
9466
+ return shape !== null && typeof shape === "object" ? shape : void 0;
9467
+ }
9468
+ var cachedJsonrepair;
9469
+ var init_coerce = __esm({
9470
+ "src/sanitize/coerce.ts"() {
9471
+ }
9472
+ });
8968
9473
 
8969
9474
  // src/internal/llm/finish.ts
8970
9475
  function collapseSystemText(system) {
@@ -8977,9 +9482,21 @@ function parseToolArguments(buffered) {
8977
9482
  try {
8978
9483
  return JSON.parse(buffered);
8979
9484
  } catch {
9485
+ const repaired = tryJson(buffered, true);
9486
+ if (isPlainObject(repaired)) return repaired;
8980
9487
  return { raw: buffered };
8981
9488
  }
8982
9489
  }
9490
+ function mapOpenAIFinish(reason) {
9491
+ switch (reason) {
9492
+ case "tool_calls":
9493
+ return "tool_use";
9494
+ case "length":
9495
+ return "max_tokens";
9496
+ default:
9497
+ return "end_turn";
9498
+ }
9499
+ }
8983
9500
  function makeLlmFinish(state2) {
8984
9501
  const finish = {
8985
9502
  stopReason: state2.stopReason,
@@ -8995,6 +9512,7 @@ function makeLlmFinish(state2) {
8995
9512
  }
8996
9513
  var init_finish = __esm({
8997
9514
  "src/internal/llm/finish.ts"() {
9515
+ init_coerce();
8998
9516
  }
8999
9517
  });
9000
9518
 
@@ -9438,6 +9956,16 @@ var init_credential_pool_types = __esm({
9438
9956
  });
9439
9957
 
9440
9958
  // src/internal/llm/retry.ts
9959
+ function computeBackoffMs(opts) {
9960
+ const base = opts.baseMs ?? DEFAULT_BASE_MS;
9961
+ const cap = opts.capMs ?? DEFAULT_CAP_MS;
9962
+ const rng = opts.rng ?? Math.random;
9963
+ if (opts.retryAfterMs !== void 0 && opts.retryAfterMs >= 0) {
9964
+ return Math.max(base, Math.min(cap, opts.retryAfterMs));
9965
+ }
9966
+ const ceiling = Math.min(cap, base * 2 ** opts.attempt);
9967
+ return Math.floor(rng() * ceiling);
9968
+ }
9441
9969
  function sleepWithAbort(ms, signal) {
9442
9970
  if (ms <= 0 || signal.aborted) return Promise.resolve();
9443
9971
  return new Promise((resolve3) => {
@@ -9452,8 +9980,11 @@ function sleepWithAbort(ms, signal) {
9452
9980
  signal.addEventListener("abort", onAbort, { once: true });
9453
9981
  });
9454
9982
  }
9983
+ var DEFAULT_BASE_MS, DEFAULT_CAP_MS;
9455
9984
  var init_retry = __esm({
9456
9985
  "src/internal/llm/retry.ts"() {
9986
+ DEFAULT_BASE_MS = 500;
9987
+ DEFAULT_CAP_MS = 32e3;
9457
9988
  }
9458
9989
  });
9459
9990
 
@@ -10209,60 +10740,6 @@ var init_ollama_native = __esm({
10209
10740
  ollamaSystemText = collapseSystemText;
10210
10741
  }
10211
10742
  });
10212
- function loadJsonrepair() {
10213
- if (cachedJsonrepair === void 0) {
10214
- const req = createRequire(import.meta.url);
10215
- cachedJsonrepair = req("jsonrepair").jsonrepair;
10216
- }
10217
- return cachedJsonrepair;
10218
- }
10219
- function isPlainObject(v) {
10220
- return v !== null && typeof v === "object" && !Array.isArray(v);
10221
- }
10222
- function toFiniteNumber(raw) {
10223
- if (raw === "") return void 0;
10224
- const n = Number(raw);
10225
- return Number.isFinite(n) && String(n) === raw ? n : void 0;
10226
- }
10227
- function tryJson(raw, repair) {
10228
- const t = raw.trimStart();
10229
- if (!(t.startsWith("{") || t.startsWith("["))) return void 0;
10230
- try {
10231
- return JSON.parse(repair ? loadJsonrepair()(t) : t);
10232
- } catch {
10233
- return void 0;
10234
- }
10235
- }
10236
- function heuristicCoerce(raw, repairJson) {
10237
- if (raw === "true") return true;
10238
- if (raw === "false") return false;
10239
- if (raw === "null") return null;
10240
- const n = toFiniteNumber(raw);
10241
- if (n !== void 0) return n;
10242
- const json = tryJson(raw, false) ?? (repairJson ? tryJson(raw, true) : void 0);
10243
- return json === void 0 ? raw : json;
10244
- }
10245
- function coerceCandidates(raw, repairJson) {
10246
- const out = [];
10247
- if (raw === "true") out.push(true);
10248
- else if (raw === "false") out.push(false);
10249
- else if (raw === "null") out.push(null);
10250
- const n = toFiniteNumber(raw);
10251
- if (n !== void 0) out.push(n);
10252
- const json = tryJson(raw, false) ?? (repairJson ? tryJson(raw, true) : void 0);
10253
- if (json !== void 0) out.push(json);
10254
- out.push(raw);
10255
- return out;
10256
- }
10257
- function objectShape(schema) {
10258
- const shape = schema?.shape;
10259
- return shape !== null && typeof shape === "object" ? shape : void 0;
10260
- }
10261
- var cachedJsonrepair;
10262
- var init_coerce = __esm({
10263
- "src/sanitize/coerce.ts"() {
10264
- }
10265
- });
10266
10743
 
10267
10744
  // src/sanitize/sanitize-tool-input.ts
10268
10745
  function applyTrim(key, value, ctx) {
@@ -10443,18 +10920,6 @@ var init_hermes_tool_extract = __esm({
10443
10920
  });
10444
10921
 
10445
10922
  // src/internal/llm/openai.ts
10446
- function mapOpenAIFinish(reason) {
10447
- switch (reason) {
10448
- case "tool_calls":
10449
- return "tool_use";
10450
- case "length":
10451
- return "max_tokens";
10452
- case "stop":
10453
- return "end_turn";
10454
- default:
10455
- return "end_turn";
10456
- }
10457
- }
10458
10923
  function applyReasoningRequest(body, effort, providerName) {
10459
10924
  if (providerName === "openai") {
10460
10925
  body.reasoning_effort = effort;
@@ -10551,6 +11016,7 @@ function assistantMessage(message) {
10551
11016
  var OpenAIClient, OpenAIStreamAccumulator, openAISystemText;
10552
11017
  var init_openai2 = __esm({
10553
11018
  "src/internal/llm/openai.ts"() {
11019
+ init_errors();
10554
11020
  init_ollama2();
10555
11021
  init_openai_compatible();
10556
11022
  init_finish();
@@ -10631,8 +11097,12 @@ var init_openai2 = __esm({
10631
11097
  // model was actually given. Empty set (no tools) recovers nothing.
10632
11098
  new Set(request.tools?.map((tool) => tool.name) ?? [])
10633
11099
  );
11100
+ let sawDone = false;
10634
11101
  for await (const record of parseSseStream(response.body, signal)) {
10635
- if (record.data === "[DONE]") break;
11102
+ if (record.data === "[DONE]") {
11103
+ sawDone = true;
11104
+ break;
11105
+ }
10636
11106
  let chunk;
10637
11107
  try {
10638
11108
  chunk = JSON.parse(record.data);
@@ -10652,6 +11122,11 @@ var init_openai2 = __esm({
10652
11122
  const events = accumulator.consume(chunk);
10653
11123
  for (const event of events) yield event;
10654
11124
  }
11125
+ if (!sawDone && !accumulator.finishReasonSeen) {
11126
+ throw new NetworkError("SSE stream truncated (no finish_reason / [DONE])", {
11127
+ code: "stream_truncated"
11128
+ });
11129
+ }
10655
11130
  const drainEvent = accumulator.finalizeHeldText();
10656
11131
  if (drainEvent !== void 0) yield drainEvent;
10657
11132
  return accumulator.finish();
@@ -10751,8 +11226,15 @@ var init_openai2 = __esm({
10751
11226
  this.toolCalls.set(call.index, existing);
10752
11227
  }
10753
11228
  }
11229
+ /** M2 #61 — true once any chunk carried a non-null `finish_reason` (else a
11230
+ * stream ending without `[DONE]` is a truncation, not a clean end). */
11231
+ sawFinishReason = false;
11232
+ get finishReasonSeen() {
11233
+ return this.sawFinishReason;
11234
+ }
10754
11235
  applyFinishReason(reason) {
10755
11236
  if (reason === void 0 || reason === null) return;
11237
+ this.sawFinishReason = true;
10756
11238
  this.stopReason = mapOpenAIFinish(reason);
10757
11239
  }
10758
11240
  finish() {
@@ -10801,6 +11283,53 @@ var init_openai2 = __esm({
10801
11283
  }
10802
11284
  });
10803
11285
 
11286
+ // src/internal/resilience/circuit-breaker.ts
11287
+ var DEFAULT_MAX_TIMEOUTS, DEFAULT_COOLDOWN_MS2, CircuitBreaker;
11288
+ var init_circuit_breaker = __esm({
11289
+ "src/internal/resilience/circuit-breaker.ts"() {
11290
+ DEFAULT_MAX_TIMEOUTS = 3;
11291
+ DEFAULT_COOLDOWN_MS2 = 6e4;
11292
+ CircuitBreaker = class {
11293
+ constructor(opts = {}) {
11294
+ this.opts = opts;
11295
+ }
11296
+ opts;
11297
+ states = /* @__PURE__ */ new Map();
11298
+ /** @returns true when the breaker is open and the call should be skipped. */
11299
+ shouldSkip(key) {
11300
+ const state2 = this.states.get(key);
11301
+ if (state2 === void 0) return false;
11302
+ if (state2.cooldownUntilMs === 0) return false;
11303
+ if (this.now() < state2.cooldownUntilMs) return true;
11304
+ state2.cooldownUntilMs = 0;
11305
+ state2.consecutiveTimeouts = 0;
11306
+ return false;
11307
+ }
11308
+ recordSuccess(key) {
11309
+ const state2 = this.states.get(key);
11310
+ if (state2 === void 0) return;
11311
+ state2.consecutiveTimeouts = 0;
11312
+ state2.cooldownUntilMs = 0;
11313
+ }
11314
+ recordTimeout(key) {
11315
+ const state2 = this.states.get(key) ?? { consecutiveTimeouts: 0, cooldownUntilMs: 0 };
11316
+ state2.consecutiveTimeouts += 1;
11317
+ if (state2.consecutiveTimeouts >= (this.opts.maxTimeouts ?? DEFAULT_MAX_TIMEOUTS)) {
11318
+ state2.cooldownUntilMs = this.now() + (this.opts.cooldownMs ?? DEFAULT_COOLDOWN_MS2);
11319
+ }
11320
+ this.states.set(key, state2);
11321
+ }
11322
+ /** @internal — tests inspect counter state. */
11323
+ inspect(key) {
11324
+ return this.states.get(key) ?? { consecutiveTimeouts: 0, cooldownUntilMs: 0 };
11325
+ }
11326
+ now() {
11327
+ return this.opts.now?.() ?? Date.now();
11328
+ }
11329
+ };
11330
+ }
11331
+ });
11332
+
10804
11333
  // src/internal/llm/pool-aware-client.ts
10805
11334
  function classifyAndDecide(error, hasRetried429) {
10806
11335
  if (error instanceof NetworkError) return "propagate";
@@ -10829,20 +11358,34 @@ var PoolAwareLlmClient;
10829
11358
  var init_pool_aware_client = __esm({
10830
11359
  "src/internal/llm/pool-aware-client.ts"() {
10831
11360
  init_errors();
11361
+ init_circuit_breaker();
11362
+ init_retry();
10832
11363
  init_stream_relay();
10833
11364
  PoolAwareLlmClient = class {
10834
- constructor(pool, buildClient2, waitForAvailableMs = 3e4) {
11365
+ constructor(pool, buildClient2, waitForAvailableMs = 3e4, resilience = {}) {
10835
11366
  this.pool = pool;
10836
11367
  this.buildClient = buildClient2;
10837
11368
  this.waitForAvailableMs = waitForAvailableMs;
10838
11369
  this.name = `pool-aware:${pool.provider}`;
11370
+ this.breaker = resilience.breaker ?? new CircuitBreaker();
11371
+ this.backoffBaseMs = resilience.backoffBaseMs;
11372
+ this.rng = resilience.rng;
10839
11373
  }
10840
11374
  pool;
10841
11375
  buildClient;
10842
11376
  waitForAvailableMs;
10843
11377
  name;
11378
+ /** M2 #60 — provider-level circuit breaker (consecutive-failure). */
11379
+ breaker;
11380
+ backoffBaseMs;
11381
+ rng;
10844
11382
  // 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.
10845
11383
  async *stream(request, signal) {
11384
+ if (this.breaker.shouldSkip(this.pool.provider)) {
11385
+ throw new NetworkError(`${this.pool.provider} circuit open \u2014 failing fast`, {
11386
+ code: "circuit_open"
11387
+ });
11388
+ }
10846
11389
  let hasRetried429 = false;
10847
11390
  while (true) {
10848
11391
  if (signal.aborted) throw abortError2(signal);
@@ -10857,6 +11400,7 @@ var init_pool_aware_client = __esm({
10857
11400
  }
10858
11401
  }
10859
11402
  if (entry === null) {
11403
+ this.breaker.recordTimeout(this.pool.provider);
10860
11404
  throw new CredentialPoolExhaustedError(
10861
11405
  `All ${this.pool.provider} credentials exhausted; next retry available at ${this.nextRetryHint() ?? "unknown"}`,
10862
11406
  { provider: this.pool.provider, nextRetryAt: this.nextRetryHint() }
@@ -10866,10 +11410,19 @@ var init_pool_aware_client = __esm({
10866
11410
  const realClient = this.buildClient(entry.accessToken);
10867
11411
  const attempt = await tryFirstEvent(realClient, request, signal);
10868
11412
  if (attempt.kind === "ok") {
11413
+ this.breaker.recordSuccess(this.pool.provider);
10869
11414
  return yield* relayStream(attempt.generator, attempt.firstResult);
10870
11415
  }
10871
11416
  const decision = classifyAndDecide(attempt.error, hasRetried429);
10872
11417
  if (decision === "retry") {
11418
+ await sleepWithAbort(
11419
+ computeBackoffMs({
11420
+ attempt: 0,
11421
+ ...this.backoffBaseMs !== void 0 ? { baseMs: this.backoffBaseMs } : {},
11422
+ ...this.rng !== void 0 ? { rng: this.rng } : {}
11423
+ }),
11424
+ signal
11425
+ );
10873
11426
  hasRetried429 = true;
10874
11427
  continue;
10875
11428
  }
@@ -10889,6 +11442,7 @@ var init_pool_aware_client = __esm({
10889
11442
  hasRetried429 = false;
10890
11443
  continue;
10891
11444
  }
11445
+ this.breaker.recordTimeout(this.pool.provider);
10892
11446
  throw attempt.error;
10893
11447
  }
10894
11448
  }
@@ -11347,9 +11901,24 @@ var init_router = __esm({
11347
11901
  warnedProviders = /* @__PURE__ */ new Set();
11348
11902
  }
11349
11903
  });
11350
- function createMcpClient(name, config) {
11904
+ function createMcpClient(name, config, fetchImpl = fetch) {
11351
11905
  if (isStdio(config)) return new StdioMcpClient(name, config);
11352
- return new HttpMcpClient(name, config);
11906
+ return new HttpMcpClient(name, config, fetchImpl);
11907
+ }
11908
+ function reconnectDelay(attempt) {
11909
+ const ceiling = RECONNECT_BASE_MS * 2 ** attempt;
11910
+ const ms = Math.floor(Math.random() * ceiling);
11911
+ return ms <= 0 ? Promise.resolve() : new Promise((resolve3) => setTimeout(resolve3, ms));
11912
+ }
11913
+ function mcpTimeoutError(name, timeoutMs) {
11914
+ return new NetworkError(`MCP ${name} request timed out after ${timeoutMs}ms`, {
11915
+ code: "mcp_timeout"
11916
+ });
11917
+ }
11918
+ function isAbortLike(cause) {
11919
+ if (typeof cause !== "object" || cause === null || !("name" in cause)) return false;
11920
+ const name = cause.name;
11921
+ return name === "TimeoutError" || name === "AbortError";
11353
11922
  }
11354
11923
  async function rpcInitialize(request) {
11355
11924
  await request("initialize", {
@@ -11379,11 +11948,16 @@ function resolveMcpCwd(configCwd) {
11379
11948
  if (isAbsolute(configCwd)) return configCwd;
11380
11949
  return safePathJoin(process.cwd(), configCwd);
11381
11950
  }
11382
- var BaseMcpClient, StdioMcpClient, HttpMcpClient;
11951
+ var DEFAULT_MCP_TIMEOUT_MS, MAX_STDIO_BUFFER_BYTES, RECONNECT_BASE_MS, MAX_RECONNECT_ATTEMPTS, BaseMcpClient, StdioMcpClient, HttpMcpClient;
11383
11952
  var init_client = __esm({
11384
11953
  "src/internal/mcp/client.ts"() {
11385
11954
  init_errors();
11955
+ init_env_policy();
11386
11956
  init_path_guard();
11957
+ DEFAULT_MCP_TIMEOUT_MS = 3e4;
11958
+ MAX_STDIO_BUFFER_BYTES = 8 * 1024 * 1024;
11959
+ RECONNECT_BASE_MS = 250;
11960
+ MAX_RECONNECT_ATTEMPTS = 2;
11387
11961
  BaseMcpClient = class {
11388
11962
  initialize() {
11389
11963
  return rpcInitialize((method, params) => this.request(method, params));
@@ -11405,32 +11979,107 @@ var init_client = __esm({
11405
11979
  name;
11406
11980
  child;
11407
11981
  nextId = 1;
11982
+ // #59 — pending requests carry a reject + timer so a silent server times out
11983
+ // (typed error), a late reply after timeout is a no-op, and close() settles them.
11408
11984
  pending = /* @__PURE__ */ new Map();
11409
11985
  buffer = "";
11410
- async initialize() {
11986
+ // M2 #59 — reconnect-after-drop state. `dropped` is set when the child exits
11987
+ // unexpectedly OR times out (not via close()); the next request re-spawns with
11988
+ // backoff. `reconnectPromise` is a SINGLE in-flight reconnect shared by every
11989
+ // concurrent request so parallel tool dispatch after a drop awaits one handshake
11990
+ // instead of racing (or spuriously failing with mcp_not_init).
11991
+ dropped = false;
11992
+ reconnectAttempts = 0;
11993
+ reconnectPromise;
11994
+ get timeoutMs() {
11995
+ return this.config.requestTimeoutMs ?? DEFAULT_MCP_TIMEOUT_MS;
11996
+ }
11997
+ /** Spawn the server child and wire stdout/stderr/error/exit handlers.
11998
+ * Shared by `initialize()` and the M2 #59 reconnect path. */
11999
+ spawnChild() {
11411
12000
  const resolvedCwd = resolveMcpCwd(this.config.cwd);
11412
12001
  const child = spawn(this.config.command, this.config.args ?? [], {
11413
12002
  cwd: resolvedCwd,
11414
- env: { ...process.env, ...this.config.env ?? {} }
12003
+ // #54 (F-H1) a third-party MCP server binary must not inherit host
12004
+ // secrets. Scrub secret-like vars by default; `config.env` still wins.
12005
+ env: resolveChildEnv({ policy: this.config.envPolicy, overrides: this.config.env })
11415
12006
  });
11416
12007
  this.child = child;
11417
12008
  child.stdout.on("data", (chunk) => this.consume(chunk));
11418
12009
  child.stderr.on("data", () => void 0);
12010
+ child.stdin.on("error", () => void 0);
11419
12011
  child.on("error", () => {
11420
- for (const resolver of this.pending.values()) {
11421
- resolver({ error: { message: "MCP process crashed" } });
11422
- }
11423
- this.pending.clear();
12012
+ this.rejectAllPending(
12013
+ new NetworkError(`MCP ${this.name} process crashed`, { code: "mcp_crashed" })
12014
+ );
12015
+ });
12016
+ child.on("exit", () => {
12017
+ if (this.child !== child) return;
12018
+ this.child = void 0;
12019
+ this.dropped = true;
12020
+ this.rejectAllPending(
12021
+ new NetworkError(`MCP ${this.name} disconnected`, { code: "mcp_disconnected" })
12022
+ );
12023
+ });
12024
+ }
12025
+ async initialize() {
12026
+ this.spawnChild();
12027
+ await super.initialize();
12028
+ }
12029
+ /** M2 #59 — ensure a live child before a request. Reconnect (bounded, with
12030
+ * full-jitter backoff) when the client was dropped; fail fast when never
12031
+ * initialized. Concurrent callers share ONE reconnect handshake. */
12032
+ ensureConnected() {
12033
+ if (this.child !== void 0) return Promise.resolve();
12034
+ if (!this.dropped) {
12035
+ return Promise.reject(
12036
+ new ConfigurationError(`MCP ${this.name} is not initialized`, { code: "mcp_not_init" })
12037
+ );
12038
+ }
12039
+ this.reconnectPromise ??= this.reconnect().finally(() => {
12040
+ this.reconnectPromise = void 0;
11424
12041
  });
12042
+ return this.reconnectPromise;
12043
+ }
12044
+ async reconnect() {
12045
+ if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
12046
+ throw new NetworkError(`MCP ${this.name} reconnect exhausted`, { code: "mcp_disconnected" });
12047
+ }
12048
+ await reconnectDelay(this.reconnectAttempts);
12049
+ this.reconnectAttempts += 1;
12050
+ this.spawnChild();
11425
12051
  await super.initialize();
12052
+ this.dropped = false;
12053
+ this.reconnectAttempts = 0;
11426
12054
  }
11427
12055
  async close() {
11428
- if (this.child === void 0) return;
11429
- this.child.kill("SIGTERM");
12056
+ this.rejectAllPending(new NetworkError(`MCP ${this.name} closed`, { code: "mcp_closed" }));
12057
+ const child = this.child;
11430
12058
  this.child = void 0;
12059
+ this.dropped = false;
12060
+ child?.kill("SIGTERM");
12061
+ }
12062
+ /** Reject + clear every pending request (crash / close). @internal */
12063
+ rejectAllPending(error) {
12064
+ for (const entry of this.pending.values()) {
12065
+ clearTimeout(entry.timer);
12066
+ entry.reject(error);
12067
+ }
12068
+ this.pending.clear();
11431
12069
  }
11432
12070
  consume(chunk) {
11433
12071
  this.buffer += chunk.toString("utf8");
12072
+ if (this.buffer.length > MAX_STDIO_BUFFER_BYTES) {
12073
+ this.buffer = "";
12074
+ this.rejectAllPending(
12075
+ new NetworkError(`MCP ${this.name} exceeded stdout buffer limit`, {
12076
+ code: "mcp_buffer_overflow"
12077
+ })
12078
+ );
12079
+ this.child?.kill("SIGKILL");
12080
+ this.child = void 0;
12081
+ return;
12082
+ }
11434
12083
  let newlineIndex = this.buffer.indexOf("\n");
11435
12084
  while (newlineIndex !== -1) {
11436
12085
  const line = this.buffer.slice(0, newlineIndex).trim();
@@ -11447,23 +12096,47 @@ var init_client = __esm({
11447
12096
  return;
11448
12097
  }
11449
12098
  if (typeof message.id !== "number") return;
11450
- const resolver = this.pending.get(message.id);
11451
- if (resolver === void 0) return;
12099
+ const entry = this.pending.get(message.id);
12100
+ if (entry === void 0) return;
11452
12101
  this.pending.delete(message.id);
11453
- resolver(message);
12102
+ clearTimeout(entry.timer);
12103
+ entry.resolve(message);
11454
12104
  }
11455
12105
  request(method, params) {
11456
- if (this.child === void 0) {
11457
- return Promise.reject(
11458
- new ConfigurationError(`MCP ${this.name} is not initialized`, { code: "mcp_not_init" })
11459
- );
12106
+ const child = this.child;
12107
+ if (child !== void 0) return this.send(child, method, params);
12108
+ if (this.dropped) return this.reconnectAndRequest(method, params);
12109
+ return Promise.reject(
12110
+ new ConfigurationError(`MCP ${this.name} is not initialized`, { code: "mcp_not_init" })
12111
+ );
12112
+ }
12113
+ /** M2 #59 — reconnect a dropped client, then send. Separate async path so the
12114
+ * happy path above never pays an extra microtask tick. */
12115
+ async reconnectAndRequest(method, params) {
12116
+ await this.ensureConnected();
12117
+ const child = this.child;
12118
+ if (child === void 0) {
12119
+ throw new ConfigurationError(`MCP ${this.name} is not initialized`, { code: "mcp_not_init" });
11460
12120
  }
12121
+ return this.send(child, method, params);
12122
+ }
12123
+ send(child, method, params) {
11461
12124
  const id = this.nextId++;
11462
12125
  const payload = { jsonrpc: "2.0", id, method, params };
11463
- this.child.stdin.write(`${JSON.stringify(payload)}
12126
+ child.stdin.write(`${JSON.stringify(payload)}
11464
12127
  `);
11465
- return new Promise((resolve3) => {
11466
- this.pending.set(id, resolve3);
12128
+ return new Promise((resolve3, reject) => {
12129
+ const timer = setTimeout(() => {
12130
+ this.pending.delete(id);
12131
+ reject(mcpTimeoutError(this.name, this.timeoutMs));
12132
+ this.child?.kill("SIGKILL");
12133
+ this.child = void 0;
12134
+ this.dropped = true;
12135
+ this.rejectAllPending(
12136
+ new NetworkError(`MCP ${this.name} disconnected`, { code: "mcp_disconnected" })
12137
+ );
12138
+ }, this.timeoutMs);
12139
+ this.pending.set(id, { resolve: resolve3, reject, timer });
11467
12140
  });
11468
12141
  }
11469
12142
  };
@@ -11489,11 +12162,20 @@ var init_client = __esm({
11489
12162
  accept: "application/json",
11490
12163
  ...this.config.headers ?? {}
11491
12164
  };
11492
- const response = await this.fetchImpl(this.config.url, {
11493
- method: "POST",
11494
- headers,
11495
- body: JSON.stringify(payload)
11496
- });
12165
+ const timeoutMs = this.config.requestTimeoutMs ?? DEFAULT_MCP_TIMEOUT_MS;
12166
+ let response;
12167
+ try {
12168
+ response = await this.fetchImpl(this.config.url, {
12169
+ method: "POST",
12170
+ headers,
12171
+ body: JSON.stringify(payload),
12172
+ // #59 — bound the request; a non-responding endpoint aborts here.
12173
+ signal: AbortSignal.timeout(timeoutMs)
12174
+ });
12175
+ } catch (cause) {
12176
+ if (isAbortLike(cause)) throw mcpTimeoutError(this.name, timeoutMs);
12177
+ throw cause;
12178
+ }
11497
12179
  if (!response.ok) {
11498
12180
  throw new NetworkError(`MCP ${this.name} returned ${response.status}`, {
11499
12181
  code: "mcp_http_error"
@@ -11613,11 +12295,33 @@ function resolveRunProvider(options) {
11613
12295
  );
11614
12296
  }
11615
12297
  const parsedModel = parseModelId(options.model?.id);
11616
- const inferredProvider = parsedModel.provider !== void 0 && getProviderProfile(parsedModel.provider) !== void 0 ? parsedModel.provider : void 0;
11617
- const primary = options.agentOptions.providers?.routes?.[0]?.provider ?? inferredProvider ?? detectPrimaryProvider();
11618
- const effectiveModelId = inferredProvider !== void 0 ? parsedModel.name : options.model?.id ?? "claude-sonnet-4-6";
12298
+ const modelInferredProvider = parsedModel.provider !== void 0 && getProviderProfile(parsedModel.provider) !== void 0 ? parsedModel.provider : void 0;
12299
+ const keyInferredProvider = inferProviderFromApiKey(options.agentOptions.apiKey);
12300
+ const primary = options.agentOptions.providers?.routes?.[0]?.provider ?? keyInferredProvider ?? modelInferredProvider ?? detectPrimaryProvider();
12301
+ const effectiveModelId = modelInferredProvider !== void 0 && modelInferredProvider === primary ? parsedModel.name : options.model?.id ?? "claude-sonnet-4-6";
11619
12302
  return { primary, effectiveModelId };
11620
12303
  }
12304
+ function inferProviderFromApiKey(apiKey) {
12305
+ if (apiKey === void 0 || apiKey.length === 0) return void 0;
12306
+ const byPrefix = [
12307
+ { provider: "openrouter", prefix: "sk-or-" },
12308
+ { provider: "anthropic", prefix: "sk-ant-" },
12309
+ { provider: "openai", prefix: "sk-" }
12310
+ ];
12311
+ for (const { provider, prefix } of byPrefix) {
12312
+ if (apiKey.startsWith(prefix) && getProviderProfile(provider) !== void 0) {
12313
+ return provider;
12314
+ }
12315
+ }
12316
+ return void 0;
12317
+ }
12318
+ function mergeExplicitApiKey(pools, primary, apiKey) {
12319
+ if (apiKey === void 0 || apiKey.length === 0) return pools;
12320
+ if (isFixtureApiKey(apiKey) || apiKey === LOCAL_RUNTIME_MOCK_KEY) return pools;
12321
+ const existing = pools?.[primary];
12322
+ if (existing !== void 0 && existing.length > 0) return pools;
12323
+ return { ...pools ?? {}, [primary]: [apiKey] };
12324
+ }
11621
12325
  function buildLoopInputs(options, runId, userText) {
11622
12326
  const maxIterations = options.sendOptions.maxIterations;
11623
12327
  if (maxIterations !== void 0 && (!Number.isInteger(maxIterations) || maxIterations < 1)) {
@@ -11628,7 +12332,11 @@ function buildLoopInputs(options, runId, userText) {
11628
12332
  }
11629
12333
  const { primary, effectiveModelId } = resolveRunProvider(options);
11630
12334
  const fallback = options.agentOptions.providers?.fallback;
11631
- const apiKeys = options.agentOptions.providers?.apiKeys;
12335
+ const apiKeys = mergeExplicitApiKey(
12336
+ options.agentOptions.providers?.apiKeys,
12337
+ primary,
12338
+ options.agentOptions.apiKey
12339
+ );
11632
12340
  const credentialPoolStrategy = options.agentOptions.providers?.credentialPoolStrategy;
11633
12341
  const extractToolCallsFromContent = options.agentOptions.providers?.routes?.[0]?.extractToolCallsFromContent;
11634
12342
  const chain = resolveProviderChain({
@@ -11672,6 +12380,10 @@ function buildLoopInputs(options, runId, userText) {
11672
12380
  // D318 — forward SendOptions.signal to the agent loop so streamLlmTurn
11673
12381
  // can attach it to the LLM `fetch({ signal })` call.
11674
12382
  ...options.sendOptions.signal !== void 0 ? { signal: options.sendOptions.signal } : {},
12383
+ // #58 / #57 — forward the per-tool timeout + tool-result guard so a consumer
12384
+ // can enable them via SendOptions (not only internal AgentLoopInputs).
12385
+ ...options.sendOptions.perToolTimeoutMs !== void 0 ? { perToolTimeoutMs: options.sendOptions.perToolTimeoutMs } : {},
12386
+ ...options.sendOptions.toolResultGuard !== void 0 ? { toolResultGuard: options.sendOptions.toolResultGuard } : {},
11675
12387
  // M1-2: per-send iteration ceiling (validated above). The loop reads
11676
12388
  // inputs.maxIterations (default 8 when unset).
11677
12389
  ...maxIterations !== void 0 ? { maxIterations } : {},
@@ -11738,6 +12450,8 @@ var init_real_local_run = __esm({
11738
12450
  "src/internal/runtime/local-agent/real-local-run.ts"() {
11739
12451
  init_errors();
11740
12452
  init_loop();
12453
+ init_api_key_validator();
12454
+ init_fixture_mode();
11741
12455
  init_fallback_client();
11742
12456
  init_model_identifier();
11743
12457
  init_router();
@@ -12014,7 +12728,12 @@ async function runActiveMemory(args) {
12014
12728
  hits: []
12015
12729
  });
12016
12730
  }
12017
- const cached2 = args.cache?.get(args.userText, cfg.queryMode);
12731
+ const tenantCtx = {
12732
+ namespace: args.namespace,
12733
+ userId: args.userId,
12734
+ scope: args.scope
12735
+ };
12736
+ const cached2 = args.cache?.get(args.userText, cfg.queryMode, tenantCtx);
12018
12737
  if (cached2 !== void 0) return endRecallSpan(span, args, cached2);
12019
12738
  const query = buildQuery(args.userText, args.priorMessages, cfg.queryMode, cfg.recentUserTurns);
12020
12739
  if (query.trim().length === 0) {
@@ -12099,7 +12818,12 @@ function notifyBreaker(breaker, key, status) {
12099
12818
  else if (status === "ok" || status === "no-recall") breaker.recordSuccess(key);
12100
12819
  }
12101
12820
  async function finalize(args, queryMode, result) {
12102
- args.cache?.set(args.userText, queryMode, result);
12821
+ const tenantCtx = {
12822
+ namespace: args.namespace,
12823
+ userId: args.userId,
12824
+ scope: args.scope
12825
+ };
12826
+ args.cache?.set(args.userText, queryMode, result, tenantCtx);
12103
12827
  if (args.persistTranscripts === true && args.cwd !== void 0) {
12104
12828
  await persistActiveMemoryTranscript(args.cwd, {
12105
12829
  runId: args.runId ?? `run-${Date.now()}`,
@@ -12804,53 +13528,6 @@ var init_catalog = __esm({
12804
13528
  }
12805
13529
  });
12806
13530
 
12807
- // src/internal/memory/circuit-breaker.ts
12808
- var DEFAULT_MAX_TIMEOUTS, DEFAULT_COOLDOWN_MS2, CircuitBreaker;
12809
- var init_circuit_breaker = __esm({
12810
- "src/internal/memory/circuit-breaker.ts"() {
12811
- DEFAULT_MAX_TIMEOUTS = 3;
12812
- DEFAULT_COOLDOWN_MS2 = 6e4;
12813
- CircuitBreaker = class {
12814
- constructor(opts = {}) {
12815
- this.opts = opts;
12816
- }
12817
- opts;
12818
- states = /* @__PURE__ */ new Map();
12819
- /** @returns true when the breaker is open and the call should be skipped. */
12820
- shouldSkip(key) {
12821
- const state2 = this.states.get(key);
12822
- if (state2 === void 0) return false;
12823
- if (state2.cooldownUntilMs === 0) return false;
12824
- if (this.now() < state2.cooldownUntilMs) return true;
12825
- state2.cooldownUntilMs = 0;
12826
- state2.consecutiveTimeouts = 0;
12827
- return false;
12828
- }
12829
- recordSuccess(key) {
12830
- const state2 = this.states.get(key);
12831
- if (state2 === void 0) return;
12832
- state2.consecutiveTimeouts = 0;
12833
- state2.cooldownUntilMs = 0;
12834
- }
12835
- recordTimeout(key) {
12836
- const state2 = this.states.get(key) ?? { consecutiveTimeouts: 0, cooldownUntilMs: 0 };
12837
- state2.consecutiveTimeouts += 1;
12838
- if (state2.consecutiveTimeouts >= (this.opts.maxTimeouts ?? DEFAULT_MAX_TIMEOUTS)) {
12839
- state2.cooldownUntilMs = this.now() + (this.opts.cooldownMs ?? DEFAULT_COOLDOWN_MS2);
12840
- }
12841
- this.states.set(key, state2);
12842
- }
12843
- /** @internal — tests inspect counter state. */
12844
- inspect(key) {
12845
- return this.states.get(key) ?? { consecutiveTimeouts: 0, cooldownUntilMs: 0 };
12846
- }
12847
- now() {
12848
- return this.opts.now?.() ?? Date.now();
12849
- }
12850
- };
12851
- }
12852
- });
12853
-
12854
13531
  // src/internal/persistence/fts5-sanitize.ts
12855
13532
  function sanitizeFts5Query(query) {
12856
13533
  if (query.length === 0) return query;
@@ -14016,9 +14693,9 @@ var init_local_agent_memory = __esm({
14016
14693
  init_active_memory();
14017
14694
  init_active_memory_cache();
14018
14695
  init_catalog();
14019
- init_circuit_breaker();
14020
14696
  init_index_manager();
14021
14697
  init_tools();
14698
+ init_circuit_breaker();
14022
14699
  LocalAgentMemory = class {
14023
14700
  constructor(options, workspaceCwd, agentId) {
14024
14701
  this.options = options;