reasonix 0.28.0 → 0.29.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.
package/dist/index.d.ts CHANGED
@@ -694,6 +694,8 @@ interface ToolDefinition<A = any, R = any> {
694
694
  readOnly?: boolean;
695
695
  /** Per-args check; takes precedence over `readOnly`. e.g. `run_command` + allowlisted argv. */
696
696
  readOnlyCheck?: (args: A) => boolean;
697
+ /** Safe to dispatch concurrently with other parallel-safe calls in the same turn. Default false — opt-in only. */
698
+ parallelSafe?: boolean;
697
699
  fn: (args: A, ctx?: ToolCallContext) => R | Promise<R>;
698
700
  }
699
701
  interface ToolRegistryOptions {
@@ -729,6 +731,8 @@ declare class ToolRegistry {
729
731
  get size(): number;
730
732
  /** True if a registered tool's schema was flattened for the model. */
731
733
  wasFlattened(name: string): boolean;
734
+ /** Unknown / unannotated tools default to false — third-party MCP tools must opt in. */
735
+ isParallelSafe(name: string): boolean;
732
736
  specs(): ToolSpec[];
733
737
  dispatch(name: string, argumentsRaw: string | Record<string, unknown>, opts?: {
734
738
  signal?: AbortSignal;
@@ -840,6 +844,7 @@ declare class CacheFirstLoop {
840
844
  private modelForCurrentCall;
841
845
  /** Returns true ONLY on the tipping call — caller surfaces a one-shot warning. */
842
846
  private noteToolFailureSignal;
847
+ private runOneToolCall;
843
848
  private buildMessages;
844
849
  abort(): void;
845
850
  /** Drop the last user message + everything after; caller re-sends. Persists to session file. */
@@ -1102,6 +1107,8 @@ declare function registerPlanTool(registry: ToolRegistry, opts?: PlanToolOptions
1102
1107
  /** Side-channel — subagents run inside a tool-dispatch frame, can't go through parent's `LoopEvent` stream. */
1103
1108
  interface SubagentEvent {
1104
1109
  kind: "start" | "progress" | "end" | "inner" | "phase";
1110
+ /** Stable per-spawn id; lets the UI key parallel runs apart instead of overwriting one shared row. */
1111
+ runId: string;
1105
1112
  task: string;
1106
1113
  skillName?: string;
1107
1114
  model?: string;
package/dist/index.js CHANGED
@@ -1046,6 +1046,10 @@ var ToolRegistry = class {
1046
1046
  wasFlattened(name) {
1047
1047
  return Boolean(this._tools.get(name)?.flatSchema);
1048
1048
  }
1049
+ /** Unknown / unannotated tools default to false — third-party MCP tools must opt in. */
1050
+ isParallelSafe(name) {
1051
+ return this._tools.get(name)?.parallelSafe === true;
1052
+ }
1049
1053
  specs() {
1050
1054
  return [...this._tools.values()].map((t) => ({
1051
1055
  type: "function",
@@ -2640,6 +2644,48 @@ var CacheFirstLoop = class {
2640
2644
  this._escalateThisTurn = true;
2641
2645
  return true;
2642
2646
  }
2647
+ async runOneToolCall(call, signal) {
2648
+ const name = call.function?.name ?? "";
2649
+ const args = call.function?.arguments ?? "{}";
2650
+ const parsedArgs = safeParseToolArgs(args);
2651
+ const preReport = await runHooks({
2652
+ hooks: this.hooks,
2653
+ payload: {
2654
+ event: "PreToolUse",
2655
+ cwd: this.hookCwd,
2656
+ toolName: name,
2657
+ toolArgs: parsedArgs
2658
+ }
2659
+ });
2660
+ const preWarnings = [...hookWarnings(preReport.outcomes, this._turn)];
2661
+ if (preReport.blocked) {
2662
+ const blocking = preReport.outcomes[preReport.outcomes.length - 1];
2663
+ const reason = (blocking?.stderr || blocking?.stdout || "blocked by PreToolUse hook").trim();
2664
+ return {
2665
+ preWarnings,
2666
+ postWarnings: [],
2667
+ result: `[hook block] ${blocking?.hook.command ?? "<unknown>"}
2668
+ ${reason}`
2669
+ };
2670
+ }
2671
+ const result = await this.tools.dispatch(name, args, {
2672
+ signal,
2673
+ maxResultTokens: DEFAULT_MAX_RESULT_TOKENS,
2674
+ confirmationGate: this.confirmationGate
2675
+ });
2676
+ const postReport = await runHooks({
2677
+ hooks: this.hooks,
2678
+ payload: {
2679
+ event: "PostToolUse",
2680
+ cwd: this.hookCwd,
2681
+ toolName: name,
2682
+ toolArgs: parsedArgs,
2683
+ toolResult: result
2684
+ }
2685
+ });
2686
+ const postWarnings = [...hookWarnings(postReport.outcomes, this._turn)];
2687
+ return { preWarnings, postWarnings, result };
2688
+ }
2643
2689
  buildMessages(pendingUser) {
2644
2690
  const healed = healLoadedMessages(this.log.toMessages(), DEFAULT_MAX_RESULT_CHARS);
2645
2691
  const msgs = [...this.prefix.toMessages(), ...healed.messages];
@@ -3131,71 +3177,69 @@ var CacheFirstLoop = class {
3131
3177
  yield* forceSummaryAfterIterLimit(this.summaryContext(), { reason: "context-guard" });
3132
3178
  return;
3133
3179
  }
3134
- for (const call of repairedCalls) {
3135
- const name = call.function?.name ?? "";
3136
- const args = call.function?.arguments ?? "{}";
3137
- yield {
3138
- turn: this._turn,
3139
- role: "tool_start",
3140
- content: "",
3141
- toolName: name,
3142
- toolArgs: args
3143
- };
3144
- const parsedArgs = safeParseToolArgs(args);
3145
- const preReport = await runHooks({
3146
- hooks: this.hooks,
3147
- payload: {
3148
- event: "PreToolUse",
3149
- cwd: this.hookCwd,
3150
- toolName: name,
3151
- toolArgs: parsedArgs
3180
+ const dispatchSerial = (process.env.REASONIX_TOOL_DISPATCH ?? "auto").toLowerCase() === "serial";
3181
+ const parallelMaxParsed = Number.parseInt(process.env.REASONIX_PARALLEL_MAX ?? "", 10);
3182
+ const parallelMax = Number.isFinite(parallelMaxParsed) && parallelMaxParsed >= 1 ? Math.min(parallelMaxParsed, 16) : 3;
3183
+ let callIdx = 0;
3184
+ while (callIdx < repairedCalls.length) {
3185
+ const chunk = [];
3186
+ if (!dispatchSerial) {
3187
+ while (callIdx < repairedCalls.length && chunk.length < parallelMax && this.tools.isParallelSafe(repairedCalls[callIdx]?.function?.name ?? "")) {
3188
+ chunk.push(repairedCalls[callIdx++]);
3152
3189
  }
3153
- });
3154
- for (const w of hookWarnings(preReport.outcomes, this._turn)) yield w;
3155
- let result;
3156
- if (preReport.blocked) {
3157
- const blocking = preReport.outcomes[preReport.outcomes.length - 1];
3158
- const reason = (blocking?.stderr || blocking?.stdout || "blocked by PreToolUse hook").trim();
3159
- result = `[hook block] ${blocking?.hook.command ?? "<unknown>"}
3160
- ${reason}`;
3161
- } else {
3162
- result = await this.tools.dispatch(name, args, {
3163
- signal,
3164
- maxResultTokens: DEFAULT_MAX_RESULT_TOKENS,
3165
- confirmationGate: this.confirmationGate
3166
- });
3167
- const postReport = await runHooks({
3168
- hooks: this.hooks,
3169
- payload: {
3170
- event: "PostToolUse",
3171
- cwd: this.hookCwd,
3172
- toolName: name,
3173
- toolArgs: parsedArgs,
3174
- toolResult: result
3175
- }
3176
- });
3177
- for (const w of hookWarnings(postReport.outcomes, this._turn)) yield w;
3178
3190
  }
3179
- this.appendAndPersist({
3180
- role: "tool",
3181
- tool_call_id: call.id ?? "",
3182
- name,
3183
- content: result
3184
- });
3185
- if (this.noteToolFailureSignal(result)) {
3191
+ if (chunk.length === 0) {
3192
+ chunk.push(repairedCalls[callIdx++]);
3193
+ }
3194
+ for (const call of chunk) {
3186
3195
  yield {
3187
3196
  turn: this._turn,
3188
- role: "warning",
3189
- content: `\u21E7 auto-escalating to ${ESCALATION_MODEL} for the rest of this turn \u2014 flash hit ${this._turnFailures.formatBreakdown()}. Next turn falls back to ${this.model} unless /pro is armed.`
3197
+ role: "tool_start",
3198
+ content: "",
3199
+ toolName: call.function?.name ?? "",
3200
+ toolArgs: call.function?.arguments ?? "{}"
3201
+ };
3202
+ }
3203
+ const settled = await Promise.allSettled(chunk.map((c) => this.runOneToolCall(c, signal)));
3204
+ for (let k = 0; k < chunk.length; k++) {
3205
+ const call = chunk[k];
3206
+ const name = call.function?.name ?? "";
3207
+ const args = call.function?.arguments ?? "{}";
3208
+ const s = settled[k];
3209
+ let result;
3210
+ let preWarnings = [];
3211
+ let postWarnings = [];
3212
+ if (s.status === "fulfilled") {
3213
+ preWarnings = s.value.preWarnings;
3214
+ postWarnings = s.value.postWarnings;
3215
+ result = s.value.result;
3216
+ } else {
3217
+ const err = s.reason instanceof Error ? s.reason : new Error(String(s.reason));
3218
+ result = JSON.stringify({ error: `${err.name}: ${err.message}` });
3219
+ }
3220
+ for (const w of preWarnings) yield w;
3221
+ for (const w of postWarnings) yield w;
3222
+ this.appendAndPersist({
3223
+ role: "tool",
3224
+ tool_call_id: call.id ?? "",
3225
+ name,
3226
+ content: result
3227
+ });
3228
+ if (this.noteToolFailureSignal(result)) {
3229
+ yield {
3230
+ turn: this._turn,
3231
+ role: "warning",
3232
+ content: `\u21E7 auto-escalating to ${ESCALATION_MODEL} for the rest of this turn \u2014 flash hit ${this._turnFailures.formatBreakdown()}. Next turn falls back to ${this.model} unless /pro is armed.`
3233
+ };
3234
+ }
3235
+ yield {
3236
+ turn: this._turn,
3237
+ role: "tool",
3238
+ content: result,
3239
+ toolName: name,
3240
+ toolArgs: args
3190
3241
  };
3191
3242
  }
3192
- yield {
3193
- turn: this._turn,
3194
- role: "tool",
3195
- content: result,
3196
- toolName: name,
3197
- toolArgs: args
3198
- };
3199
3243
  }
3200
3244
  }
3201
3245
  yield* forceSummaryAfterIterLimit(this.summaryContext(), { reason: "budget" });
@@ -4604,6 +4648,7 @@ function registerFilesystemTools(registry, opts) {
4604
4648
  };
4605
4649
  registry.register({
4606
4650
  name: "read_file",
4651
+ parallelSafe: true,
4607
4652
  description: `Read a file under the sandbox root. To save context, PREFER to scope the read instead of pulling the whole file:
4608
4653
  - head: N \u2192 first N lines (imports, public API, small configs)
4609
4654
  - tail: N \u2192 last N lines (recently-added code, log tails)
@@ -4687,6 +4732,7 @@ ${slice.join("\n")}`;
4687
4732
  });
4688
4733
  registry.register({
4689
4734
  name: "list_directory",
4735
+ parallelSafe: true,
4690
4736
  description: "List entries in a directory under the sandbox root. Returns one line per entry, marking directories with a trailing slash. Not recursive \u2014 use directory_tree for that.",
4691
4737
  readOnly: true,
4692
4738
  parameters: {
@@ -4707,6 +4753,7 @@ ${slice.join("\n")}`;
4707
4753
  });
4708
4754
  registry.register({
4709
4755
  name: "directory_tree",
4756
+ parallelSafe: true,
4710
4757
  description: `Recursively list entries in a directory. Shows indented tree structure with directories marked '/'. Budget-aware by default:
4711
4758
  - maxDepth defaults to 2 (root + one level). A depth-4 tree on a real repo blew ~5K tokens in one call. If you truly need deeper, pass maxDepth:N explicitly.
4712
4759
  - Skips ${[...SKIP_DIR_NAMES].sort().join(", ")} unless include_deps:true. Traversing into node_modules / .git / dist is almost always token-waste.
@@ -4785,6 +4832,7 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
4785
4832
  });
4786
4833
  registry.register({
4787
4834
  name: "search_files",
4835
+ parallelSafe: true,
4788
4836
  description: "Find files whose NAME matches a substring or regex. Case-insensitive. Walks the directory recursively under the sandbox root. Returns one path per line. Skips dependency / VCS / build directories (node_modules, .git, dist, build, .next, target, .venv) by default.",
4789
4837
  readOnly: true,
4790
4838
  parameters: {
@@ -4810,6 +4858,7 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
4810
4858
  });
4811
4859
  registry.register({
4812
4860
  name: "search_content",
4861
+ parallelSafe: true,
4813
4862
  description: "Recursively grep file CONTENTS for a substring or regex. This is the right tool for 'find all places that call X', 'where is Y referenced', 'what files contain Z'. Different from search_files (which matches FILE NAMES). Returns one match per line in 'path:line: text' format. Skips dependency / VCS / build directories (node_modules, .git, dist, build, .next, target, .venv) and binary files by default.",
4814
4863
  readOnly: true,
4815
4864
  parameters: {
@@ -4852,6 +4901,7 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
4852
4901
  });
4853
4902
  registry.register({
4854
4903
  name: "get_file_info",
4904
+ parallelSafe: true,
4855
4905
  description: "Stat a path under the sandbox root. Returns type (file|directory|symlink), size in bytes, mtime in ISO-8601.",
4856
4906
  readOnly: true,
4857
4907
  parameters: {
@@ -5032,6 +5082,7 @@ function registerMemoryTools(registry, opts = {}) {
5032
5082
  name: "recall_memory",
5033
5083
  description: "Read the full body of a memory file when its MEMORY.md one-liner (already in the system prompt) isn't enough detail. Most of the time the index suffices \u2014 only call this when the user's question genuinely requires the full context.",
5034
5084
  readOnly: true,
5085
+ parallelSafe: true,
5035
5086
  parameters: {
5036
5087
  type: "object",
5037
5088
  properties: {
@@ -5458,6 +5509,11 @@ function getSubagentType(name) {
5458
5509
  }
5459
5510
 
5460
5511
  // src/tools/subagent.ts
5512
+ var runIdCounter = 0;
5513
+ function nextRunId() {
5514
+ runIdCounter++;
5515
+ return `sub-${runIdCounter.toString(36)}`;
5516
+ }
5461
5517
  var DEFAULT_SUBAGENT_SYSTEM = `You are a Reasonix subagent. The parent agent spawned you to handle one focused subtask, then return.
5462
5518
 
5463
5519
  Rules:
@@ -5486,9 +5542,11 @@ async function spawnSubagent(opts) {
5486
5542
  const sink = opts.sink;
5487
5543
  const skillName = opts.skillName;
5488
5544
  const startedAt = Date.now();
5545
+ const runId = nextRunId();
5489
5546
  const taskPreview = opts.task.length > 30 ? `${opts.task.slice(0, 30)}\u2026` : opts.task;
5490
5547
  sink?.current?.({
5491
5548
  kind: "start",
5549
+ runId,
5492
5550
  task: taskPreview,
5493
5551
  skillName,
5494
5552
  model,
@@ -5501,6 +5559,7 @@ async function spawnSubagent(opts) {
5501
5559
  const errorMessage2 = `subagent allow-list names tool(s) not registered in the parent: ${missing.join(", ")}. Fix the skill's \`allowed-tools\` frontmatter or check spelling.`;
5502
5560
  sink?.current?.({
5503
5561
  kind: "end",
5562
+ runId,
5504
5563
  task: taskPreview,
5505
5564
  skillName,
5506
5565
  model,
@@ -5562,12 +5621,13 @@ async function spawnSubagent(opts) {
5562
5621
  let summarisingEmitted = false;
5563
5622
  try {
5564
5623
  for await (const ev of childLoop.step(opts.task)) {
5565
- sink?.current?.({ kind: "inner", task: taskPreview, skillName, model, inner: ev });
5624
+ sink?.current?.({ kind: "inner", runId, task: taskPreview, skillName, model, inner: ev });
5566
5625
  if (ev.role === "tool") {
5567
5626
  toolIter++;
5568
5627
  summarisingEmitted = false;
5569
5628
  sink?.current?.({
5570
5629
  kind: "progress",
5630
+ runId,
5571
5631
  task: taskPreview,
5572
5632
  skillName,
5573
5633
  model,
@@ -5579,6 +5639,7 @@ async function spawnSubagent(opts) {
5579
5639
  summarisingEmitted = true;
5580
5640
  sink?.current?.({
5581
5641
  kind: "phase",
5642
+ runId,
5582
5643
  task: taskPreview,
5583
5644
  skillName,
5584
5645
  model,
@@ -5615,6 +5676,7 @@ async function spawnSubagent(opts) {
5615
5676
  [\u2026truncated ${final.length - maxResultChars} chars; ask the subagent for a tighter summary if you need more.]` : final;
5616
5677
  sink?.current?.({
5617
5678
  kind: "end",
5679
+ runId,
5618
5680
  task: taskPreview,
5619
5681
  skillName,
5620
5682
  model,
@@ -5678,6 +5740,7 @@ function registerSubagentTool(parentRegistry, opts) {
5678
5740
  const sink = opts.sink;
5679
5741
  parentRegistry.register({
5680
5742
  name: SUBAGENT_TOOL_NAME,
5743
+ parallelSafe: true,
5681
5744
  description: "Spawn an isolated subagent to handle a self-contained subtask in a fresh context, returning only its final answer. Use for: deep codebase exploration that would flood the main context, multi-step research where you only need the conclusion, or any focused subtask whose intermediate reasoning the user does not need to see. The subagent inherits all your tools (filesystem, shell, web, MCP, etc.) but runs in its own isolated message log \u2014 its tool calls and reasoning never enter your context. Only the final assistant message comes back as this tool's result. Keep tasks focused; the subagent has a stricter iter budget than you do.",
5682
5745
  parameters: {
5683
5746
  type: "object",
@@ -7103,6 +7166,7 @@ function registerShellTools(registry, opts) {
7103
7166
  name: "job_output",
7104
7167
  description: "Read the latest output of a background job started with `run_background`. By default returns the tail of the buffer (last 80 lines). Pass `since` (the `byteLength` from a previous call) to stream only new content incrementally. Tells you whether the job is still running, so you can stop polling when it's done.",
7105
7168
  readOnly: true,
7169
+ parallelSafe: true,
7106
7170
  parameters: {
7107
7171
  type: "object",
7108
7172
  properties: {
@@ -7147,6 +7211,7 @@ function registerShellTools(registry, opts) {
7147
7211
  name: "list_jobs",
7148
7212
  description: "List every background job started this session \u2014 running and exited \u2014 with id, command, pid, status. Use when you've lost track of which job_id corresponds to which process, or to see what's still alive.",
7149
7213
  readOnly: true,
7214
+ parallelSafe: true,
7150
7215
  parameters: { type: "object", properties: {} },
7151
7216
  fn: async () => {
7152
7217
  const all = jobs.list();
@@ -7404,6 +7469,7 @@ function registerWebTools(registry, opts = {}) {
7404
7469
  name: "web_search",
7405
7470
  description: "Search the public web. Returns ranked results with title, url, and snippet. Call this when the answer's correctness depends on current state \u2014 anything that changes over time (events, prices, releases, status of a thing in the real world). Composing such answers from training memory invents stale numbers; search first, then ground the answer in the results. For evergreen / definitional questions you don't need this.",
7406
7471
  readOnly: true,
7472
+ parallelSafe: true,
7407
7473
  parameters: {
7408
7474
  type: "object",
7409
7475
  properties: {
@@ -7427,6 +7493,7 @@ function registerWebTools(registry, opts = {}) {
7427
7493
  name: "web_fetch",
7428
7494
  description: "Download a URL and return its visible text content (HTML pages get scripts/styles/nav stripped). Truncated at the tool-result cap. Use after web_search when a snippet isn't enough.",
7429
7495
  readOnly: true,
7496
+ parallelSafe: true,
7430
7497
  parameters: {
7431
7498
  type: "object",
7432
7499
  properties: {