@theokit/sdk 2.15.1 → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/a2a/index.cjs +981 -208
  3. package/dist/a2a/index.cjs.map +1 -1
  4. package/dist/a2a/index.js +982 -209
  5. package/dist/a2a/index.js.map +1 -1
  6. package/dist/{cron-BxLSz1UH.d.cts → cron-Bbg0mBOv.d.ts} +33 -3
  7. package/dist/{cron-DcaoP7aW.d.ts → cron-ZLSKbDbB.d.cts} +33 -3
  8. package/dist/cron.cjs +945 -196
  9. package/dist/cron.cjs.map +1 -1
  10. package/dist/cron.d.cts +2 -2
  11. package/dist/cron.d.ts +2 -2
  12. package/dist/cron.js +945 -196
  13. package/dist/cron.js.map +1 -1
  14. package/dist/define-tool.d.ts +9 -2
  15. package/dist/{errors-Bart0ptP.d.cts → errors-1tVcX3Fq.d.cts} +1 -1
  16. package/dist/{errors-DJuuubJK.d.ts → errors-qyVYfk9H.d.ts} +1 -1
  17. package/dist/errors.d.cts +2 -2
  18. package/dist/eval.cjs +951 -198
  19. package/dist/eval.cjs.map +1 -1
  20. package/dist/eval.js +951 -198
  21. package/dist/eval.js.map +1 -1
  22. package/dist/event-bus.d.ts +3 -0
  23. package/dist/index.cjs +1082 -224
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +122 -27
  26. package/dist/index.d.ts +122 -27
  27. package/dist/index.js +1082 -226
  28. package/dist/index.js.map +1 -1
  29. package/dist/internal/agent-loop/tool-dispatch.d.ts +3 -1
  30. package/dist/internal/agent-loop/tool-result-guard.d.ts +24 -0
  31. package/dist/internal/agent-loop/tool-timeout.d.ts +23 -0
  32. package/dist/internal/llm/openai.d.ts +13 -0
  33. package/dist/internal/llm/sse.d.ts +13 -1
  34. package/dist/internal/mcp/client.d.ts +1 -1
  35. package/dist/internal/memory/active-memory.d.ts +1 -1
  36. package/dist/internal/persistence/conversation-storage-fs.d.cts +7 -1
  37. package/dist/internal/persistence/conversation-storage-fs.d.ts +7 -1
  38. package/dist/internal/persistence/conversation-storage-memory.d.cts +7 -1
  39. package/dist/internal/persistence/conversation-storage-memory.d.ts +7 -1
  40. package/dist/internal/persistence/pagination.d.cts +8 -0
  41. package/dist/internal/persistence/pagination.d.ts +8 -0
  42. package/dist/internal/plugins/index.cjs +135 -0
  43. package/dist/internal/plugins/index.cjs.map +1 -1
  44. package/dist/internal/plugins/index.js +135 -0
  45. package/dist/internal/plugins/index.js.map +1 -1
  46. package/dist/internal/plugins/manager.d.cts +21 -1
  47. package/dist/internal/plugins/manager.d.ts +21 -1
  48. package/dist/internal/plugins/types.d.cts +40 -0
  49. package/dist/internal/plugins/types.d.ts +40 -0
  50. package/dist/internal/{memory → resilience}/circuit-breaker.d.ts +5 -1
  51. package/dist/internal/runtime/hooks/hooks-frontmatter.d.ts +1 -1
  52. package/dist/internal/runtime/lifecycle/env-policy.d.ts +30 -0
  53. package/dist/internal/runtime/session/agent-session-store.d.ts +1 -0
  54. package/dist/internal/telemetry/span-names.d.ts +7 -1
  55. package/dist/job-queue.d.ts +29 -7
  56. package/dist/permission-engine.d.ts +32 -7
  57. package/dist/{run-DXy_MVwz.d.cts → run-pE-34AAo.d.cts} +64 -3
  58. package/dist/{run-DXy_MVwz.d.ts → run-pE-34AAo.d.ts} +64 -3
  59. package/dist/sandbox/index.cjs +53 -2
  60. package/dist/sandbox/index.cjs.map +1 -1
  61. package/dist/sandbox/index.js +53 -2
  62. package/dist/sandbox/index.js.map +1 -1
  63. package/dist/sandbox/local-sandbox.d.cts +11 -3
  64. package/dist/sandbox/local-sandbox.d.ts +11 -3
  65. package/dist/sandbox/types.d.cts +7 -0
  66. package/dist/sandbox/types.d.ts +7 -0
  67. package/dist/types/agent-prims.d.ts +6 -2
  68. package/dist/types/conversation-storage.d.ts +32 -2
  69. package/dist/types/mcp.d.ts +20 -0
  70. package/dist/types/run.d.ts +17 -0
  71. package/dist/workflow.cjs +6 -3
  72. package/dist/workflow.cjs.map +1 -1
  73. package/dist/workflow.js +6 -3
  74. package/dist/workflow.js.map +1 -1
  75. package/package.json +1 -1
package/dist/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) {
@@ -10362,28 +10839,87 @@ function parseHermesParams(inner) {
10362
10839
  }
10363
10840
  return sanitizeToolInput(input, { trim: true }).value;
10364
10841
  }
10365
- var HERMES_BLOCK, HERMES_PARAM;
10842
+ function streamToolCallBufferState(held, allowedToolNames, cap = DEFAULT_STREAM_BUFFER_CAP) {
10843
+ if (allowedToolNames.size === 0) return "impossible";
10844
+ const t = held.trimStart();
10845
+ if (t.length < STREAM_MARKER.length) {
10846
+ return STREAM_MARKER.startsWith(t) ? "possible" : "impossible";
10847
+ }
10848
+ if (!t.startsWith(STREAM_MARKER)) return "impossible";
10849
+ const parsed = parseStreamMarkerName(t);
10850
+ if (parsed === "building") return "possible";
10851
+ if (parsed === "invalid") return "impossible";
10852
+ const nameOk = parsed.complete ? allowedToolNames.has(parsed.name) : someToolNameStartsWith(allowedToolNames, parsed.name);
10853
+ if (!nameOk) return "impossible";
10854
+ return held.length > cap ? "impossible" : "possible";
10855
+ }
10856
+ function parseStreamMarkerName(t) {
10857
+ let cursor = STREAM_MARKER.length;
10858
+ while (cursor < t.length && isStreamWs(t[cursor])) cursor += 1;
10859
+ const nameStart = cursor;
10860
+ while (cursor < t.length && t[cursor] !== ">" && !isStreamWs(t[cursor])) cursor += 1;
10861
+ const name = t.slice(nameStart, cursor);
10862
+ if (name.length === 0) return cursor >= t.length ? "building" : "invalid";
10863
+ return { name, complete: cursor < t.length && t[cursor] === ">" };
10864
+ }
10865
+ function someToolNameStartsWith(allowedToolNames, prefix) {
10866
+ for (const name of allowedToolNames) {
10867
+ if (name.startsWith(prefix)) return true;
10868
+ }
10869
+ return false;
10870
+ }
10871
+ function firstPossibleMarkerStart(held, allowedToolNames) {
10872
+ for (let i = held.indexOf("<"); i !== -1; i = held.indexOf("<", i + 1)) {
10873
+ if (streamToolCallBufferState(held.slice(i), allowedToolNames) === "possible") return i;
10874
+ }
10875
+ return -1;
10876
+ }
10877
+ var HERMES_BLOCK, HERMES_PARAM, STREAM_MARKER, DEFAULT_STREAM_BUFFER_CAP, isStreamWs, StreamSuppressionBuffer;
10366
10878
  var init_hermes_tool_extract = __esm({
10367
10879
  "src/internal/llm/hermes-tool-extract.ts"() {
10368
10880
  init_sanitize_tool_input();
10369
10881
  HERMES_BLOCK = /<function=\s*([^>\s]+)\s*>([\s\S]*?)<\/tool_call>/g;
10370
10882
  HERMES_PARAM = /<parameter=\s*([^>\s]+)\s*>([\s\S]*?)<\/parameter>/g;
10883
+ STREAM_MARKER = "<function=";
10884
+ DEFAULT_STREAM_BUFFER_CAP = 8192;
10885
+ isStreamWs = (c) => c === " " || c === " " || c === "\n" || c === "\r";
10886
+ StreamSuppressionBuffer = class {
10887
+ constructor(allowedToolNames) {
10888
+ this.allowedToolNames = allowedToolNames;
10889
+ }
10890
+ allowedToolNames;
10891
+ #held = "";
10892
+ /** Feed a content delta; returns the text to emit as a `text_delta` now, or `undefined` to hold. */
10893
+ push(content) {
10894
+ this.#held += content;
10895
+ if (streamToolCallBufferState(this.#held, this.allowedToolNames) === "possible")
10896
+ return void 0;
10897
+ const holdStart = firstPossibleMarkerStart(this.#held, this.allowedToolNames);
10898
+ if (holdStart > 0) {
10899
+ const flush2 = this.#held.slice(0, holdStart);
10900
+ this.#held = this.#held.slice(holdStart);
10901
+ return flush2;
10902
+ }
10903
+ const flush = this.#held;
10904
+ this.#held = "";
10905
+ return flush;
10906
+ }
10907
+ /** Drain the held buffer at stream end. `hasNativeCalls` mirrors `finish()`'s size-guard: when
10908
+ * native `tool_calls` exist, `finish()` won't strip the leaked block, so stream the held text WHOLE
10909
+ * (keeping `accumulatedText == finish.text`); otherwise strip the recoverable blocks. Idempotent. */
10910
+ drain(hasNativeCalls) {
10911
+ if (this.#held.length === 0) return void 0;
10912
+ const held = this.#held;
10913
+ this.#held = "";
10914
+ if (hasNativeCalls) return held;
10915
+ const residual = extractHermesToolCalls(held, () => "held", this.allowedToolNames).residualText;
10916
+ return residual.length > 0 ? residual : void 0;
10917
+ }
10918
+ };
10371
10919
  }
10372
10920
  });
10373
10921
 
10374
10922
  // src/internal/llm/openai.ts
10375
- function mapOpenAIFinish(reason) {
10376
- switch (reason) {
10377
- case "tool_calls":
10378
- return "tool_use";
10379
- case "length":
10380
- return "max_tokens";
10381
- case "stop":
10382
- return "end_turn";
10383
- default:
10384
- return "end_turn";
10385
- }
10386
- }
10387
10923
  function applyReasoningRequest(body, effort, providerName) {
10388
10924
  if (providerName === "openai") {
10389
10925
  body.reasoning_effort = effort;
@@ -10480,6 +11016,7 @@ function assistantMessage(message) {
10480
11016
  var OpenAIClient, OpenAIStreamAccumulator, openAISystemText;
10481
11017
  var init_openai2 = __esm({
10482
11018
  "src/internal/llm/openai.ts"() {
11019
+ init_errors();
10483
11020
  init_ollama2();
10484
11021
  init_openai_compatible();
10485
11022
  init_finish();
@@ -10560,8 +11097,12 @@ var init_openai2 = __esm({
10560
11097
  // model was actually given. Empty set (no tools) recovers nothing.
10561
11098
  new Set(request.tools?.map((tool) => tool.name) ?? [])
10562
11099
  );
11100
+ let sawDone = false;
10563
11101
  for await (const record of parseSseStream(response.body, signal)) {
10564
- if (record.data === "[DONE]") break;
11102
+ if (record.data === "[DONE]") {
11103
+ sawDone = true;
11104
+ break;
11105
+ }
10565
11106
  let chunk;
10566
11107
  try {
10567
11108
  chunk = JSON.parse(record.data);
@@ -10581,6 +11122,13 @@ var init_openai2 = __esm({
10581
11122
  const events = accumulator.consume(chunk);
10582
11123
  for (const event of events) yield event;
10583
11124
  }
11125
+ if (!sawDone && !accumulator.finishReasonSeen) {
11126
+ throw new NetworkError("SSE stream truncated (no finish_reason / [DONE])", {
11127
+ code: "stream_truncated"
11128
+ });
11129
+ }
11130
+ const drainEvent = accumulator.finalizeHeldText();
11131
+ if (drainEvent !== void 0) yield drainEvent;
10584
11132
  return accumulator.finish();
10585
11133
  }
10586
11134
  };
@@ -10596,6 +11144,7 @@ var init_openai2 = __esm({
10596
11144
  this.extractFromContent = extractFromContent;
10597
11145
  this.providerName = providerName;
10598
11146
  this.allowedToolNames = allowedToolNames;
11147
+ this.suppress = extractFromContent && allowedToolNames !== void 0 && allowedToolNames.size > 0 ? new StreamSuppressionBuffer(allowedToolNames) : void 0;
10599
11148
  }
10600
11149
  extractFromContent;
10601
11150
  providerName;
@@ -10608,18 +11157,30 @@ var init_openai2 = __esm({
10608
11157
  cacheWriteTokens;
10609
11158
  reasoningTokens;
10610
11159
  toolCalls = /* @__PURE__ */ new Map();
11160
+ /** R7: present only when recovery is enabled AND the request declares tools — holds suspected
11161
+ * leaked-dialect content back from the `text_delta` stream. `undefined` ⇒ stream immediately. */
11162
+ suppress;
10611
11163
  consume(chunk) {
10612
11164
  const events = [];
10613
11165
  this.applyUsage(chunk.usage);
10614
11166
  for (const choice of chunk.choices ?? []) {
10615
- const reasoningEvent = this.applyReasoningDelta(
10616
- choice.delta?.reasoning ?? choice.delta?.reasoning_content
10617
- );
10618
- if (reasoningEvent !== void 0) events.push(reasoningEvent);
10619
- const textEvent = this.applyContentDelta(choice.delta?.content);
10620
- if (textEvent !== void 0) events.push(textEvent);
10621
- this.mergeToolCallDeltas(choice.delta?.tool_calls);
10622
- this.applyFinishReason(choice.finish_reason);
11167
+ events.push(...this.applyChoice(choice));
11168
+ }
11169
+ return events;
11170
+ }
11171
+ applyChoice(choice) {
11172
+ const events = [];
11173
+ const reasoningEvent = this.applyReasoningDelta(
11174
+ choice.delta?.reasoning ?? choice.delta?.reasoning_content
11175
+ );
11176
+ if (reasoningEvent !== void 0) events.push(reasoningEvent);
11177
+ const textEvent = this.applyContentDelta(choice.delta?.content);
11178
+ if (textEvent !== void 0) events.push(textEvent);
11179
+ this.mergeToolCallDeltas(choice.delta?.tool_calls);
11180
+ this.applyFinishReason(choice.finish_reason);
11181
+ if (choice.finish_reason !== void 0 && choice.finish_reason !== null) {
11182
+ const flushEvent = this.finalizeHeldText();
11183
+ if (flushEvent !== void 0) events.push(flushEvent);
10623
11184
  }
10624
11185
  return events;
10625
11186
  }
@@ -10644,7 +11205,17 @@ var init_openai2 = __esm({
10644
11205
  applyContentDelta(content) {
10645
11206
  if (typeof content !== "string" || content.length === 0) return void 0;
10646
11207
  this.text += content;
10647
- return { type: "text_delta", text: content };
11208
+ if (this.suppress === void 0) return { type: "text_delta", text: content };
11209
+ const emit = this.suppress.push(content);
11210
+ return emit !== void 0 ? { type: "text_delta", text: emit } : void 0;
11211
+ }
11212
+ /** R7 held-buffer finalizer, called at the `finish_reason` chunk (in `applyChoice`) AND after the
11213
+ * SSE loop in `stream()` — so a stream that omits a `finish_reason` terminal never silently drops
11214
+ * held text. `toolCalls.size > 0` (native calls present) makes `finish()` skip recovery, so the
11215
+ * buffer streams the held text whole. Idempotent once drained. */
11216
+ finalizeHeldText() {
11217
+ const emit = this.suppress?.drain(this.toolCalls.size > 0);
11218
+ return emit !== void 0 ? { type: "text_delta", text: emit } : void 0;
10648
11219
  }
10649
11220
  mergeToolCallDeltas(deltas) {
10650
11221
  for (const call of deltas ?? []) {
@@ -10655,8 +11226,15 @@ var init_openai2 = __esm({
10655
11226
  this.toolCalls.set(call.index, existing);
10656
11227
  }
10657
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
+ }
10658
11235
  applyFinishReason(reason) {
10659
11236
  if (reason === void 0 || reason === null) return;
11237
+ this.sawFinishReason = true;
10660
11238
  this.stopReason = mapOpenAIFinish(reason);
10661
11239
  }
10662
11240
  finish() {
@@ -10705,6 +11283,53 @@ var init_openai2 = __esm({
10705
11283
  }
10706
11284
  });
10707
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
+
10708
11333
  // src/internal/llm/pool-aware-client.ts
10709
11334
  function classifyAndDecide(error, hasRetried429) {
10710
11335
  if (error instanceof NetworkError) return "propagate";
@@ -10733,20 +11358,34 @@ var PoolAwareLlmClient;
10733
11358
  var init_pool_aware_client = __esm({
10734
11359
  "src/internal/llm/pool-aware-client.ts"() {
10735
11360
  init_errors();
11361
+ init_circuit_breaker();
11362
+ init_retry();
10736
11363
  init_stream_relay();
10737
11364
  PoolAwareLlmClient = class {
10738
- constructor(pool, buildClient2, waitForAvailableMs = 3e4) {
11365
+ constructor(pool, buildClient2, waitForAvailableMs = 3e4, resilience = {}) {
10739
11366
  this.pool = pool;
10740
11367
  this.buildClient = buildClient2;
10741
11368
  this.waitForAvailableMs = waitForAvailableMs;
10742
11369
  this.name = `pool-aware:${pool.provider}`;
11370
+ this.breaker = resilience.breaker ?? new CircuitBreaker();
11371
+ this.backoffBaseMs = resilience.backoffBaseMs;
11372
+ this.rng = resilience.rng;
10743
11373
  }
10744
11374
  pool;
10745
11375
  buildClient;
10746
11376
  waitForAvailableMs;
10747
11377
  name;
11378
+ /** M2 #60 — provider-level circuit breaker (consecutive-failure). */
11379
+ breaker;
11380
+ backoffBaseMs;
11381
+ rng;
10748
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.
10749
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
+ }
10750
11389
  let hasRetried429 = false;
10751
11390
  while (true) {
10752
11391
  if (signal.aborted) throw abortError2(signal);
@@ -10761,6 +11400,7 @@ var init_pool_aware_client = __esm({
10761
11400
  }
10762
11401
  }
10763
11402
  if (entry === null) {
11403
+ this.breaker.recordTimeout(this.pool.provider);
10764
11404
  throw new CredentialPoolExhaustedError(
10765
11405
  `All ${this.pool.provider} credentials exhausted; next retry available at ${this.nextRetryHint() ?? "unknown"}`,
10766
11406
  { provider: this.pool.provider, nextRetryAt: this.nextRetryHint() }
@@ -10770,10 +11410,19 @@ var init_pool_aware_client = __esm({
10770
11410
  const realClient = this.buildClient(entry.accessToken);
10771
11411
  const attempt = await tryFirstEvent(realClient, request, signal);
10772
11412
  if (attempt.kind === "ok") {
11413
+ this.breaker.recordSuccess(this.pool.provider);
10773
11414
  return yield* relayStream(attempt.generator, attempt.firstResult);
10774
11415
  }
10775
11416
  const decision = classifyAndDecide(attempt.error, hasRetried429);
10776
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
+ );
10777
11426
  hasRetried429 = true;
10778
11427
  continue;
10779
11428
  }
@@ -10793,6 +11442,7 @@ var init_pool_aware_client = __esm({
10793
11442
  hasRetried429 = false;
10794
11443
  continue;
10795
11444
  }
11445
+ this.breaker.recordTimeout(this.pool.provider);
10796
11446
  throw attempt.error;
10797
11447
  }
10798
11448
  }
@@ -11251,9 +11901,24 @@ var init_router = __esm({
11251
11901
  warnedProviders = /* @__PURE__ */ new Set();
11252
11902
  }
11253
11903
  });
11254
- function createMcpClient(name, config) {
11904
+ function createMcpClient(name, config, fetchImpl = fetch) {
11255
11905
  if (isStdio(config)) return new StdioMcpClient(name, config);
11256
- 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";
11257
11922
  }
11258
11923
  async function rpcInitialize(request) {
11259
11924
  await request("initialize", {
@@ -11283,11 +11948,16 @@ function resolveMcpCwd(configCwd) {
11283
11948
  if (isAbsolute(configCwd)) return configCwd;
11284
11949
  return safePathJoin(process.cwd(), configCwd);
11285
11950
  }
11286
- var BaseMcpClient, StdioMcpClient, HttpMcpClient;
11951
+ var DEFAULT_MCP_TIMEOUT_MS, MAX_STDIO_BUFFER_BYTES, RECONNECT_BASE_MS, MAX_RECONNECT_ATTEMPTS, BaseMcpClient, StdioMcpClient, HttpMcpClient;
11287
11952
  var init_client = __esm({
11288
11953
  "src/internal/mcp/client.ts"() {
11289
11954
  init_errors();
11955
+ init_env_policy();
11290
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;
11291
11961
  BaseMcpClient = class {
11292
11962
  initialize() {
11293
11963
  return rpcInitialize((method, params) => this.request(method, params));
@@ -11309,32 +11979,107 @@ var init_client = __esm({
11309
11979
  name;
11310
11980
  child;
11311
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.
11312
11984
  pending = /* @__PURE__ */ new Map();
11313
11985
  buffer = "";
11314
- 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() {
11315
12000
  const resolvedCwd = resolveMcpCwd(this.config.cwd);
11316
12001
  const child = spawn(this.config.command, this.config.args ?? [], {
11317
12002
  cwd: resolvedCwd,
11318
- 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 })
11319
12006
  });
11320
12007
  this.child = child;
11321
12008
  child.stdout.on("data", (chunk) => this.consume(chunk));
11322
12009
  child.stderr.on("data", () => void 0);
12010
+ child.stdin.on("error", () => void 0);
11323
12011
  child.on("error", () => {
11324
- for (const resolver of this.pending.values()) {
11325
- resolver({ error: { message: "MCP process crashed" } });
11326
- }
11327
- 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;
11328
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();
11329
12051
  await super.initialize();
12052
+ this.dropped = false;
12053
+ this.reconnectAttempts = 0;
11330
12054
  }
11331
12055
  async close() {
11332
- if (this.child === void 0) return;
11333
- this.child.kill("SIGTERM");
12056
+ this.rejectAllPending(new NetworkError(`MCP ${this.name} closed`, { code: "mcp_closed" }));
12057
+ const child = this.child;
11334
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();
11335
12069
  }
11336
12070
  consume(chunk) {
11337
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
+ }
11338
12083
  let newlineIndex = this.buffer.indexOf("\n");
11339
12084
  while (newlineIndex !== -1) {
11340
12085
  const line = this.buffer.slice(0, newlineIndex).trim();
@@ -11351,23 +12096,47 @@ var init_client = __esm({
11351
12096
  return;
11352
12097
  }
11353
12098
  if (typeof message.id !== "number") return;
11354
- const resolver = this.pending.get(message.id);
11355
- if (resolver === void 0) return;
12099
+ const entry = this.pending.get(message.id);
12100
+ if (entry === void 0) return;
11356
12101
  this.pending.delete(message.id);
11357
- resolver(message);
12102
+ clearTimeout(entry.timer);
12103
+ entry.resolve(message);
11358
12104
  }
11359
12105
  request(method, params) {
11360
- if (this.child === void 0) {
11361
- return Promise.reject(
11362
- new ConfigurationError(`MCP ${this.name} is not initialized`, { code: "mcp_not_init" })
11363
- );
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" });
11364
12120
  }
12121
+ return this.send(child, method, params);
12122
+ }
12123
+ send(child, method, params) {
11365
12124
  const id = this.nextId++;
11366
12125
  const payload = { jsonrpc: "2.0", id, method, params };
11367
- this.child.stdin.write(`${JSON.stringify(payload)}
12126
+ child.stdin.write(`${JSON.stringify(payload)}
11368
12127
  `);
11369
- return new Promise((resolve3) => {
11370
- 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 });
11371
12140
  });
11372
12141
  }
11373
12142
  };
@@ -11393,11 +12162,20 @@ var init_client = __esm({
11393
12162
  accept: "application/json",
11394
12163
  ...this.config.headers ?? {}
11395
12164
  };
11396
- const response = await this.fetchImpl(this.config.url, {
11397
- method: "POST",
11398
- headers,
11399
- body: JSON.stringify(payload)
11400
- });
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
+ }
11401
12179
  if (!response.ok) {
11402
12180
  throw new NetworkError(`MCP ${this.name} returned ${response.status}`, {
11403
12181
  code: "mcp_http_error"
@@ -11517,11 +12295,33 @@ function resolveRunProvider(options) {
11517
12295
  );
11518
12296
  }
11519
12297
  const parsedModel = parseModelId(options.model?.id);
11520
- const inferredProvider = parsedModel.provider !== void 0 && getProviderProfile(parsedModel.provider) !== void 0 ? parsedModel.provider : void 0;
11521
- const primary = options.agentOptions.providers?.routes?.[0]?.provider ?? inferredProvider ?? detectPrimaryProvider();
11522
- 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";
11523
12302
  return { primary, effectiveModelId };
11524
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
+ }
11525
12325
  function buildLoopInputs(options, runId, userText) {
11526
12326
  const maxIterations = options.sendOptions.maxIterations;
11527
12327
  if (maxIterations !== void 0 && (!Number.isInteger(maxIterations) || maxIterations < 1)) {
@@ -11532,7 +12332,11 @@ function buildLoopInputs(options, runId, userText) {
11532
12332
  }
11533
12333
  const { primary, effectiveModelId } = resolveRunProvider(options);
11534
12334
  const fallback = options.agentOptions.providers?.fallback;
11535
- const apiKeys = options.agentOptions.providers?.apiKeys;
12335
+ const apiKeys = mergeExplicitApiKey(
12336
+ options.agentOptions.providers?.apiKeys,
12337
+ primary,
12338
+ options.agentOptions.apiKey
12339
+ );
11536
12340
  const credentialPoolStrategy = options.agentOptions.providers?.credentialPoolStrategy;
11537
12341
  const extractToolCallsFromContent = options.agentOptions.providers?.routes?.[0]?.extractToolCallsFromContent;
11538
12342
  const chain = resolveProviderChain({
@@ -11576,6 +12380,10 @@ function buildLoopInputs(options, runId, userText) {
11576
12380
  // D318 — forward SendOptions.signal to the agent loop so streamLlmTurn
11577
12381
  // can attach it to the LLM `fetch({ signal })` call.
11578
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 } : {},
11579
12387
  // M1-2: per-send iteration ceiling (validated above). The loop reads
11580
12388
  // inputs.maxIterations (default 8 when unset).
11581
12389
  ...maxIterations !== void 0 ? { maxIterations } : {},
@@ -11642,6 +12450,8 @@ var init_real_local_run = __esm({
11642
12450
  "src/internal/runtime/local-agent/real-local-run.ts"() {
11643
12451
  init_errors();
11644
12452
  init_loop();
12453
+ init_api_key_validator();
12454
+ init_fixture_mode();
11645
12455
  init_fallback_client();
11646
12456
  init_model_identifier();
11647
12457
  init_router();
@@ -11918,7 +12728,12 @@ async function runActiveMemory(args) {
11918
12728
  hits: []
11919
12729
  });
11920
12730
  }
11921
- 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);
11922
12737
  if (cached2 !== void 0) return endRecallSpan(span, args, cached2);
11923
12738
  const query = buildQuery(args.userText, args.priorMessages, cfg.queryMode, cfg.recentUserTurns);
11924
12739
  if (query.trim().length === 0) {
@@ -12003,7 +12818,12 @@ function notifyBreaker(breaker, key, status) {
12003
12818
  else if (status === "ok" || status === "no-recall") breaker.recordSuccess(key);
12004
12819
  }
12005
12820
  async function finalize(args, queryMode, result) {
12006
- 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);
12007
12827
  if (args.persistTranscripts === true && args.cwd !== void 0) {
12008
12828
  await persistActiveMemoryTranscript(args.cwd, {
12009
12829
  runId: args.runId ?? `run-${Date.now()}`,
@@ -12708,53 +13528,6 @@ var init_catalog = __esm({
12708
13528
  }
12709
13529
  });
12710
13530
 
12711
- // src/internal/memory/circuit-breaker.ts
12712
- var DEFAULT_MAX_TIMEOUTS, DEFAULT_COOLDOWN_MS2, CircuitBreaker;
12713
- var init_circuit_breaker = __esm({
12714
- "src/internal/memory/circuit-breaker.ts"() {
12715
- DEFAULT_MAX_TIMEOUTS = 3;
12716
- DEFAULT_COOLDOWN_MS2 = 6e4;
12717
- CircuitBreaker = class {
12718
- constructor(opts = {}) {
12719
- this.opts = opts;
12720
- }
12721
- opts;
12722
- states = /* @__PURE__ */ new Map();
12723
- /** @returns true when the breaker is open and the call should be skipped. */
12724
- shouldSkip(key) {
12725
- const state2 = this.states.get(key);
12726
- if (state2 === void 0) return false;
12727
- if (state2.cooldownUntilMs === 0) return false;
12728
- if (this.now() < state2.cooldownUntilMs) return true;
12729
- state2.cooldownUntilMs = 0;
12730
- state2.consecutiveTimeouts = 0;
12731
- return false;
12732
- }
12733
- recordSuccess(key) {
12734
- const state2 = this.states.get(key);
12735
- if (state2 === void 0) return;
12736
- state2.consecutiveTimeouts = 0;
12737
- state2.cooldownUntilMs = 0;
12738
- }
12739
- recordTimeout(key) {
12740
- const state2 = this.states.get(key) ?? { consecutiveTimeouts: 0, cooldownUntilMs: 0 };
12741
- state2.consecutiveTimeouts += 1;
12742
- if (state2.consecutiveTimeouts >= (this.opts.maxTimeouts ?? DEFAULT_MAX_TIMEOUTS)) {
12743
- state2.cooldownUntilMs = this.now() + (this.opts.cooldownMs ?? DEFAULT_COOLDOWN_MS2);
12744
- }
12745
- this.states.set(key, state2);
12746
- }
12747
- /** @internal — tests inspect counter state. */
12748
- inspect(key) {
12749
- return this.states.get(key) ?? { consecutiveTimeouts: 0, cooldownUntilMs: 0 };
12750
- }
12751
- now() {
12752
- return this.opts.now?.() ?? Date.now();
12753
- }
12754
- };
12755
- }
12756
- });
12757
-
12758
13531
  // src/internal/persistence/fts5-sanitize.ts
12759
13532
  function sanitizeFts5Query(query) {
12760
13533
  if (query.length === 0) return query;
@@ -13920,9 +14693,9 @@ var init_local_agent_memory = __esm({
13920
14693
  init_active_memory();
13921
14694
  init_active_memory_cache();
13922
14695
  init_catalog();
13923
- init_circuit_breaker();
13924
14696
  init_index_manager();
13925
14697
  init_tools();
14698
+ init_circuit_breaker();
13926
14699
  LocalAgentMemory = class {
13927
14700
  constructor(options, workspaceCwd, agentId) {
13928
14701
  this.options = options;