@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
@@ -2548,13 +2548,13 @@ var init_cloud_run = __esm({
2548
2548
  });
2549
2549
 
2550
2550
  // src/internal/llm/sse.ts
2551
- async function* parseSseStream(body, signal) {
2551
+ async function* parseSseStream(body, signal, idleTimeoutMs = DEFAULT_SSE_IDLE_MS) {
2552
2552
  if (body === null) return;
2553
2553
  const reader = body.getReader();
2554
2554
  const decoder = new TextDecoder("utf-8");
2555
2555
  const state2 = { buffer: "", event: "message", data: "" };
2556
2556
  try {
2557
- for await (const chunk of readChunks(reader, signal)) {
2557
+ for await (const chunk of readChunks(reader, signal, idleTimeoutMs)) {
2558
2558
  state2.buffer += decoder.decode(chunk, { stream: true });
2559
2559
  for (const record of drainCompleteRecords(state2)) yield record;
2560
2560
  }
@@ -2564,14 +2564,38 @@ async function* parseSseStream(body, signal) {
2564
2564
  releaseReader(reader);
2565
2565
  }
2566
2566
  }
2567
- async function* readChunks(reader, signal) {
2567
+ async function* readChunks(reader, signal, idleTimeoutMs) {
2568
2568
  while (true) {
2569
2569
  if (signal.aborted) return;
2570
- const { value, done } = await reader.read();
2570
+ const { value, done } = await readWithIdleTimeout(reader, idleTimeoutMs);
2571
2571
  if (done) return;
2572
2572
  if (value !== void 0) yield value;
2573
2573
  }
2574
2574
  }
2575
+ function readWithIdleTimeout(reader, idleTimeoutMs) {
2576
+ if (idleTimeoutMs <= 0) return reader.read();
2577
+ return new Promise(
2578
+ (resolve3, reject) => {
2579
+ const timer = setTimeout(() => {
2580
+ reject(
2581
+ new NetworkError(`SSE stream idle for ${idleTimeoutMs}ms \u2014 upstream stalled`, {
2582
+ code: "stream_idle_timeout"
2583
+ })
2584
+ );
2585
+ }, idleTimeoutMs);
2586
+ reader.read().then(
2587
+ (result) => {
2588
+ clearTimeout(timer);
2589
+ resolve3(result);
2590
+ },
2591
+ (err) => {
2592
+ clearTimeout(timer);
2593
+ reject(err);
2594
+ }
2595
+ );
2596
+ }
2597
+ );
2598
+ }
2575
2599
  async function cancelReaderQuietly(reader) {
2576
2600
  try {
2577
2601
  await reader.cancel();
@@ -2622,8 +2646,11 @@ function releaseReader(reader) {
2622
2646
  } catch {
2623
2647
  }
2624
2648
  }
2649
+ var DEFAULT_SSE_IDLE_MS;
2625
2650
  var init_sse = __esm({
2626
2651
  "src/internal/llm/sse.ts"() {
2652
+ init_errors();
2653
+ DEFAULT_SSE_IDLE_MS = 6e4;
2627
2654
  }
2628
2655
  });
2629
2656
 
@@ -3572,9 +3599,16 @@ var init_context = __esm({
3572
3599
  });
3573
3600
 
3574
3601
  // src/internal/plugins/manager.ts
3602
+ function removeAll(arr, toRemove) {
3603
+ for (const item of toRemove) {
3604
+ const idx = arr.indexOf(item);
3605
+ if (idx !== -1) arr.splice(idx, 1);
3606
+ }
3607
+ }
3575
3608
  var PluginManager;
3576
3609
  var init_manager = __esm({
3577
3610
  "src/internal/plugins/manager.ts"() {
3611
+ init_errors();
3578
3612
  init_context();
3579
3613
  PluginManager = class {
3580
3614
  #aggregated = {
@@ -3586,6 +3620,9 @@ var init_manager = __esm({
3586
3620
  memoryProviders: []
3587
3621
  };
3588
3622
  #initialized = false;
3623
+ // #68 — registrations of plugins added post-init via `register()`, keyed by
3624
+ // plugin name so a re-register REPLACES (not appends) the prior hooks.
3625
+ #byName = /* @__PURE__ */ new Map();
3589
3626
  async initialize(plugins) {
3590
3627
  if (this.#initialized) {
3591
3628
  throw new Error("PluginManager.initialize called twice \u2014 register only once per process");
@@ -3603,6 +3640,36 @@ var init_manager = __esm({
3603
3640
  await this.#dispatchPlugin(plugin);
3604
3641
  }
3605
3642
  }
3643
+ /**
3644
+ * #68 — register a single `general` plugin AFTER `initialize()` has run.
3645
+ *
3646
+ * The bulk `initialize()` is single-shot (one call per process); late
3647
+ * registration is a distinct, named operation used by adapters that install
3648
+ * a plugin per-session/per-request (e.g. the ACP permission veto, which is
3649
+ * installed once the permission mode + connection are known — after the
3650
+ * agent's own plugins were already initialized).
3651
+ *
3652
+ * Idempotent by plugin NAME: re-registering a plugin with the same name
3653
+ * REPLACES its prior hooks/tools instead of appending duplicates (the ACP
3654
+ * permission plugin is re-installed on every prompt).
3655
+ *
3656
+ * Only `general` plugins may be registered late — model-provider / memory
3657
+ * plugins are resolved during the bulk init and cannot be added afterwards.
3658
+ */
3659
+ async register(plugin) {
3660
+ if (plugin.kind !== "general") {
3661
+ throw new ConfigurationError(
3662
+ `late register supports general plugins only (got "${plugin.kind}" for "${plugin.name}")`,
3663
+ { code: "plugin_late_register_kind" }
3664
+ );
3665
+ }
3666
+ const prior = this.#byName.get(plugin.name);
3667
+ if (prior !== void 0) this.#unmerge(prior);
3668
+ const { ctx, registrations } = createPluginContext();
3669
+ await plugin.register(ctx);
3670
+ this.#byName.set(plugin.name, registrations);
3671
+ this.#merge(registrations);
3672
+ }
3606
3673
  get aggregated() {
3607
3674
  return this.#aggregated;
3608
3675
  }
@@ -3683,6 +3750,64 @@ var init_manager = __esm({
3683
3750
  }
3684
3751
  }
3685
3752
  }
3753
+ // #65 — the previously-dead hooks, now wired. Fire-and-forget hooks run
3754
+ // in order (per-handler errors logged, never thrown); transform hooks fold
3755
+ // over the payload (a handler returning a value replaces it).
3756
+ /** @internal */
3757
+ async #runFireAndForget(name, ctx) {
3758
+ for (const h of this.#aggregated.hooks.get(name) ?? []) {
3759
+ try {
3760
+ await h(ctx);
3761
+ } catch (err) {
3762
+ process.stderr.write(
3763
+ `[theokit-sdk] ${name} hook failed: ${err instanceof Error ? err.message : String(err)}
3764
+ `
3765
+ );
3766
+ }
3767
+ }
3768
+ }
3769
+ /** @internal — fold: each handler may return a replacement payload; a throw keeps the prior value. */
3770
+ async #runTransform(name, payload, ctx) {
3771
+ let current = payload;
3772
+ for (const h of this.#aggregated.hooks.get(name) ?? []) {
3773
+ try {
3774
+ const out = await h(current, ctx);
3775
+ if (out !== void 0) current = out;
3776
+ } catch (err) {
3777
+ process.stderr.write(
3778
+ `[theokit-sdk] ${name} hook failed: ${err instanceof Error ? err.message : String(err)}
3779
+ `
3780
+ );
3781
+ }
3782
+ }
3783
+ return current;
3784
+ }
3785
+ /** #65 — fired after a tool call completes. @internal */
3786
+ runPostToolCallHooks(ctx) {
3787
+ return this.#runFireAndForget("post_tool_call", ctx);
3788
+ }
3789
+ /** #65 — fired before / after each LLM turn. @internal */
3790
+ runPreLlmCallHooks(ctx) {
3791
+ return this.#runFireAndForget("pre_llm_call", ctx);
3792
+ }
3793
+ runPostLlmCallHooks(ctx) {
3794
+ return this.#runFireAndForget("post_llm_call", ctx);
3795
+ }
3796
+ /** #65 — fired at run start / end. @internal */
3797
+ runOnSessionStartHooks(ctx) {
3798
+ return this.#runFireAndForget("on_session_start", ctx);
3799
+ }
3800
+ runOnSessionEndHooks(ctx) {
3801
+ return this.#runFireAndForget("on_session_end", ctx);
3802
+ }
3803
+ /** #65/#57 — transform tool results before they reach the LLM (the #57 seam). @internal */
3804
+ runTransformToolResultHooks(results, ctx) {
3805
+ return this.#runTransform("transform_tool_result", results, ctx);
3806
+ }
3807
+ /** #65 — transform the LLM output text before it is consumed. @internal */
3808
+ runTransformLlmOutputHooks(output, ctx) {
3809
+ return this.#runTransform("transform_llm_output", output, ctx);
3810
+ }
3686
3811
  async #dispatchPlugin(plugin) {
3687
3812
  if (plugin.kind === "general") {
3688
3813
  const { ctx, registrations } = createPluginContext();
@@ -3710,6 +3835,22 @@ var init_manager = __esm({
3710
3835
  }
3711
3836
  this.#aggregated.injected.push(...r.injected);
3712
3837
  }
3838
+ /**
3839
+ * #68 — inverse of #merge: remove a prior registration's contributions from
3840
+ * the aggregated view by object identity. Used by `register()` to replace a
3841
+ * same-named plugin's hooks/tools instead of accumulating duplicates.
3842
+ */
3843
+ #unmerge(r) {
3844
+ removeAll(this.#aggregated.tools, r.tools);
3845
+ removeAll(this.#aggregated.commands, r.commands);
3846
+ removeAll(this.#aggregated.injected, r.injected);
3847
+ for (const [hook, handlers] of r.hooks.entries()) {
3848
+ const existing = this.#aggregated.hooks.get(hook);
3849
+ if (existing === void 0) continue;
3850
+ removeAll(existing, handlers);
3851
+ if (existing.length === 0) this.#aggregated.hooks.delete(hook);
3852
+ }
3853
+ }
3713
3854
  };
3714
3855
  }
3715
3856
  });
@@ -3734,7 +3875,12 @@ var init_span_names = __esm({
3734
3875
  LLM_CALL: "llm.call"
3735
3876
  };
3736
3877
  HISTOGRAM_NAMES = {
3737
- MEMORY_RECALL_DURATION_MS: "theokit_memory_recall_duration_ms"
3878
+ MEMORY_RECALL_DURATION_MS: "theokit_memory_recall_duration_ms",
3879
+ TOOL_CALL_DURATION_MS: "theokit_tool_call_duration_ms",
3880
+ LLM_CALL_DURATION_MS: "theokit_llm_call_duration_ms",
3881
+ LLM_TOKENS: "theokit_llm_tokens",
3882
+ /** M3 #66 — count of finishes where the provider omitted usage (silent undercount). */
3883
+ LLM_USAGE_MISSING: "theokit_llm_usage_missing"
3738
3884
  };
3739
3885
  }
3740
3886
  });
@@ -4099,7 +4245,25 @@ function createTelemetry(settings) {
4099
4245
  enabled: true,
4100
4246
  includeContent: settings.includeContent === true,
4101
4247
  startSpan: startNewSpan,
4102
- startChildSpan: (_parent, name, attrs) => startNewSpan(name, attrs),
4248
+ // M3 #64 actually nest the child under its parent instead of discarding it.
4249
+ // The parent's SpanContext is set on a fresh OTel context so the child links
4250
+ // to it (traceId + parentSpanId), reconstructing the causal trace tree. Falls
4251
+ // back to a root span when the parent has no valid span id (telemetry off /
4252
+ // NOOP), preserving the pre-M3 behavior for parentless callers.
4253
+ startChildSpan: (parent, name, attrs) => {
4254
+ const redactedAttrs = attrs === void 0 ? void 0 : redactAttrs(attrs);
4255
+ const opts = redactedAttrs ? { attributes: redactedAttrs } : void 0;
4256
+ const pctx = safe(() => parent?.spanContext(), void 0);
4257
+ const span = safe(() => {
4258
+ if (pctx !== void 0 && pctx.spanId !== "0".repeat(16)) {
4259
+ const childCtx = otel.trace.setSpanContext(otel.context.active(), pctx);
4260
+ return tracer.startSpan(name, opts, childCtx);
4261
+ }
4262
+ return tracer.startSpan(name, opts);
4263
+ }, NOOP_SPAN);
4264
+ if (span !== NOOP_SPAN) openSpans.add(span);
4265
+ return wrapSpan(span, openSpans);
4266
+ },
4103
4267
  recordHistogram,
4104
4268
  endAll: () => {
4105
4269
  for (const span of openSpans) safe(() => span.end(), void 0);
@@ -4169,12 +4333,65 @@ var init_tracer = __esm({
4169
4333
  warnedOnce = false;
4170
4334
  }
4171
4335
  });
4336
+
4337
+ // src/internal/runtime/lifecycle/env-policy.ts
4338
+ function isSecretName(name) {
4339
+ return SECRET_PATTERNS.some((re) => re.test(name));
4340
+ }
4341
+ function inheritsUnderPolicy(name, policy) {
4342
+ if (policy === "all") return true;
4343
+ if (policy === "core") return CORE_VARS.includes(name);
4344
+ return !isSecretName(name);
4345
+ }
4346
+ function resolveChildEnv(options = {}) {
4347
+ const parent = options.parent ?? process.env;
4348
+ const policy = options.policy ?? "inherit-scrubbed";
4349
+ const base = {};
4350
+ for (const [name, value] of Object.entries(parent)) {
4351
+ if (value !== void 0 && inheritsUnderPolicy(name, policy)) base[name] = value;
4352
+ }
4353
+ for (const [name, value] of Object.entries(options.overrides ?? {})) {
4354
+ base[name] = value;
4355
+ }
4356
+ return base;
4357
+ }
4358
+ var SECRET_PATTERNS, CORE_VARS;
4359
+ var init_env_policy = __esm({
4360
+ "src/internal/runtime/lifecycle/env-policy.ts"() {
4361
+ SECRET_PATTERNS = [
4362
+ /KEY/i,
4363
+ /SECRET/i,
4364
+ /TOKEN/i,
4365
+ /PASSWORD/i,
4366
+ /PASSWD/i,
4367
+ /PASSPHRASE/i,
4368
+ /[_-]PWD/i,
4369
+ /CREDENTIAL/i,
4370
+ /PRIVATE/i,
4371
+ /_AUTH/i
4372
+ ];
4373
+ CORE_VARS = [
4374
+ "PATH",
4375
+ "HOME",
4376
+ "SHELL",
4377
+ "LANG",
4378
+ "LC_ALL",
4379
+ "LC_CTYPE",
4380
+ "TMPDIR",
4381
+ "TMP",
4382
+ "TEMP",
4383
+ "USER",
4384
+ "LOGNAME"
4385
+ ];
4386
+ }
4387
+ });
4172
4388
  function spawnAndCollect(options) {
4173
4389
  return new Promise((resolve3) => {
4174
4390
  const timeoutMs = options.timeoutMs ?? 3e4;
4175
4391
  const spawnOptions = {
4176
4392
  cwd: options.cwd,
4177
- env: { ...process.env, ...options.env ?? {} }
4393
+ // #54 — scrub secret-like parent env by default; `options.env` still wins.
4394
+ env: resolveChildEnv({ policy: options.envPolicy, overrides: options.env })
4178
4395
  };
4179
4396
  const child = child_process.spawn(options.command, options.args ?? [], spawnOptions);
4180
4397
  let stdout = "";
@@ -4215,6 +4432,7 @@ function spawnAndCollect(options) {
4215
4432
  }
4216
4433
  var init_spawn_collect = __esm({
4217
4434
  "src/internal/runtime/lifecycle/spawn-collect.ts"() {
4435
+ init_env_policy();
4218
4436
  }
4219
4437
  });
4220
4438
 
@@ -4403,15 +4621,24 @@ function warnMalformed(agentId, line) {
4403
4621
  `
4404
4622
  );
4405
4623
  }
4624
+ function hydrateSessionLine(parsed) {
4625
+ if (typeof parsed.text !== "string" || parsed.role === void 0) return void 0;
4626
+ if (parsed.role === "user" || parsed.role === "assistant") {
4627
+ return { role: parsed.role, text: parsed.text };
4628
+ }
4629
+ if (parsed.role === "tool_call" || parsed.role === "tool_result") {
4630
+ const label = parsed.role === "tool_call" ? "tool call" : "tool result";
4631
+ return { role: "assistant", text: `[${label}] ${parsed.text}` };
4632
+ }
4633
+ return void 0;
4634
+ }
4406
4635
  async function readSessionFile(cwd, agentId) {
4407
4636
  const lines = await readJsonlLines(cwd, agentId);
4408
4637
  const messages = [];
4409
4638
  for (const line of lines) {
4410
4639
  try {
4411
- const parsed = JSON.parse(line);
4412
- if ((parsed.role === "user" || parsed.role === "assistant") && typeof parsed.text === "string") {
4413
- messages.push({ role: parsed.role, text: parsed.text });
4414
- }
4640
+ const msg = hydrateSessionLine(JSON.parse(line));
4641
+ if (msg !== void 0) messages.push(msg);
4415
4642
  } catch {
4416
4643
  warnMalformed(agentId, line);
4417
4644
  }
@@ -4438,29 +4665,70 @@ async function readAllPersistedMessages(cwd, agentId) {
4438
4665
  return messages;
4439
4666
  }
4440
4667
  async function appendAnyPersistedMessage(cwd, agentId, record) {
4668
+ await appendPersistedMessages(cwd, agentId, [record]);
4669
+ }
4670
+ async function appendPersistedMessages(cwd, agentId, records) {
4671
+ if (records.length === 0) return;
4441
4672
  const path$1 = sessionFilePath(cwd, agentId);
4442
- await promises.mkdir(path.dirname(path$1), { recursive: true });
4443
- await promises.appendFile(path$1, `${redactSecrets(JSON.stringify(record))}
4444
- `, "utf8");
4673
+ const payload = records.map((r) => `${redactSecrets(JSON.stringify(r))}
4674
+ `).join("");
4675
+ const dir = path.dirname(path$1);
4676
+ let written = false;
4677
+ const attempt = async () => {
4678
+ await promises.mkdir(dir, { recursive: true });
4679
+ await withFileLock(path$1, async () => {
4680
+ await promises.appendFile(path$1, payload, "utf8");
4681
+ written = true;
4682
+ });
4683
+ };
4684
+ try {
4685
+ await attempt();
4686
+ } catch (cause) {
4687
+ if (written || cause.code !== "ENOENT") throw cause;
4688
+ await attempt();
4689
+ }
4690
+ }
4691
+ async function rewriteLockedSession(path, transform) {
4692
+ await withFileLock(path, async () => {
4693
+ let raw;
4694
+ try {
4695
+ raw = await promises.readFile(path, "utf8");
4696
+ } catch {
4697
+ return;
4698
+ }
4699
+ const lines = raw.split("\n").filter((line) => line.length > 0);
4700
+ const next = transform(lines);
4701
+ if (next === void 0) return;
4702
+ await replaceFileAtomic(path, next);
4703
+ });
4445
4704
  }
4446
4705
  async function compactSessionFile(cwd, agentId, maxTurns) {
4447
4706
  const path = sessionFilePath(cwd, agentId);
4448
- let raw;
4449
- try {
4450
- raw = await promises.readFile(path, "utf8");
4451
- } catch {
4452
- return;
4453
- }
4454
- const lines = raw.split("\n").filter((line) => line.length > 0);
4455
- if (lines.length <= maxTurns * 2) return;
4456
- const trimmed = `${lines.slice(-maxTurns).join("\n")}
4707
+ if (!fs.existsSync(path)) return;
4708
+ await rewriteLockedSession(
4709
+ path,
4710
+ (lines) => lines.length <= maxTurns * 2 ? void 0 : `${lines.slice(-maxTurns).join("\n")}
4711
+ `
4712
+ );
4713
+ }
4714
+ async function truncateSessionTo(cwd, agentId, keepCount) {
4715
+ const path = sessionFilePath(cwd, agentId);
4716
+ if (!fs.existsSync(path)) return 0;
4717
+ let kept = 0;
4718
+ await rewriteLockedSession(path, (lines) => {
4719
+ const keep = Math.max(0, Math.min(keepCount, lines.length));
4720
+ kept = keep;
4721
+ if (keep === lines.length) return void 0;
4722
+ return keep === 0 ? "" : `${lines.slice(0, keep).join("\n")}
4457
4723
  `;
4458
- await replaceFileAtomic(path, trimmed);
4724
+ });
4725
+ return kept;
4459
4726
  }
4460
4727
  var VALID_ROLES;
4461
4728
  var init_agent_session_store = __esm({
4462
4729
  "src/internal/runtime/session/agent-session-store.ts"() {
4463
4730
  init_atomic_write();
4731
+ init_file_lock();
4464
4732
  init_security();
4465
4733
  VALID_ROLES = /* @__PURE__ */ new Set([
4466
4734
  "user",
@@ -4471,6 +4739,18 @@ var init_agent_session_store = __esm({
4471
4739
  ]);
4472
4740
  }
4473
4741
  });
4742
+
4743
+ // src/internal/persistence/pagination.ts
4744
+ function paginate(items, opts) {
4745
+ if (opts === void 0 || opts.offset === void 0 && opts.limit === void 0) return items;
4746
+ const start = Math.max(0, opts.offset ?? 0);
4747
+ const end = opts.limit === void 0 ? items.length : start + Math.max(0, opts.limit);
4748
+ return items.slice(start, end);
4749
+ }
4750
+ var init_pagination = __esm({
4751
+ "src/internal/persistence/pagination.ts"() {
4752
+ }
4753
+ });
4474
4754
  function toStoredMessage(record) {
4475
4755
  return {
4476
4756
  role: record.role,
@@ -4478,11 +4758,15 @@ function toStoredMessage(record) {
4478
4758
  at: record.at
4479
4759
  };
4480
4760
  }
4761
+ function toRecord(message) {
4762
+ return { role: message.role, text: message.content, at: message.at ?? Date.now() };
4763
+ }
4481
4764
  var FileSystemConversationStorage;
4482
4765
  var init_conversation_storage_fs = __esm({
4483
4766
  "src/internal/persistence/conversation-storage-fs.ts"() {
4484
4767
  init_agent_session_store();
4485
4768
  init_security();
4769
+ init_pagination();
4486
4770
  FileSystemConversationStorage = class {
4487
4771
  #root;
4488
4772
  constructor(opts = {}) {
@@ -4492,23 +4776,31 @@ var init_conversation_storage_fs = __esm({
4492
4776
  get root() {
4493
4777
  return this.#root;
4494
4778
  }
4495
- async getMessages(conversationId) {
4779
+ async getMessages(conversationId, opts) {
4496
4780
  const records = await readAllPersistedMessages(this.#root, conversationId);
4497
- return records.map(toStoredMessage);
4781
+ const all = records.map(toStoredMessage);
4782
+ return paginate(all, opts);
4498
4783
  }
4499
4784
  async appendMessage(conversationId, message) {
4500
- const record = {
4501
- role: message.role,
4502
- text: message.content,
4503
- at: message.at ?? Date.now()
4504
- };
4505
- await appendAnyPersistedMessage(this.#root, conversationId, record);
4785
+ await appendAnyPersistedMessage(this.#root, conversationId, toRecord(message));
4786
+ }
4787
+ async appendMessages(conversationId, messages) {
4788
+ await appendPersistedMessages(this.#root, conversationId, messages.map(toRecord));
4789
+ }
4790
+ async truncateConversation(conversationId, keepCount) {
4791
+ return truncateSessionTo(this.#root, conversationId, keepCount);
4506
4792
  }
4507
4793
  async deleteConversation(conversationId) {
4508
4794
  const safe2 = sanitizeIdentifier(conversationId, { maxLen: 128 });
4509
4795
  const dirPath = safePathJoin(this.#root, ".theokit", "agents", safe2);
4510
4796
  await promises.rm(dirPath, { recursive: true, force: true });
4511
4797
  }
4798
+ async deleteScope(prefix) {
4799
+ const ids = await this.listConversationIds();
4800
+ const matching = ids.filter((id) => id.startsWith(prefix));
4801
+ for (const id of matching) await this.deleteConversation(id);
4802
+ return matching.length;
4803
+ }
4512
4804
  async listConversationIds(opts = {}) {
4513
4805
  const agentsRoot = safePathJoin(this.#root, ".theokit", "agents");
4514
4806
  let entries;
@@ -4605,12 +4897,19 @@ async function readPersistedForCache(adapter, agentId) {
4605
4897
  const records = await adapter.getMessages(agentId);
4606
4898
  const out = [];
4607
4899
  for (const r of records) {
4608
- if (r.role === "user" || r.role === "assistant") {
4609
- out.push({ role: r.role, text: r.content });
4610
- }
4900
+ const folded = foldStoredToSession(r);
4901
+ if (folded !== void 0) out.push(folded);
4611
4902
  }
4612
4903
  return out;
4613
4904
  }
4905
+ function foldStoredToSession(r) {
4906
+ if (r.role === "user" || r.role === "assistant") return { role: r.role, text: r.content };
4907
+ if (r.role === "tool_call" || r.role === "tool_result") {
4908
+ const label = r.role === "tool_call" ? "tool call" : "tool result";
4909
+ return { role: "assistant", text: `[${label}] ${r.content}` };
4910
+ }
4911
+ return void 0;
4912
+ }
4614
4913
  async function flushSessionWrites() {
4615
4914
  while (pendingAppends.size > 0) {
4616
4915
  const all = Array.from(pendingAppends.values());
@@ -7181,11 +7480,32 @@ function reasoningEffortFromParams(params) {
7181
7480
  const thinking = params?.find((p) => p.id === "thinking");
7182
7481
  return thinking !== void 0 && thinking.value.length > 0 ? thinking.value : void 0;
7183
7482
  }
7483
+ function emitLlmMetrics(inputs, result, startAt) {
7484
+ inputs.telemetry?.recordHistogram(HISTOGRAM_NAMES.LLM_CALL_DURATION_MS, Date.now() - startAt, {
7485
+ provider: inputs.llm.name
7486
+ });
7487
+ if (result.inputTokens === void 0 && result.outputTokens === void 0) {
7488
+ inputs.telemetry?.recordHistogram(HISTOGRAM_NAMES.LLM_USAGE_MISSING, 1, {
7489
+ provider: inputs.llm.name
7490
+ });
7491
+ process.stderr.write(
7492
+ `[theokit-sdk] llm usage missing from ${inputs.llm.name} finish \u2014 budget may undercount
7493
+ `
7494
+ );
7495
+ return;
7496
+ }
7497
+ inputs.telemetry?.recordHistogram(
7498
+ HISTOGRAM_NAMES.LLM_TOKENS,
7499
+ (result.inputTokens ?? 0) + (result.outputTokens ?? 0),
7500
+ { provider: inputs.llm.name }
7501
+ );
7502
+ }
7184
7503
  async function streamLlmTurn(inputs, ctx) {
7185
- const llmSpan = inputs.telemetry?.startSpan("llm.call", {
7504
+ const llmSpan = inputs.telemetry?.startChildSpan(ctx.sendSpan, "llm.call", {
7186
7505
  "model.id": inputs.model.id ?? "auto",
7187
7506
  provider: inputs.llm.name
7188
7507
  });
7508
+ const startAt = Date.now();
7189
7509
  const signal = inputs.signal ?? new AbortController().signal;
7190
7510
  const generator = inputs.llm.stream(
7191
7511
  {
@@ -7228,6 +7548,7 @@ async function streamLlmTurn(inputs, ctx) {
7228
7548
  inputTokens: result.inputTokens ?? 0,
7229
7549
  outputTokens: result.outputTokens ?? 0
7230
7550
  });
7551
+ emitLlmMetrics(inputs, result, startAt);
7231
7552
  llmSpan?.end();
7232
7553
  const stripped = stripThinkBlocks(collected.accumulatedText);
7233
7554
  return {
@@ -7324,6 +7645,7 @@ async function emitReasoningDeltaCallback(inputs, text) {
7324
7645
  var init_loop_llm_stream = __esm({
7325
7646
  "src/internal/agent-loop/loop-llm-stream.ts"() {
7326
7647
  init_safe_call();
7648
+ init_span_names();
7327
7649
  init_strip_think();
7328
7650
  init_message_builders();
7329
7651
  }
@@ -7542,21 +7864,21 @@ async function executeTool(inputs, resolved, call) {
7542
7864
  }
7543
7865
  if (resolved.origin === "shell") return runShellTool(inputs, call);
7544
7866
  if (resolved.origin === "memory") return runMemoryTool(resolved, call);
7545
- if (resolved.origin === "custom") return runCustomTool(resolved, call);
7867
+ if (resolved.origin === "custom") return runCustomTool(resolved, call, inputs.signal);
7546
7868
  return runMcpTool(inputs, resolved, call);
7547
7869
  }
7548
7870
  async function runMemoryTool(resolved, call) {
7549
7871
  return runHandlerTool("memory", resolved.memoryHandler, call);
7550
7872
  }
7551
- async function runCustomTool(resolved, call) {
7552
- return runHandlerTool("custom", resolved.customHandler, call);
7873
+ async function runCustomTool(resolved, call, signal) {
7874
+ return runHandlerTool("custom", resolved.customHandler, call, signal);
7553
7875
  }
7554
- async function runHandlerTool(kind, handler, call) {
7876
+ async function runHandlerTool(kind, handler, call, signal) {
7555
7877
  if (handler === void 0) {
7556
7878
  return { stdout: "", stderr: `${kind} tool ${call.name} has no handler`, exitCode: 127 };
7557
7879
  }
7558
7880
  try {
7559
- const stdout = await handler(call.input);
7881
+ const stdout = await handler(call.input, { signal });
7560
7882
  return { stdout, stderr: "", exitCode: 0 };
7561
7883
  } catch (cause) {
7562
7884
  const message = cause instanceof Error ? cause.message : String(cause);
@@ -7607,22 +7929,71 @@ var init_tool_executors = __esm({
7607
7929
  }
7608
7930
  });
7609
7931
 
7932
+ // src/internal/agent-loop/tool-timeout.ts
7933
+ function abortedResult(signal) {
7934
+ const reason = signal.reason;
7935
+ const timedOut = reason?.name === "TimeoutError";
7936
+ return {
7937
+ stdout: "",
7938
+ stderr: timedOut ? "tool execution timed out" : "tool execution aborted",
7939
+ exitCode: TOOL_ABORTED_EXIT
7940
+ };
7941
+ }
7942
+ function raceToolExecution(exec, opts) {
7943
+ const { signal, timeoutMs } = opts;
7944
+ if (signal === void 0 && timeoutMs === void 0) return exec;
7945
+ const signals = [];
7946
+ if (signal !== void 0) signals.push(signal);
7947
+ if (timeoutMs !== void 0) signals.push(AbortSignal.timeout(timeoutMs));
7948
+ const merged = signals.length === 1 ? signals[0] : AbortSignal.any(signals);
7949
+ if (merged.aborted) return Promise.resolve(abortedResult(merged));
7950
+ return new Promise((resolve3, reject) => {
7951
+ let settled = false;
7952
+ const onAbort = () => {
7953
+ if (settled) return;
7954
+ settled = true;
7955
+ resolve3(abortedResult(merged));
7956
+ };
7957
+ merged.addEventListener("abort", onAbort, { once: true });
7958
+ exec.then(
7959
+ (r) => {
7960
+ if (settled) return;
7961
+ settled = true;
7962
+ merged.removeEventListener("abort", onAbort);
7963
+ resolve3(r);
7964
+ },
7965
+ (e) => {
7966
+ if (settled) return;
7967
+ settled = true;
7968
+ merged.removeEventListener("abort", onAbort);
7969
+ reject(e);
7970
+ }
7971
+ );
7972
+ });
7973
+ }
7974
+ var TOOL_ABORTED_EXIT;
7975
+ var init_tool_timeout = __esm({
7976
+ "src/internal/agent-loop/tool-timeout.ts"() {
7977
+ TOOL_ABORTED_EXIT = 124;
7978
+ }
7979
+ });
7980
+
7610
7981
  // src/internal/agent-loop/tool-dispatch.ts
7611
- async function dispatchTools(inputs, tools, toolCalls, events) {
7982
+ async function dispatchTools(inputs, tools, toolCalls, events, parentSpan) {
7612
7983
  const maxConcurrent = inputs.maxConcurrentTools ?? 4;
7613
7984
  return mapWithConcurrency(
7614
7985
  toolCalls,
7615
7986
  maxConcurrent,
7616
- (call) => dispatchSingleCall(inputs, tools, call, events)
7987
+ (call) => dispatchSingleCall(inputs, tools, call, events, parentSpan)
7617
7988
  );
7618
7989
  }
7619
- async function dispatchSingleCall(inputs, tools, call, events) {
7990
+ async function dispatchSingleCall(inputs, tools, call, events, parentSpan) {
7620
7991
  const { call: workingCall, repairs } = applyRepairAndExtractCall(tools, call);
7621
7992
  const callId = generateCallId();
7622
7993
  const forkVeto = vetoFromForkWhitelist(inputs, workingCall, callId, events);
7623
7994
  if (forkVeto !== void 0) return forkVeto;
7624
7995
  const resolved = tools.find((tool) => tool.name === workingCall.name);
7625
- const toolSpan = startToolCallSpan(inputs, workingCall, resolved, callId, repairs);
7996
+ const toolSpan = startToolCallSpan(inputs, workingCall, resolved, callId, repairs, parentSpan);
7626
7997
  events.push(buildToolUseRunning(inputs, callId, workingCall));
7627
7998
  const pluginVeto = await vetoFromPluginPreHook(inputs, workingCall, callId, events);
7628
7999
  if (pluginVeto !== void 0) {
@@ -7639,6 +8010,13 @@ async function dispatchSingleCall(inputs, tools, call, events) {
7639
8010
  return fileVeto;
7640
8011
  }
7641
8012
  const result = await runToolWithLifecycle(inputs, resolved, workingCall, callId);
8013
+ await inputs.pluginManager?.runPostToolCallHooks({
8014
+ name: workingCall.name,
8015
+ args: workingCall.input,
8016
+ result: { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode },
8017
+ agentId: inputs.agentId,
8018
+ runId: inputs.runId
8019
+ });
7642
8020
  return finalizeSpanAndPostHook(inputs, workingCall, callId, result, events, toolSpan);
7643
8021
  }
7644
8022
  function applyRepairAndExtractCall(tools, call) {
@@ -7671,8 +8049,8 @@ function vetoFromForkWhitelist(inputs, call, callId, events) {
7671
8049
  content: `Tool blocked by fork whitelist: ${whitelistDecision.reason}`
7672
8050
  };
7673
8051
  }
7674
- function startToolCallSpan(inputs, call, resolved, callId, repairs) {
7675
- const toolSpan = inputs.telemetry?.startSpan("tool.call", {
8052
+ function startToolCallSpan(inputs, call, resolved, callId, repairs, parentSpan) {
8053
+ const toolSpan = inputs.telemetry?.startChildSpan(parentSpan, "tool.call", {
7676
8054
  "tool.name": call.name,
7677
8055
  "tool.origin": resolved?.origin ?? "unknown",
7678
8056
  callId
@@ -7736,8 +8114,14 @@ async function runToolWithLifecycle(inputs, resolved, call, callId) {
7736
8114
  conversationId: inputs.agentId,
7737
8115
  callId
7738
8116
  });
7739
- const result = await executeTool(inputs, resolved, call);
8117
+ const result = await raceToolExecution(executeTool(inputs, resolved, call), {
8118
+ signal: inputs.signal,
8119
+ timeoutMs: inputs.perToolTimeoutMs
8120
+ });
7740
8121
  const durationMs = Date.now() - startAt;
8122
+ inputs.telemetry?.recordHistogram(HISTOGRAM_NAMES.TOOL_CALL_DURATION_MS, durationMs, {
8123
+ "tool.name": call.name
8124
+ });
7741
8125
  if (result.exitCode !== void 0 && result.exitCode !== 0 && result.exitCode !== null) {
7742
8126
  await safeEmitToolHook(inputs.onToolError, {
7743
8127
  toolName: call.name,
@@ -7840,8 +8224,44 @@ var init_tool_dispatch = __esm({
7840
8224
  init_ids();
7841
8225
  init_async_local_storage();
7842
8226
  init_map_with_concurrency();
8227
+ init_span_names();
7843
8228
  init_repair_middleware();
7844
8229
  init_tool_executors();
8230
+ init_tool_timeout();
8231
+ }
8232
+ });
8233
+
8234
+ // src/internal/agent-loop/tool-result-guard.ts
8235
+ function guardText(content, opts) {
8236
+ let out = content;
8237
+ if (opts.redactPii === true) {
8238
+ for (const re of PII_PATTERNS) out = out.replace(re, "[REDACTED]");
8239
+ }
8240
+ if (opts.delimit === true) {
8241
+ const safe2 = out.split(CLOSE).join("</ untrusted-tool-output>");
8242
+ out = `${OPEN}
8243
+ ${safe2}
8244
+ ${CLOSE}`;
8245
+ }
8246
+ return out;
8247
+ }
8248
+ function applyToolResultGuard(parts, opts) {
8249
+ if (opts.delimit !== true && opts.redactPii !== true) return parts;
8250
+ return parts.map(
8251
+ (p) => p.type === "tool_result" ? { ...p, content: guardText(p.content, opts) } : p
8252
+ );
8253
+ }
8254
+ var OPEN, CLOSE, PII_PATTERNS;
8255
+ var init_tool_result_guard = __esm({
8256
+ "src/internal/agent-loop/tool-result-guard.ts"() {
8257
+ OPEN = "<untrusted-tool-output>";
8258
+ CLOSE = "</untrusted-tool-output>";
8259
+ PII_PATTERNS = [
8260
+ /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
8261
+ // email
8262
+ /\b(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g
8263
+ // phone
8264
+ ];
7845
8265
  }
7846
8266
  });
7847
8267
 
@@ -8225,6 +8645,11 @@ async function runAgentLoop(inputs) {
8225
8645
  try {
8226
8646
  const ctx = await initLoopContext(inputs);
8227
8647
  ctxRef = ctx;
8648
+ ctx.sendSpan = sendSpan;
8649
+ await inputs.pluginManager?.runOnSessionStartHooks({
8650
+ agentId: inputs.agentId,
8651
+ runId: inputs.runId
8652
+ });
8228
8653
  const budget = inputs.budget ?? new IterationBudget({ maxIterations: inputs.maxIterations ?? 8 });
8229
8654
  let lastTurnDecision;
8230
8655
  while (budget.shouldContinue()) {
@@ -8252,6 +8677,7 @@ async function runAgentLoop(inputs) {
8252
8677
  }
8253
8678
  budget.consume();
8254
8679
  inputs.budgetTracker?.nextIteration?.();
8680
+ if (inputs.signal?.aborted === true) break;
8255
8681
  }
8256
8682
  if (lastTurnDecision === "continue" && budget.shouldContinue() === false) {
8257
8683
  ctx.stoppedAtIterationLimit = true;
@@ -8291,6 +8717,10 @@ async function runAgentLoop(inputs) {
8291
8717
  ...ctx.stoppedByDoomLoop === true ? { stoppedByDoomLoop: true } : {}
8292
8718
  };
8293
8719
  } finally {
8720
+ await inputs.pluginManager?.runOnSessionEndHooks({
8721
+ agentId: inputs.agentId,
8722
+ runId: inputs.runId
8723
+ });
8294
8724
  if (ctxRef !== void 0 && ctxRef.memoryProviderHandle !== void 0 && inputs.memoryProvider !== void 0) {
8295
8725
  try {
8296
8726
  await inputs.memoryProvider.dispose(ctxRef.memoryProviderHandle);
@@ -8394,7 +8824,10 @@ async function finishOrReflect(inputs, ctx, llmOutput) {
8394
8824
  return "done";
8395
8825
  }
8396
8826
  async function runIteration(inputs, ctx) {
8827
+ const hookCtx = { agentId: inputs.agentId, runId: inputs.runId };
8828
+ await inputs.pluginManager?.runPreLlmCallHooks(hookCtx);
8397
8829
  const llmOutput = await streamLlmTurn(inputs, ctx);
8830
+ await inputs.pluginManager?.runPostLlmCallHooks(hookCtx);
8398
8831
  accumulateUsage(ctx.usage, llmOutput);
8399
8832
  if (inputs.budgetTracker !== void 0) {
8400
8833
  const modelId = inputs.model.id ?? "auto";
@@ -8420,6 +8853,13 @@ async function runIteration(inputs, ctx) {
8420
8853
  }
8421
8854
  return continueOrTerminate(inputs, ctx, llmOutput);
8422
8855
  }
8856
+ async function transformLlmOutputText(inputs, text, ctx) {
8857
+ return inputs.pluginManager !== void 0 ? inputs.pluginManager.runTransformLlmOutputHooks(text, ctx) : text;
8858
+ }
8859
+ async function guardAndTransformToolResults(inputs, raw, ctx) {
8860
+ const guarded = inputs.toolResultGuard !== void 0 ? applyToolResultGuard(raw, inputs.toolResultGuard) : raw;
8861
+ return inputs.pluginManager !== void 0 ? inputs.pluginManager.runTransformToolResultHooks(guarded, ctx) : guarded;
8862
+ }
8423
8863
  async function continueOrTerminate(inputs, ctx, llmOutput) {
8424
8864
  if (llmOutput.errored) return "error";
8425
8865
  if (llmOutput.text.length > 0) {
@@ -8428,8 +8868,18 @@ async function continueOrTerminate(inputs, ctx, llmOutput) {
8428
8868
  if (llmOutput.stopReason !== "tool_use" || llmOutput.toolCalls.length === 0) {
8429
8869
  return finishOrReflect(inputs, ctx, llmOutput);
8430
8870
  }
8431
- ctx.messages.push(buildAssistantTurn(llmOutput.text, llmOutput.toolCalls));
8432
- const toolResults = await dispatchTools(inputs, ctx.tools, llmOutput.toolCalls, ctx.events);
8871
+ const tCtx = { agentId: inputs.agentId, runId: inputs.runId };
8872
+ const outText = await transformLlmOutputText(inputs, llmOutput.text, tCtx);
8873
+ ctx.messages.push(buildAssistantTurn(outText, llmOutput.toolCalls));
8874
+ const rawResults = await dispatchTools(
8875
+ inputs,
8876
+ ctx.tools,
8877
+ llmOutput.toolCalls,
8878
+ ctx.events,
8879
+ ctx.sendSpan
8880
+ // M3 #64 — nest tool.call spans under agent.send
8881
+ );
8882
+ const toolResults = await guardAndTransformToolResults(inputs, rawResults, tCtx);
8433
8883
  ctx.messages.push({ role: "user", content: toolResults });
8434
8884
  if (inputs.onStep !== void 0) {
8435
8885
  const cb = inputs.onStep;
@@ -8479,6 +8929,7 @@ var init_loop = __esm({
8479
8929
  init_loop_llm_stream();
8480
8930
  init_message_builders();
8481
8931
  init_tool_dispatch();
8932
+ init_tool_result_guard();
8482
8933
  init_usage_and_cost();
8483
8934
  MAX_NUDGE_ATTEMPTS = 2;
8484
8935
  MAX_STOP_FEEDBACK_ATTEMPTS = 2;
@@ -8968,6 +9419,60 @@ var init_anthropic2 = __esm({
8968
9419
  init_shared();
8969
9420
  }
8970
9421
  });
9422
+ function loadJsonrepair() {
9423
+ if (cachedJsonrepair === void 0) {
9424
+ const req = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)));
9425
+ cachedJsonrepair = req("jsonrepair").jsonrepair;
9426
+ }
9427
+ return cachedJsonrepair;
9428
+ }
9429
+ function isPlainObject(v) {
9430
+ return v !== null && typeof v === "object" && !Array.isArray(v);
9431
+ }
9432
+ function toFiniteNumber(raw) {
9433
+ if (raw === "") return void 0;
9434
+ const n = Number(raw);
9435
+ return Number.isFinite(n) && String(n) === raw ? n : void 0;
9436
+ }
9437
+ function tryJson(raw, repair) {
9438
+ const t = raw.trimStart();
9439
+ if (!(t.startsWith("{") || t.startsWith("["))) return void 0;
9440
+ try {
9441
+ return JSON.parse(repair ? loadJsonrepair()(t) : t);
9442
+ } catch {
9443
+ return void 0;
9444
+ }
9445
+ }
9446
+ function heuristicCoerce(raw, repairJson) {
9447
+ if (raw === "true") return true;
9448
+ if (raw === "false") return false;
9449
+ if (raw === "null") return null;
9450
+ const n = toFiniteNumber(raw);
9451
+ if (n !== void 0) return n;
9452
+ const json = tryJson(raw, false) ?? (repairJson ? tryJson(raw, true) : void 0);
9453
+ return json === void 0 ? raw : json;
9454
+ }
9455
+ function coerceCandidates(raw, repairJson) {
9456
+ const out = [];
9457
+ if (raw === "true") out.push(true);
9458
+ else if (raw === "false") out.push(false);
9459
+ else if (raw === "null") out.push(null);
9460
+ const n = toFiniteNumber(raw);
9461
+ if (n !== void 0) out.push(n);
9462
+ const json = tryJson(raw, false) ?? (repairJson ? tryJson(raw, true) : void 0);
9463
+ if (json !== void 0) out.push(json);
9464
+ out.push(raw);
9465
+ return out;
9466
+ }
9467
+ function objectShape(schema) {
9468
+ const shape = schema?.shape;
9469
+ return shape !== null && typeof shape === "object" ? shape : void 0;
9470
+ }
9471
+ var cachedJsonrepair;
9472
+ var init_coerce = __esm({
9473
+ "src/sanitize/coerce.ts"() {
9474
+ }
9475
+ });
8971
9476
 
8972
9477
  // src/internal/llm/finish.ts
8973
9478
  function collapseSystemText(system) {
@@ -8980,9 +9485,21 @@ function parseToolArguments(buffered) {
8980
9485
  try {
8981
9486
  return JSON.parse(buffered);
8982
9487
  } catch {
9488
+ const repaired = tryJson(buffered, true);
9489
+ if (isPlainObject(repaired)) return repaired;
8983
9490
  return { raw: buffered };
8984
9491
  }
8985
9492
  }
9493
+ function mapOpenAIFinish(reason) {
9494
+ switch (reason) {
9495
+ case "tool_calls":
9496
+ return "tool_use";
9497
+ case "length":
9498
+ return "max_tokens";
9499
+ default:
9500
+ return "end_turn";
9501
+ }
9502
+ }
8986
9503
  function makeLlmFinish(state2) {
8987
9504
  const finish = {
8988
9505
  stopReason: state2.stopReason,
@@ -8998,6 +9515,7 @@ function makeLlmFinish(state2) {
8998
9515
  }
8999
9516
  var init_finish = __esm({
9000
9517
  "src/internal/llm/finish.ts"() {
9518
+ init_coerce();
9001
9519
  }
9002
9520
  });
9003
9521
 
@@ -9441,6 +9959,16 @@ var init_credential_pool_types = __esm({
9441
9959
  });
9442
9960
 
9443
9961
  // src/internal/llm/retry.ts
9962
+ function computeBackoffMs(opts) {
9963
+ const base = opts.baseMs ?? DEFAULT_BASE_MS;
9964
+ const cap = opts.capMs ?? DEFAULT_CAP_MS;
9965
+ const rng = opts.rng ?? Math.random;
9966
+ if (opts.retryAfterMs !== void 0 && opts.retryAfterMs >= 0) {
9967
+ return Math.max(base, Math.min(cap, opts.retryAfterMs));
9968
+ }
9969
+ const ceiling = Math.min(cap, base * 2 ** opts.attempt);
9970
+ return Math.floor(rng() * ceiling);
9971
+ }
9444
9972
  function sleepWithAbort(ms, signal) {
9445
9973
  if (ms <= 0 || signal.aborted) return Promise.resolve();
9446
9974
  return new Promise((resolve3) => {
@@ -9455,8 +9983,11 @@ function sleepWithAbort(ms, signal) {
9455
9983
  signal.addEventListener("abort", onAbort, { once: true });
9456
9984
  });
9457
9985
  }
9986
+ var DEFAULT_BASE_MS, DEFAULT_CAP_MS;
9458
9987
  var init_retry = __esm({
9459
9988
  "src/internal/llm/retry.ts"() {
9989
+ DEFAULT_BASE_MS = 500;
9990
+ DEFAULT_CAP_MS = 32e3;
9460
9991
  }
9461
9992
  });
9462
9993
 
@@ -10212,60 +10743,6 @@ var init_ollama_native = __esm({
10212
10743
  ollamaSystemText = collapseSystemText;
10213
10744
  }
10214
10745
  });
10215
- function loadJsonrepair() {
10216
- if (cachedJsonrepair === void 0) {
10217
- const req = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)));
10218
- cachedJsonrepair = req("jsonrepair").jsonrepair;
10219
- }
10220
- return cachedJsonrepair;
10221
- }
10222
- function isPlainObject(v) {
10223
- return v !== null && typeof v === "object" && !Array.isArray(v);
10224
- }
10225
- function toFiniteNumber(raw) {
10226
- if (raw === "") return void 0;
10227
- const n = Number(raw);
10228
- return Number.isFinite(n) && String(n) === raw ? n : void 0;
10229
- }
10230
- function tryJson(raw, repair) {
10231
- const t = raw.trimStart();
10232
- if (!(t.startsWith("{") || t.startsWith("["))) return void 0;
10233
- try {
10234
- return JSON.parse(repair ? loadJsonrepair()(t) : t);
10235
- } catch {
10236
- return void 0;
10237
- }
10238
- }
10239
- function heuristicCoerce(raw, repairJson) {
10240
- if (raw === "true") return true;
10241
- if (raw === "false") return false;
10242
- if (raw === "null") return null;
10243
- const n = toFiniteNumber(raw);
10244
- if (n !== void 0) return n;
10245
- const json = tryJson(raw, false) ?? (repairJson ? tryJson(raw, true) : void 0);
10246
- return json === void 0 ? raw : json;
10247
- }
10248
- function coerceCandidates(raw, repairJson) {
10249
- const out = [];
10250
- if (raw === "true") out.push(true);
10251
- else if (raw === "false") out.push(false);
10252
- else if (raw === "null") out.push(null);
10253
- const n = toFiniteNumber(raw);
10254
- if (n !== void 0) out.push(n);
10255
- const json = tryJson(raw, false) ?? (repairJson ? tryJson(raw, true) : void 0);
10256
- if (json !== void 0) out.push(json);
10257
- out.push(raw);
10258
- return out;
10259
- }
10260
- function objectShape(schema) {
10261
- const shape = schema?.shape;
10262
- return shape !== null && typeof shape === "object" ? shape : void 0;
10263
- }
10264
- var cachedJsonrepair;
10265
- var init_coerce = __esm({
10266
- "src/sanitize/coerce.ts"() {
10267
- }
10268
- });
10269
10746
 
10270
10747
  // src/sanitize/sanitize-tool-input.ts
10271
10748
  function applyTrim(key, value, ctx) {
@@ -10365,28 +10842,87 @@ function parseHermesParams(inner) {
10365
10842
  }
10366
10843
  return sanitizeToolInput(input, { trim: true }).value;
10367
10844
  }
10368
- var HERMES_BLOCK, HERMES_PARAM;
10845
+ function streamToolCallBufferState(held, allowedToolNames, cap = DEFAULT_STREAM_BUFFER_CAP) {
10846
+ if (allowedToolNames.size === 0) return "impossible";
10847
+ const t = held.trimStart();
10848
+ if (t.length < STREAM_MARKER.length) {
10849
+ return STREAM_MARKER.startsWith(t) ? "possible" : "impossible";
10850
+ }
10851
+ if (!t.startsWith(STREAM_MARKER)) return "impossible";
10852
+ const parsed = parseStreamMarkerName(t);
10853
+ if (parsed === "building") return "possible";
10854
+ if (parsed === "invalid") return "impossible";
10855
+ const nameOk = parsed.complete ? allowedToolNames.has(parsed.name) : someToolNameStartsWith(allowedToolNames, parsed.name);
10856
+ if (!nameOk) return "impossible";
10857
+ return held.length > cap ? "impossible" : "possible";
10858
+ }
10859
+ function parseStreamMarkerName(t) {
10860
+ let cursor = STREAM_MARKER.length;
10861
+ while (cursor < t.length && isStreamWs(t[cursor])) cursor += 1;
10862
+ const nameStart = cursor;
10863
+ while (cursor < t.length && t[cursor] !== ">" && !isStreamWs(t[cursor])) cursor += 1;
10864
+ const name = t.slice(nameStart, cursor);
10865
+ if (name.length === 0) return cursor >= t.length ? "building" : "invalid";
10866
+ return { name, complete: cursor < t.length && t[cursor] === ">" };
10867
+ }
10868
+ function someToolNameStartsWith(allowedToolNames, prefix) {
10869
+ for (const name of allowedToolNames) {
10870
+ if (name.startsWith(prefix)) return true;
10871
+ }
10872
+ return false;
10873
+ }
10874
+ function firstPossibleMarkerStart(held, allowedToolNames) {
10875
+ for (let i = held.indexOf("<"); i !== -1; i = held.indexOf("<", i + 1)) {
10876
+ if (streamToolCallBufferState(held.slice(i), allowedToolNames) === "possible") return i;
10877
+ }
10878
+ return -1;
10879
+ }
10880
+ var HERMES_BLOCK, HERMES_PARAM, STREAM_MARKER, DEFAULT_STREAM_BUFFER_CAP, isStreamWs, StreamSuppressionBuffer;
10369
10881
  var init_hermes_tool_extract = __esm({
10370
10882
  "src/internal/llm/hermes-tool-extract.ts"() {
10371
10883
  init_sanitize_tool_input();
10372
10884
  HERMES_BLOCK = /<function=\s*([^>\s]+)\s*>([\s\S]*?)<\/tool_call>/g;
10373
10885
  HERMES_PARAM = /<parameter=\s*([^>\s]+)\s*>([\s\S]*?)<\/parameter>/g;
10886
+ STREAM_MARKER = "<function=";
10887
+ DEFAULT_STREAM_BUFFER_CAP = 8192;
10888
+ isStreamWs = (c) => c === " " || c === " " || c === "\n" || c === "\r";
10889
+ StreamSuppressionBuffer = class {
10890
+ constructor(allowedToolNames) {
10891
+ this.allowedToolNames = allowedToolNames;
10892
+ }
10893
+ allowedToolNames;
10894
+ #held = "";
10895
+ /** Feed a content delta; returns the text to emit as a `text_delta` now, or `undefined` to hold. */
10896
+ push(content) {
10897
+ this.#held += content;
10898
+ if (streamToolCallBufferState(this.#held, this.allowedToolNames) === "possible")
10899
+ return void 0;
10900
+ const holdStart = firstPossibleMarkerStart(this.#held, this.allowedToolNames);
10901
+ if (holdStart > 0) {
10902
+ const flush2 = this.#held.slice(0, holdStart);
10903
+ this.#held = this.#held.slice(holdStart);
10904
+ return flush2;
10905
+ }
10906
+ const flush = this.#held;
10907
+ this.#held = "";
10908
+ return flush;
10909
+ }
10910
+ /** Drain the held buffer at stream end. `hasNativeCalls` mirrors `finish()`'s size-guard: when
10911
+ * native `tool_calls` exist, `finish()` won't strip the leaked block, so stream the held text WHOLE
10912
+ * (keeping `accumulatedText == finish.text`); otherwise strip the recoverable blocks. Idempotent. */
10913
+ drain(hasNativeCalls) {
10914
+ if (this.#held.length === 0) return void 0;
10915
+ const held = this.#held;
10916
+ this.#held = "";
10917
+ if (hasNativeCalls) return held;
10918
+ const residual = extractHermesToolCalls(held, () => "held", this.allowedToolNames).residualText;
10919
+ return residual.length > 0 ? residual : void 0;
10920
+ }
10921
+ };
10374
10922
  }
10375
10923
  });
10376
10924
 
10377
10925
  // src/internal/llm/openai.ts
10378
- function mapOpenAIFinish(reason) {
10379
- switch (reason) {
10380
- case "tool_calls":
10381
- return "tool_use";
10382
- case "length":
10383
- return "max_tokens";
10384
- case "stop":
10385
- return "end_turn";
10386
- default:
10387
- return "end_turn";
10388
- }
10389
- }
10390
10926
  function applyReasoningRequest(body, effort, providerName) {
10391
10927
  if (providerName === "openai") {
10392
10928
  body.reasoning_effort = effort;
@@ -10483,6 +11019,7 @@ function assistantMessage(message) {
10483
11019
  var OpenAIClient, OpenAIStreamAccumulator, openAISystemText;
10484
11020
  var init_openai2 = __esm({
10485
11021
  "src/internal/llm/openai.ts"() {
11022
+ init_errors();
10486
11023
  init_ollama2();
10487
11024
  init_openai_compatible();
10488
11025
  init_finish();
@@ -10563,8 +11100,12 @@ var init_openai2 = __esm({
10563
11100
  // model was actually given. Empty set (no tools) recovers nothing.
10564
11101
  new Set(request.tools?.map((tool) => tool.name) ?? [])
10565
11102
  );
11103
+ let sawDone = false;
10566
11104
  for await (const record of parseSseStream(response.body, signal)) {
10567
- if (record.data === "[DONE]") break;
11105
+ if (record.data === "[DONE]") {
11106
+ sawDone = true;
11107
+ break;
11108
+ }
10568
11109
  let chunk;
10569
11110
  try {
10570
11111
  chunk = JSON.parse(record.data);
@@ -10584,6 +11125,13 @@ var init_openai2 = __esm({
10584
11125
  const events = accumulator.consume(chunk);
10585
11126
  for (const event of events) yield event;
10586
11127
  }
11128
+ if (!sawDone && !accumulator.finishReasonSeen) {
11129
+ throw new NetworkError("SSE stream truncated (no finish_reason / [DONE])", {
11130
+ code: "stream_truncated"
11131
+ });
11132
+ }
11133
+ const drainEvent = accumulator.finalizeHeldText();
11134
+ if (drainEvent !== void 0) yield drainEvent;
10587
11135
  return accumulator.finish();
10588
11136
  }
10589
11137
  };
@@ -10599,6 +11147,7 @@ var init_openai2 = __esm({
10599
11147
  this.extractFromContent = extractFromContent;
10600
11148
  this.providerName = providerName;
10601
11149
  this.allowedToolNames = allowedToolNames;
11150
+ this.suppress = extractFromContent && allowedToolNames !== void 0 && allowedToolNames.size > 0 ? new StreamSuppressionBuffer(allowedToolNames) : void 0;
10602
11151
  }
10603
11152
  extractFromContent;
10604
11153
  providerName;
@@ -10611,18 +11160,30 @@ var init_openai2 = __esm({
10611
11160
  cacheWriteTokens;
10612
11161
  reasoningTokens;
10613
11162
  toolCalls = /* @__PURE__ */ new Map();
11163
+ /** R7: present only when recovery is enabled AND the request declares tools — holds suspected
11164
+ * leaked-dialect content back from the `text_delta` stream. `undefined` ⇒ stream immediately. */
11165
+ suppress;
10614
11166
  consume(chunk) {
10615
11167
  const events = [];
10616
11168
  this.applyUsage(chunk.usage);
10617
11169
  for (const choice of chunk.choices ?? []) {
10618
- const reasoningEvent = this.applyReasoningDelta(
10619
- choice.delta?.reasoning ?? choice.delta?.reasoning_content
10620
- );
10621
- if (reasoningEvent !== void 0) events.push(reasoningEvent);
10622
- const textEvent = this.applyContentDelta(choice.delta?.content);
10623
- if (textEvent !== void 0) events.push(textEvent);
10624
- this.mergeToolCallDeltas(choice.delta?.tool_calls);
10625
- this.applyFinishReason(choice.finish_reason);
11170
+ events.push(...this.applyChoice(choice));
11171
+ }
11172
+ return events;
11173
+ }
11174
+ applyChoice(choice) {
11175
+ const events = [];
11176
+ const reasoningEvent = this.applyReasoningDelta(
11177
+ choice.delta?.reasoning ?? choice.delta?.reasoning_content
11178
+ );
11179
+ if (reasoningEvent !== void 0) events.push(reasoningEvent);
11180
+ const textEvent = this.applyContentDelta(choice.delta?.content);
11181
+ if (textEvent !== void 0) events.push(textEvent);
11182
+ this.mergeToolCallDeltas(choice.delta?.tool_calls);
11183
+ this.applyFinishReason(choice.finish_reason);
11184
+ if (choice.finish_reason !== void 0 && choice.finish_reason !== null) {
11185
+ const flushEvent = this.finalizeHeldText();
11186
+ if (flushEvent !== void 0) events.push(flushEvent);
10626
11187
  }
10627
11188
  return events;
10628
11189
  }
@@ -10647,7 +11208,17 @@ var init_openai2 = __esm({
10647
11208
  applyContentDelta(content) {
10648
11209
  if (typeof content !== "string" || content.length === 0) return void 0;
10649
11210
  this.text += content;
10650
- return { type: "text_delta", text: content };
11211
+ if (this.suppress === void 0) return { type: "text_delta", text: content };
11212
+ const emit = this.suppress.push(content);
11213
+ return emit !== void 0 ? { type: "text_delta", text: emit } : void 0;
11214
+ }
11215
+ /** R7 held-buffer finalizer, called at the `finish_reason` chunk (in `applyChoice`) AND after the
11216
+ * SSE loop in `stream()` — so a stream that omits a `finish_reason` terminal never silently drops
11217
+ * held text. `toolCalls.size > 0` (native calls present) makes `finish()` skip recovery, so the
11218
+ * buffer streams the held text whole. Idempotent once drained. */
11219
+ finalizeHeldText() {
11220
+ const emit = this.suppress?.drain(this.toolCalls.size > 0);
11221
+ return emit !== void 0 ? { type: "text_delta", text: emit } : void 0;
10651
11222
  }
10652
11223
  mergeToolCallDeltas(deltas) {
10653
11224
  for (const call of deltas ?? []) {
@@ -10658,8 +11229,15 @@ var init_openai2 = __esm({
10658
11229
  this.toolCalls.set(call.index, existing);
10659
11230
  }
10660
11231
  }
11232
+ /** M2 #61 — true once any chunk carried a non-null `finish_reason` (else a
11233
+ * stream ending without `[DONE]` is a truncation, not a clean end). */
11234
+ sawFinishReason = false;
11235
+ get finishReasonSeen() {
11236
+ return this.sawFinishReason;
11237
+ }
10661
11238
  applyFinishReason(reason) {
10662
11239
  if (reason === void 0 || reason === null) return;
11240
+ this.sawFinishReason = true;
10663
11241
  this.stopReason = mapOpenAIFinish(reason);
10664
11242
  }
10665
11243
  finish() {
@@ -10708,6 +11286,53 @@ var init_openai2 = __esm({
10708
11286
  }
10709
11287
  });
10710
11288
 
11289
+ // src/internal/resilience/circuit-breaker.ts
11290
+ var DEFAULT_MAX_TIMEOUTS, DEFAULT_COOLDOWN_MS2, CircuitBreaker;
11291
+ var init_circuit_breaker = __esm({
11292
+ "src/internal/resilience/circuit-breaker.ts"() {
11293
+ DEFAULT_MAX_TIMEOUTS = 3;
11294
+ DEFAULT_COOLDOWN_MS2 = 6e4;
11295
+ CircuitBreaker = class {
11296
+ constructor(opts = {}) {
11297
+ this.opts = opts;
11298
+ }
11299
+ opts;
11300
+ states = /* @__PURE__ */ new Map();
11301
+ /** @returns true when the breaker is open and the call should be skipped. */
11302
+ shouldSkip(key) {
11303
+ const state2 = this.states.get(key);
11304
+ if (state2 === void 0) return false;
11305
+ if (state2.cooldownUntilMs === 0) return false;
11306
+ if (this.now() < state2.cooldownUntilMs) return true;
11307
+ state2.cooldownUntilMs = 0;
11308
+ state2.consecutiveTimeouts = 0;
11309
+ return false;
11310
+ }
11311
+ recordSuccess(key) {
11312
+ const state2 = this.states.get(key);
11313
+ if (state2 === void 0) return;
11314
+ state2.consecutiveTimeouts = 0;
11315
+ state2.cooldownUntilMs = 0;
11316
+ }
11317
+ recordTimeout(key) {
11318
+ const state2 = this.states.get(key) ?? { consecutiveTimeouts: 0, cooldownUntilMs: 0 };
11319
+ state2.consecutiveTimeouts += 1;
11320
+ if (state2.consecutiveTimeouts >= (this.opts.maxTimeouts ?? DEFAULT_MAX_TIMEOUTS)) {
11321
+ state2.cooldownUntilMs = this.now() + (this.opts.cooldownMs ?? DEFAULT_COOLDOWN_MS2);
11322
+ }
11323
+ this.states.set(key, state2);
11324
+ }
11325
+ /** @internal — tests inspect counter state. */
11326
+ inspect(key) {
11327
+ return this.states.get(key) ?? { consecutiveTimeouts: 0, cooldownUntilMs: 0 };
11328
+ }
11329
+ now() {
11330
+ return this.opts.now?.() ?? Date.now();
11331
+ }
11332
+ };
11333
+ }
11334
+ });
11335
+
10711
11336
  // src/internal/llm/pool-aware-client.ts
10712
11337
  function classifyAndDecide(error, hasRetried429) {
10713
11338
  if (error instanceof NetworkError) return "propagate";
@@ -10736,20 +11361,34 @@ var PoolAwareLlmClient;
10736
11361
  var init_pool_aware_client = __esm({
10737
11362
  "src/internal/llm/pool-aware-client.ts"() {
10738
11363
  init_errors();
11364
+ init_circuit_breaker();
11365
+ init_retry();
10739
11366
  init_stream_relay();
10740
11367
  PoolAwareLlmClient = class {
10741
- constructor(pool, buildClient2, waitForAvailableMs = 3e4) {
11368
+ constructor(pool, buildClient2, waitForAvailableMs = 3e4, resilience = {}) {
10742
11369
  this.pool = pool;
10743
11370
  this.buildClient = buildClient2;
10744
11371
  this.waitForAvailableMs = waitForAvailableMs;
10745
11372
  this.name = `pool-aware:${pool.provider}`;
11373
+ this.breaker = resilience.breaker ?? new CircuitBreaker();
11374
+ this.backoffBaseMs = resilience.backoffBaseMs;
11375
+ this.rng = resilience.rng;
10746
11376
  }
10747
11377
  pool;
10748
11378
  buildClient;
10749
11379
  waitForAvailableMs;
10750
11380
  name;
11381
+ /** M2 #60 — provider-level circuit breaker (consecutive-failure). */
11382
+ breaker;
11383
+ backoffBaseMs;
11384
+ rng;
10751
11385
  // 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.
10752
11386
  async *stream(request, signal) {
11387
+ if (this.breaker.shouldSkip(this.pool.provider)) {
11388
+ throw new NetworkError(`${this.pool.provider} circuit open \u2014 failing fast`, {
11389
+ code: "circuit_open"
11390
+ });
11391
+ }
10753
11392
  let hasRetried429 = false;
10754
11393
  while (true) {
10755
11394
  if (signal.aborted) throw abortError2(signal);
@@ -10764,6 +11403,7 @@ var init_pool_aware_client = __esm({
10764
11403
  }
10765
11404
  }
10766
11405
  if (entry === null) {
11406
+ this.breaker.recordTimeout(this.pool.provider);
10767
11407
  throw new CredentialPoolExhaustedError(
10768
11408
  `All ${this.pool.provider} credentials exhausted; next retry available at ${this.nextRetryHint() ?? "unknown"}`,
10769
11409
  { provider: this.pool.provider, nextRetryAt: this.nextRetryHint() }
@@ -10773,10 +11413,19 @@ var init_pool_aware_client = __esm({
10773
11413
  const realClient = this.buildClient(entry.accessToken);
10774
11414
  const attempt = await tryFirstEvent(realClient, request, signal);
10775
11415
  if (attempt.kind === "ok") {
11416
+ this.breaker.recordSuccess(this.pool.provider);
10776
11417
  return yield* relayStream(attempt.generator, attempt.firstResult);
10777
11418
  }
10778
11419
  const decision = classifyAndDecide(attempt.error, hasRetried429);
10779
11420
  if (decision === "retry") {
11421
+ await sleepWithAbort(
11422
+ computeBackoffMs({
11423
+ attempt: 0,
11424
+ ...this.backoffBaseMs !== void 0 ? { baseMs: this.backoffBaseMs } : {},
11425
+ ...this.rng !== void 0 ? { rng: this.rng } : {}
11426
+ }),
11427
+ signal
11428
+ );
10780
11429
  hasRetried429 = true;
10781
11430
  continue;
10782
11431
  }
@@ -10796,6 +11445,7 @@ var init_pool_aware_client = __esm({
10796
11445
  hasRetried429 = false;
10797
11446
  continue;
10798
11447
  }
11448
+ this.breaker.recordTimeout(this.pool.provider);
10799
11449
  throw attempt.error;
10800
11450
  }
10801
11451
  }
@@ -11254,9 +11904,24 @@ var init_router = __esm({
11254
11904
  warnedProviders = /* @__PURE__ */ new Set();
11255
11905
  }
11256
11906
  });
11257
- function createMcpClient(name, config) {
11907
+ function createMcpClient(name, config, fetchImpl = fetch) {
11258
11908
  if (isStdio(config)) return new StdioMcpClient(name, config);
11259
- return new HttpMcpClient(name, config);
11909
+ return new HttpMcpClient(name, config, fetchImpl);
11910
+ }
11911
+ function reconnectDelay(attempt) {
11912
+ const ceiling = RECONNECT_BASE_MS * 2 ** attempt;
11913
+ const ms = Math.floor(Math.random() * ceiling);
11914
+ return ms <= 0 ? Promise.resolve() : new Promise((resolve3) => setTimeout(resolve3, ms));
11915
+ }
11916
+ function mcpTimeoutError(name, timeoutMs) {
11917
+ return new NetworkError(`MCP ${name} request timed out after ${timeoutMs}ms`, {
11918
+ code: "mcp_timeout"
11919
+ });
11920
+ }
11921
+ function isAbortLike(cause) {
11922
+ if (typeof cause !== "object" || cause === null || !("name" in cause)) return false;
11923
+ const name = cause.name;
11924
+ return name === "TimeoutError" || name === "AbortError";
11260
11925
  }
11261
11926
  async function rpcInitialize(request) {
11262
11927
  await request("initialize", {
@@ -11286,11 +11951,16 @@ function resolveMcpCwd(configCwd) {
11286
11951
  if (path.isAbsolute(configCwd)) return configCwd;
11287
11952
  return safePathJoin(process.cwd(), configCwd);
11288
11953
  }
11289
- var BaseMcpClient, StdioMcpClient, HttpMcpClient;
11954
+ var DEFAULT_MCP_TIMEOUT_MS, MAX_STDIO_BUFFER_BYTES, RECONNECT_BASE_MS, MAX_RECONNECT_ATTEMPTS, BaseMcpClient, StdioMcpClient, HttpMcpClient;
11290
11955
  var init_client = __esm({
11291
11956
  "src/internal/mcp/client.ts"() {
11292
11957
  init_errors();
11958
+ init_env_policy();
11293
11959
  init_path_guard();
11960
+ DEFAULT_MCP_TIMEOUT_MS = 3e4;
11961
+ MAX_STDIO_BUFFER_BYTES = 8 * 1024 * 1024;
11962
+ RECONNECT_BASE_MS = 250;
11963
+ MAX_RECONNECT_ATTEMPTS = 2;
11294
11964
  BaseMcpClient = class {
11295
11965
  initialize() {
11296
11966
  return rpcInitialize((method, params) => this.request(method, params));
@@ -11312,32 +11982,107 @@ var init_client = __esm({
11312
11982
  name;
11313
11983
  child;
11314
11984
  nextId = 1;
11985
+ // #59 — pending requests carry a reject + timer so a silent server times out
11986
+ // (typed error), a late reply after timeout is a no-op, and close() settles them.
11315
11987
  pending = /* @__PURE__ */ new Map();
11316
11988
  buffer = "";
11317
- async initialize() {
11989
+ // M2 #59 — reconnect-after-drop state. `dropped` is set when the child exits
11990
+ // unexpectedly OR times out (not via close()); the next request re-spawns with
11991
+ // backoff. `reconnectPromise` is a SINGLE in-flight reconnect shared by every
11992
+ // concurrent request so parallel tool dispatch after a drop awaits one handshake
11993
+ // instead of racing (or spuriously failing with mcp_not_init).
11994
+ dropped = false;
11995
+ reconnectAttempts = 0;
11996
+ reconnectPromise;
11997
+ get timeoutMs() {
11998
+ return this.config.requestTimeoutMs ?? DEFAULT_MCP_TIMEOUT_MS;
11999
+ }
12000
+ /** Spawn the server child and wire stdout/stderr/error/exit handlers.
12001
+ * Shared by `initialize()` and the M2 #59 reconnect path. */
12002
+ spawnChild() {
11318
12003
  const resolvedCwd = resolveMcpCwd(this.config.cwd);
11319
12004
  const child = child_process.spawn(this.config.command, this.config.args ?? [], {
11320
12005
  cwd: resolvedCwd,
11321
- env: { ...process.env, ...this.config.env ?? {} }
12006
+ // #54 (F-H1) a third-party MCP server binary must not inherit host
12007
+ // secrets. Scrub secret-like vars by default; `config.env` still wins.
12008
+ env: resolveChildEnv({ policy: this.config.envPolicy, overrides: this.config.env })
11322
12009
  });
11323
12010
  this.child = child;
11324
12011
  child.stdout.on("data", (chunk) => this.consume(chunk));
11325
12012
  child.stderr.on("data", () => void 0);
12013
+ child.stdin.on("error", () => void 0);
11326
12014
  child.on("error", () => {
11327
- for (const resolver of this.pending.values()) {
11328
- resolver({ error: { message: "MCP process crashed" } });
11329
- }
11330
- this.pending.clear();
12015
+ this.rejectAllPending(
12016
+ new NetworkError(`MCP ${this.name} process crashed`, { code: "mcp_crashed" })
12017
+ );
12018
+ });
12019
+ child.on("exit", () => {
12020
+ if (this.child !== child) return;
12021
+ this.child = void 0;
12022
+ this.dropped = true;
12023
+ this.rejectAllPending(
12024
+ new NetworkError(`MCP ${this.name} disconnected`, { code: "mcp_disconnected" })
12025
+ );
12026
+ });
12027
+ }
12028
+ async initialize() {
12029
+ this.spawnChild();
12030
+ await super.initialize();
12031
+ }
12032
+ /** M2 #59 — ensure a live child before a request. Reconnect (bounded, with
12033
+ * full-jitter backoff) when the client was dropped; fail fast when never
12034
+ * initialized. Concurrent callers share ONE reconnect handshake. */
12035
+ ensureConnected() {
12036
+ if (this.child !== void 0) return Promise.resolve();
12037
+ if (!this.dropped) {
12038
+ return Promise.reject(
12039
+ new ConfigurationError(`MCP ${this.name} is not initialized`, { code: "mcp_not_init" })
12040
+ );
12041
+ }
12042
+ this.reconnectPromise ??= this.reconnect().finally(() => {
12043
+ this.reconnectPromise = void 0;
11331
12044
  });
12045
+ return this.reconnectPromise;
12046
+ }
12047
+ async reconnect() {
12048
+ if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
12049
+ throw new NetworkError(`MCP ${this.name} reconnect exhausted`, { code: "mcp_disconnected" });
12050
+ }
12051
+ await reconnectDelay(this.reconnectAttempts);
12052
+ this.reconnectAttempts += 1;
12053
+ this.spawnChild();
11332
12054
  await super.initialize();
12055
+ this.dropped = false;
12056
+ this.reconnectAttempts = 0;
11333
12057
  }
11334
12058
  async close() {
11335
- if (this.child === void 0) return;
11336
- this.child.kill("SIGTERM");
12059
+ this.rejectAllPending(new NetworkError(`MCP ${this.name} closed`, { code: "mcp_closed" }));
12060
+ const child = this.child;
11337
12061
  this.child = void 0;
12062
+ this.dropped = false;
12063
+ child?.kill("SIGTERM");
12064
+ }
12065
+ /** Reject + clear every pending request (crash / close). @internal */
12066
+ rejectAllPending(error) {
12067
+ for (const entry of this.pending.values()) {
12068
+ clearTimeout(entry.timer);
12069
+ entry.reject(error);
12070
+ }
12071
+ this.pending.clear();
11338
12072
  }
11339
12073
  consume(chunk) {
11340
12074
  this.buffer += chunk.toString("utf8");
12075
+ if (this.buffer.length > MAX_STDIO_BUFFER_BYTES) {
12076
+ this.buffer = "";
12077
+ this.rejectAllPending(
12078
+ new NetworkError(`MCP ${this.name} exceeded stdout buffer limit`, {
12079
+ code: "mcp_buffer_overflow"
12080
+ })
12081
+ );
12082
+ this.child?.kill("SIGKILL");
12083
+ this.child = void 0;
12084
+ return;
12085
+ }
11341
12086
  let newlineIndex = this.buffer.indexOf("\n");
11342
12087
  while (newlineIndex !== -1) {
11343
12088
  const line = this.buffer.slice(0, newlineIndex).trim();
@@ -11354,23 +12099,47 @@ var init_client = __esm({
11354
12099
  return;
11355
12100
  }
11356
12101
  if (typeof message.id !== "number") return;
11357
- const resolver = this.pending.get(message.id);
11358
- if (resolver === void 0) return;
12102
+ const entry = this.pending.get(message.id);
12103
+ if (entry === void 0) return;
11359
12104
  this.pending.delete(message.id);
11360
- resolver(message);
12105
+ clearTimeout(entry.timer);
12106
+ entry.resolve(message);
11361
12107
  }
11362
12108
  request(method, params) {
11363
- if (this.child === void 0) {
11364
- return Promise.reject(
11365
- new ConfigurationError(`MCP ${this.name} is not initialized`, { code: "mcp_not_init" })
11366
- );
12109
+ const child = this.child;
12110
+ if (child !== void 0) return this.send(child, method, params);
12111
+ if (this.dropped) return this.reconnectAndRequest(method, params);
12112
+ return Promise.reject(
12113
+ new ConfigurationError(`MCP ${this.name} is not initialized`, { code: "mcp_not_init" })
12114
+ );
12115
+ }
12116
+ /** M2 #59 — reconnect a dropped client, then send. Separate async path so the
12117
+ * happy path above never pays an extra microtask tick. */
12118
+ async reconnectAndRequest(method, params) {
12119
+ await this.ensureConnected();
12120
+ const child = this.child;
12121
+ if (child === void 0) {
12122
+ throw new ConfigurationError(`MCP ${this.name} is not initialized`, { code: "mcp_not_init" });
11367
12123
  }
12124
+ return this.send(child, method, params);
12125
+ }
12126
+ send(child, method, params) {
11368
12127
  const id = this.nextId++;
11369
12128
  const payload = { jsonrpc: "2.0", id, method, params };
11370
- this.child.stdin.write(`${JSON.stringify(payload)}
12129
+ child.stdin.write(`${JSON.stringify(payload)}
11371
12130
  `);
11372
- return new Promise((resolve3) => {
11373
- this.pending.set(id, resolve3);
12131
+ return new Promise((resolve3, reject) => {
12132
+ const timer = setTimeout(() => {
12133
+ this.pending.delete(id);
12134
+ reject(mcpTimeoutError(this.name, this.timeoutMs));
12135
+ this.child?.kill("SIGKILL");
12136
+ this.child = void 0;
12137
+ this.dropped = true;
12138
+ this.rejectAllPending(
12139
+ new NetworkError(`MCP ${this.name} disconnected`, { code: "mcp_disconnected" })
12140
+ );
12141
+ }, this.timeoutMs);
12142
+ this.pending.set(id, { resolve: resolve3, reject, timer });
11374
12143
  });
11375
12144
  }
11376
12145
  };
@@ -11396,11 +12165,20 @@ var init_client = __esm({
11396
12165
  accept: "application/json",
11397
12166
  ...this.config.headers ?? {}
11398
12167
  };
11399
- const response = await this.fetchImpl(this.config.url, {
11400
- method: "POST",
11401
- headers,
11402
- body: JSON.stringify(payload)
11403
- });
12168
+ const timeoutMs = this.config.requestTimeoutMs ?? DEFAULT_MCP_TIMEOUT_MS;
12169
+ let response;
12170
+ try {
12171
+ response = await this.fetchImpl(this.config.url, {
12172
+ method: "POST",
12173
+ headers,
12174
+ body: JSON.stringify(payload),
12175
+ // #59 — bound the request; a non-responding endpoint aborts here.
12176
+ signal: AbortSignal.timeout(timeoutMs)
12177
+ });
12178
+ } catch (cause) {
12179
+ if (isAbortLike(cause)) throw mcpTimeoutError(this.name, timeoutMs);
12180
+ throw cause;
12181
+ }
11404
12182
  if (!response.ok) {
11405
12183
  throw new NetworkError(`MCP ${this.name} returned ${response.status}`, {
11406
12184
  code: "mcp_http_error"
@@ -11520,11 +12298,33 @@ function resolveRunProvider(options) {
11520
12298
  );
11521
12299
  }
11522
12300
  const parsedModel = parseModelId(options.model?.id);
11523
- const inferredProvider = parsedModel.provider !== void 0 && getProviderProfile(parsedModel.provider) !== void 0 ? parsedModel.provider : void 0;
11524
- const primary = options.agentOptions.providers?.routes?.[0]?.provider ?? inferredProvider ?? detectPrimaryProvider();
11525
- const effectiveModelId = inferredProvider !== void 0 ? parsedModel.name : options.model?.id ?? "claude-sonnet-4-6";
12301
+ const modelInferredProvider = parsedModel.provider !== void 0 && getProviderProfile(parsedModel.provider) !== void 0 ? parsedModel.provider : void 0;
12302
+ const keyInferredProvider = inferProviderFromApiKey(options.agentOptions.apiKey);
12303
+ const primary = options.agentOptions.providers?.routes?.[0]?.provider ?? keyInferredProvider ?? modelInferredProvider ?? detectPrimaryProvider();
12304
+ const effectiveModelId = modelInferredProvider !== void 0 && modelInferredProvider === primary ? parsedModel.name : options.model?.id ?? "claude-sonnet-4-6";
11526
12305
  return { primary, effectiveModelId };
11527
12306
  }
12307
+ function inferProviderFromApiKey(apiKey) {
12308
+ if (apiKey === void 0 || apiKey.length === 0) return void 0;
12309
+ const byPrefix = [
12310
+ { provider: "openrouter", prefix: "sk-or-" },
12311
+ { provider: "anthropic", prefix: "sk-ant-" },
12312
+ { provider: "openai", prefix: "sk-" }
12313
+ ];
12314
+ for (const { provider, prefix } of byPrefix) {
12315
+ if (apiKey.startsWith(prefix) && getProviderProfile(provider) !== void 0) {
12316
+ return provider;
12317
+ }
12318
+ }
12319
+ return void 0;
12320
+ }
12321
+ function mergeExplicitApiKey(pools, primary, apiKey) {
12322
+ if (apiKey === void 0 || apiKey.length === 0) return pools;
12323
+ if (isFixtureApiKey(apiKey) || apiKey === LOCAL_RUNTIME_MOCK_KEY) return pools;
12324
+ const existing = pools?.[primary];
12325
+ if (existing !== void 0 && existing.length > 0) return pools;
12326
+ return { ...pools ?? {}, [primary]: [apiKey] };
12327
+ }
11528
12328
  function buildLoopInputs(options, runId, userText) {
11529
12329
  const maxIterations = options.sendOptions.maxIterations;
11530
12330
  if (maxIterations !== void 0 && (!Number.isInteger(maxIterations) || maxIterations < 1)) {
@@ -11535,7 +12335,11 @@ function buildLoopInputs(options, runId, userText) {
11535
12335
  }
11536
12336
  const { primary, effectiveModelId } = resolveRunProvider(options);
11537
12337
  const fallback = options.agentOptions.providers?.fallback;
11538
- const apiKeys = options.agentOptions.providers?.apiKeys;
12338
+ const apiKeys = mergeExplicitApiKey(
12339
+ options.agentOptions.providers?.apiKeys,
12340
+ primary,
12341
+ options.agentOptions.apiKey
12342
+ );
11539
12343
  const credentialPoolStrategy = options.agentOptions.providers?.credentialPoolStrategy;
11540
12344
  const extractToolCallsFromContent = options.agentOptions.providers?.routes?.[0]?.extractToolCallsFromContent;
11541
12345
  const chain = resolveProviderChain({
@@ -11579,6 +12383,10 @@ function buildLoopInputs(options, runId, userText) {
11579
12383
  // D318 — forward SendOptions.signal to the agent loop so streamLlmTurn
11580
12384
  // can attach it to the LLM `fetch({ signal })` call.
11581
12385
  ...options.sendOptions.signal !== void 0 ? { signal: options.sendOptions.signal } : {},
12386
+ // #58 / #57 — forward the per-tool timeout + tool-result guard so a consumer
12387
+ // can enable them via SendOptions (not only internal AgentLoopInputs).
12388
+ ...options.sendOptions.perToolTimeoutMs !== void 0 ? { perToolTimeoutMs: options.sendOptions.perToolTimeoutMs } : {},
12389
+ ...options.sendOptions.toolResultGuard !== void 0 ? { toolResultGuard: options.sendOptions.toolResultGuard } : {},
11582
12390
  // M1-2: per-send iteration ceiling (validated above). The loop reads
11583
12391
  // inputs.maxIterations (default 8 when unset).
11584
12392
  ...maxIterations !== void 0 ? { maxIterations } : {},
@@ -11645,6 +12453,8 @@ var init_real_local_run = __esm({
11645
12453
  "src/internal/runtime/local-agent/real-local-run.ts"() {
11646
12454
  init_errors();
11647
12455
  init_loop();
12456
+ init_api_key_validator();
12457
+ init_fixture_mode();
11648
12458
  init_fallback_client();
11649
12459
  init_model_identifier();
11650
12460
  init_router();
@@ -11921,7 +12731,12 @@ async function runActiveMemory(args) {
11921
12731
  hits: []
11922
12732
  });
11923
12733
  }
11924
- const cached2 = args.cache?.get(args.userText, cfg.queryMode);
12734
+ const tenantCtx = {
12735
+ namespace: args.namespace,
12736
+ userId: args.userId,
12737
+ scope: args.scope
12738
+ };
12739
+ const cached2 = args.cache?.get(args.userText, cfg.queryMode, tenantCtx);
11925
12740
  if (cached2 !== void 0) return endRecallSpan(span, args, cached2);
11926
12741
  const query = buildQuery(args.userText, args.priorMessages, cfg.queryMode, cfg.recentUserTurns);
11927
12742
  if (query.trim().length === 0) {
@@ -12006,7 +12821,12 @@ function notifyBreaker(breaker, key, status) {
12006
12821
  else if (status === "ok" || status === "no-recall") breaker.recordSuccess(key);
12007
12822
  }
12008
12823
  async function finalize(args, queryMode, result) {
12009
- args.cache?.set(args.userText, queryMode, result);
12824
+ const tenantCtx = {
12825
+ namespace: args.namespace,
12826
+ userId: args.userId,
12827
+ scope: args.scope
12828
+ };
12829
+ args.cache?.set(args.userText, queryMode, result, tenantCtx);
12010
12830
  if (args.persistTranscripts === true && args.cwd !== void 0) {
12011
12831
  await persistActiveMemoryTranscript(args.cwd, {
12012
12832
  runId: args.runId ?? `run-${Date.now()}`,
@@ -12711,53 +13531,6 @@ var init_catalog = __esm({
12711
13531
  }
12712
13532
  });
12713
13533
 
12714
- // src/internal/memory/circuit-breaker.ts
12715
- var DEFAULT_MAX_TIMEOUTS, DEFAULT_COOLDOWN_MS2, CircuitBreaker;
12716
- var init_circuit_breaker = __esm({
12717
- "src/internal/memory/circuit-breaker.ts"() {
12718
- DEFAULT_MAX_TIMEOUTS = 3;
12719
- DEFAULT_COOLDOWN_MS2 = 6e4;
12720
- CircuitBreaker = class {
12721
- constructor(opts = {}) {
12722
- this.opts = opts;
12723
- }
12724
- opts;
12725
- states = /* @__PURE__ */ new Map();
12726
- /** @returns true when the breaker is open and the call should be skipped. */
12727
- shouldSkip(key) {
12728
- const state2 = this.states.get(key);
12729
- if (state2 === void 0) return false;
12730
- if (state2.cooldownUntilMs === 0) return false;
12731
- if (this.now() < state2.cooldownUntilMs) return true;
12732
- state2.cooldownUntilMs = 0;
12733
- state2.consecutiveTimeouts = 0;
12734
- return false;
12735
- }
12736
- recordSuccess(key) {
12737
- const state2 = this.states.get(key);
12738
- if (state2 === void 0) return;
12739
- state2.consecutiveTimeouts = 0;
12740
- state2.cooldownUntilMs = 0;
12741
- }
12742
- recordTimeout(key) {
12743
- const state2 = this.states.get(key) ?? { consecutiveTimeouts: 0, cooldownUntilMs: 0 };
12744
- state2.consecutiveTimeouts += 1;
12745
- if (state2.consecutiveTimeouts >= (this.opts.maxTimeouts ?? DEFAULT_MAX_TIMEOUTS)) {
12746
- state2.cooldownUntilMs = this.now() + (this.opts.cooldownMs ?? DEFAULT_COOLDOWN_MS2);
12747
- }
12748
- this.states.set(key, state2);
12749
- }
12750
- /** @internal — tests inspect counter state. */
12751
- inspect(key) {
12752
- return this.states.get(key) ?? { consecutiveTimeouts: 0, cooldownUntilMs: 0 };
12753
- }
12754
- now() {
12755
- return this.opts.now?.() ?? Date.now();
12756
- }
12757
- };
12758
- }
12759
- });
12760
-
12761
13534
  // src/internal/persistence/fts5-sanitize.ts
12762
13535
  function sanitizeFts5Query(query) {
12763
13536
  if (query.length === 0) return query;
@@ -13923,9 +14696,9 @@ var init_local_agent_memory = __esm({
13923
14696
  init_active_memory();
13924
14697
  init_active_memory_cache();
13925
14698
  init_catalog();
13926
- init_circuit_breaker();
13927
14699
  init_index_manager();
13928
14700
  init_tools();
14701
+ init_circuit_breaker();
13929
14702
  LocalAgentMemory = class {
13930
14703
  constructor(options, workspaceCwd, agentId) {
13931
14704
  this.options = options;