@diffexai/diffex 0.3.1 → 0.3.2

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 (76) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/core/agent-session.d.ts +0 -5
  3. package/dist/core/agent-session.js +6 -61
  4. package/dist/core/compaction/branch-summarization.js +3 -2
  5. package/dist/core/compaction/compaction.js +9 -7
  6. package/dist/core/compaction/utils.d.ts +3 -0
  7. package/dist/core/compaction/utils.js +21 -0
  8. package/dist/core/current-plan-state.d.ts +3 -2
  9. package/dist/core/current-plan-state.js +23 -6
  10. package/dist/core/plan-snapshot.d.ts +3 -0
  11. package/dist/core/plan-snapshot.js +23 -0
  12. package/dist/core/session-manager.js +21 -1
  13. package/dist/core/tools/update-plan.d.ts +27 -6
  14. package/dist/core/tools/update-plan.js +122 -18
  15. package/dist/modes/interactive/components/footer.js +16 -5
  16. package/dist/modes/interactive/components/tool-group.d.ts +1 -0
  17. package/dist/modes/interactive/components/tool-group.js +26 -7
  18. package/dist/node_modules/@diffexai/diffex-agent-core/dist/agent-loop.js +21 -3
  19. package/dist/node_modules/@diffexai/diffex-agent-core/dist/types.d.ts +17 -10
  20. package/dist/node_modules/@diffexai/diffex-agent-core/distribution-components.json +4 -4
  21. package/dist/node_modules/@diffexai/diffex-agent-core/distribution-files.json +3 -3
  22. package/dist/node_modules/@diffexai/diffex-agent-core/package.json +1 -1
  23. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/.manifest.json +1 -1
  24. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/amazon-bedrock.json +1 -1
  25. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/anthropic.json +1 -1
  26. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/azure-openai-responses.json +1 -1
  27. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/cerebras.json +1 -1
  28. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/cloudflare-ai-gateway.json +1 -1
  29. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/cloudflare-workers-ai.json +1 -1
  30. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/fireworks.json +1 -1
  31. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/github-copilot.json +1 -1
  32. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/google-vertex.json +1 -1
  33. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/google.json +1 -1
  34. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/huggingface.json +1 -1
  35. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/minimax-cn.json +1 -1
  36. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/minimax.json +1 -1
  37. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/mistral.json +1 -1
  38. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/moonshotai-cn.json +1 -1
  39. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/moonshotai.json +1 -1
  40. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/nvidia.json +1 -1
  41. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/openai.json +1 -1
  42. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/opencode-go.json +1 -1
  43. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/opencode.json +1 -1
  44. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/openrouter.json +1 -1
  45. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/together.json +1 -1
  46. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/vercel-ai-gateway.json +1 -1
  47. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/xai.json +1 -1
  48. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/xiaomi-token-plan-ams.json +1 -1
  49. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/xiaomi-token-plan-cn.json +1 -1
  50. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/xiaomi-token-plan-sgp.json +1 -1
  51. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/xiaomi.json +1 -1
  52. package/dist/node_modules/@diffexai/diffex-ai/distribution-components.json +3 -3
  53. package/dist/node_modules/@diffexai/diffex-ai/distribution-files.json +30 -30
  54. package/dist/node_modules/@diffexai/diffex-ai/package.json +1 -1
  55. package/dist/node_modules/@diffexai/diffex-client/distribution-components.json +3 -3
  56. package/dist/node_modules/@diffexai/diffex-client/distribution-files.json +1 -1
  57. package/dist/node_modules/@diffexai/diffex-client/package.json +1 -1
  58. package/dist/node_modules/@diffexai/diffex-harness-state/distribution-components.json +2 -2
  59. package/dist/node_modules/@diffexai/diffex-harness-state/distribution-files.json +1 -1
  60. package/dist/node_modules/@diffexai/diffex-harness-state/package.json +1 -1
  61. package/dist/node_modules/@diffexai/diffex-protocol/distribution-components.json +2 -2
  62. package/dist/node_modules/@diffexai/diffex-protocol/distribution-files.json +1 -1
  63. package/dist/node_modules/@diffexai/diffex-protocol/package.json +1 -1
  64. package/dist/node_modules/@diffexai/diffex-telemetry/distribution-components.json +2 -2
  65. package/dist/node_modules/@diffexai/diffex-telemetry/distribution-files.json +1 -1
  66. package/dist/node_modules/@diffexai/diffex-telemetry/package.json +1 -1
  67. package/dist/node_modules/@diffexai/diffex-tui/distribution-components.json +2 -2
  68. package/dist/node_modules/@diffexai/diffex-tui/distribution-files.json +1 -1
  69. package/dist/node_modules/@diffexai/diffex-tui/package.json +1 -1
  70. package/distribution-components.json +11 -11
  71. package/distribution-files.json +75 -67
  72. package/npm-shrinkwrap.json +2 -2
  73. package/package.json +1 -1
  74. package/release/distribution-manifest.json +4 -4
  75. package/release/install-package-lock.json +5 -5
  76. package/release/install-package.json +2 -2
@@ -9,26 +9,34 @@ const updatePlanStatusSchema = Type.Unsafe({
9
9
  description: "Status of this plan item.",
10
10
  });
11
11
  const updatePlanItemSchema = Type.Object({
12
+ id: Type.String({ description: "Stable step ID; retain it across edits and reordering." }),
12
13
  step: Type.String({ description: "Task step text." }),
13
14
  status: updatePlanStatusSchema,
15
+ success_criteria: Type.Optional(Type.Array(Type.String(), { description: "Observable completion criteria." })),
14
16
  }, { additionalProperties: false });
15
17
  const updatePlanSchema = Type.Object({
16
18
  plan: Type.Optional(Type.Array(updatePlanItemSchema, {
17
19
  minItems: 1,
18
- description: "Full ordered plan snapshot. Provide exactly one operation: plan or clear.",
20
+ description: "Full ordered plan snapshot. Keep existing IDs for retained steps.",
19
21
  })),
22
+ update: Type.Optional(Type.Object({
23
+ id: Type.String({ description: "ID of an existing step." }),
24
+ step: Type.Optional(Type.String()),
25
+ status: Type.Optional(updatePlanStatusSchema),
26
+ success_criteria: Type.Optional(Type.Array(Type.String())),
27
+ }, { additionalProperties: false })),
20
28
  clear: Type.Optional(Type.Literal(true, {
21
- description: "Explicitly abandon and clear the current plan. Provide exactly one operation: plan or clear.",
29
+ description: "Explicitly abandon and clear the current plan.",
22
30
  })),
23
31
  explanation: Type.Optional(Type.String({ description: "Reason for changing or clearing the plan." })),
24
32
  }, { additionalProperties: false });
25
33
  export const updatePlanToolSystemPromptContribution = {
26
- snippet: "track progress with an ordered task checklist",
34
+ snippet: "persist an ordered plan with step IDs and optional success criteria",
27
35
  guidelines: [
28
36
  "Use update_plan for complex, ambiguous, dependent, long-running, or multi-part work, when the user requests a plan, or when checkpoints would improve collaboration; skip it for simple or immediately executable work.",
29
- "Write short, ordered, outcome-focused steps with observable evidence where useful; do not narrate commands or pad the checklist with obvious actions.",
37
+ "Write short, ordered, outcome-focused steps. Give each step a stable ID and add optional success_criteria for subtasks needing validation.",
30
38
  "Include final verification when correctness requires it.",
31
- "Update the checklist at meaningful milestones and mark completed work before moving on.",
39
+ "Update by ID at meaningful milestones; retain IDs when rewording, reordering, or changing status.",
32
40
  "Explain material replanning, omit explanations for routine progress, and use clear: true only when abandoning the checklist.",
33
41
  "Publishing the checklist communicates progress but does not execute, schedule, or prove completion of its steps.",
34
42
  ],
@@ -39,28 +47,52 @@ function isRecord(value) {
39
47
  function isUpdatePlanStatus(value) {
40
48
  return typeof value === "string" && UPDATE_PLAN_STATUSES.includes(value);
41
49
  }
50
+ function validateCriteria(value, label) {
51
+ if (!Array.isArray(value) ||
52
+ value.some((criterion) => typeof criterion !== "string" || criterion.trim().length === 0)) {
53
+ throw new Error(`${label} must be an array of non-empty strings.`);
54
+ }
55
+ return [...value];
56
+ }
42
57
  function validatePlanSnapshot(rawPlan) {
43
58
  if (!Array.isArray(rawPlan) || rawPlan.length === 0) {
44
59
  throw new Error("Plan must contain at least one item.");
45
60
  }
46
61
  const plan = [];
62
+ const ids = new Set();
47
63
  for (let index = 0; index < rawPlan.length; index++) {
48
64
  const rawItem = rawPlan[index];
49
65
  if (!isRecord(rawItem)) {
50
66
  throw new Error(`Plan item ${index + 1} must be an object.`);
51
67
  }
52
- const unexpectedField = Object.keys(rawItem).find((field) => field !== "step" && field !== "status");
68
+ const unexpectedField = Object.keys(rawItem).find((field) => field !== "id" && field !== "step" && field !== "status" && field !== "success_criteria");
53
69
  if (unexpectedField !== undefined) {
54
70
  throw new Error(`Plan item ${index + 1} contains unexpected field: ${unexpectedField}.`);
55
71
  }
56
- const { step, status } = rawItem;
72
+ const { id, step, status } = rawItem;
73
+ if (typeof id !== "string" || id.trim().length === 0) {
74
+ throw new Error(`Plan item ${index + 1} id must be a non-empty string.`);
75
+ }
76
+ if (ids.has(id)) {
77
+ throw new Error(`Duplicate plan item id: ${id}.`);
78
+ }
79
+ ids.add(id);
57
80
  if (typeof step !== "string" || step.trim().length === 0) {
58
81
  throw new Error(`Plan item ${index + 1} step must be a non-empty string.`);
59
82
  }
60
83
  if (!isUpdatePlanStatus(status)) {
61
84
  throw new Error(`Plan item ${index + 1} status must be pending, in_progress, or completed.`);
62
85
  }
63
- plan.push({ step, status });
86
+ plan.push({
87
+ id,
88
+ step,
89
+ status,
90
+ ...(Object.hasOwn(rawItem, "success_criteria")
91
+ ? {
92
+ success_criteria: validateCriteria(rawItem.success_criteria, `Plan item ${index + 1} success_criteria`),
93
+ }
94
+ : {}),
95
+ });
64
96
  }
65
97
  const counts = countPlanStatuses(plan);
66
98
  if (counts.completed !== plan.length && counts.inProgress !== 1) {
@@ -72,14 +104,15 @@ export function parseUpdatePlanOperation(input) {
72
104
  if (!isRecord(input)) {
73
105
  throw new Error("update_plan input must be an object.");
74
106
  }
75
- const unexpectedField = Object.keys(input).find((field) => field !== "plan" && field !== "clear" && field !== "explanation");
107
+ const unexpectedField = Object.keys(input).find((field) => field !== "plan" && field !== "update" && field !== "clear" && field !== "explanation");
76
108
  if (unexpectedField !== undefined) {
77
109
  throw new Error(`Unexpected update_plan field: ${unexpectedField}.`);
78
110
  }
79
111
  const hasPlan = Object.hasOwn(input, "plan");
112
+ const hasUpdate = Object.hasOwn(input, "update");
80
113
  const hasClear = Object.hasOwn(input, "clear");
81
- if (hasPlan === hasClear) {
82
- throw new Error("Provide exactly one update_plan operation: plan or clear.");
114
+ if (Number(hasPlan) + Number(hasUpdate) + Number(hasClear) !== 1) {
115
+ throw new Error("Provide exactly one update_plan operation: plan, update, or clear.");
83
116
  }
84
117
  const hasExplanation = Object.hasOwn(input, "explanation");
85
118
  if (hasExplanation && typeof input.explanation !== "string") {
@@ -92,9 +125,64 @@ export function parseUpdatePlanOperation(input) {
92
125
  }
93
126
  return hasExplanation ? { clear: true, explanation } : { clear: true };
94
127
  }
128
+ if (hasUpdate) {
129
+ if (!isRecord(input.update))
130
+ throw new Error("Update must be an object.");
131
+ const unexpectedUpdateField = Object.keys(input.update).find((field) => field !== "id" && field !== "step" && field !== "status" && field !== "success_criteria");
132
+ if (unexpectedUpdateField !== undefined)
133
+ throw new Error(`Unexpected update field: ${unexpectedUpdateField}.`);
134
+ const { id, step, status } = input.update;
135
+ if (typeof id !== "string" || id.trim().length === 0)
136
+ throw new Error("Update id must be a non-empty string.");
137
+ if (step !== undefined && (typeof step !== "string" || step.trim().length === 0)) {
138
+ throw new Error("Update step must be a non-empty string.");
139
+ }
140
+ if (status !== undefined && !isUpdatePlanStatus(status)) {
141
+ throw new Error("Update status must be pending, in_progress, or completed.");
142
+ }
143
+ const hasCriteria = Object.hasOwn(input.update, "success_criteria");
144
+ if (step === undefined && status === undefined && !hasCriteria)
145
+ throw new Error("Update must change a step field.");
146
+ const update = {
147
+ id,
148
+ ...(step === undefined ? {} : { step }),
149
+ ...(status === undefined ? {} : { status }),
150
+ ...(hasCriteria
151
+ ? { success_criteria: validateCriteria(input.update.success_criteria, "Update success_criteria") }
152
+ : {}),
153
+ };
154
+ return hasExplanation ? { update, explanation } : { update };
155
+ }
95
156
  const plan = validatePlanSnapshot(input.plan);
96
157
  return hasExplanation ? { plan, explanation } : { plan };
97
158
  }
159
+ export function resolveUpdatePlanOperation(input, currentPlan) {
160
+ const operation = parseUpdatePlanOperation(input);
161
+ if (!("update" in operation))
162
+ return operation;
163
+ if (!currentPlan)
164
+ throw new Error("Cannot update a step without a current plan.");
165
+ const index = currentPlan.findIndex((item) => item.id === operation.update.id);
166
+ if (index < 0)
167
+ throw new Error(`Unknown plan step id: ${operation.update.id}.`);
168
+ const plan = currentPlan.map((item, itemIndex) => {
169
+ if (itemIndex !== index)
170
+ return item;
171
+ const { success_criteria, ...changes } = operation.update;
172
+ const updated = { ...item, ...changes };
173
+ if (success_criteria !== undefined) {
174
+ if (success_criteria.length === 0)
175
+ delete updated.success_criteria;
176
+ else
177
+ updated.success_criteria = success_criteria;
178
+ }
179
+ return updated;
180
+ });
181
+ return {
182
+ plan: validatePlanSnapshot(plan),
183
+ ...(operation.explanation === undefined ? {} : { explanation: operation.explanation }),
184
+ };
185
+ }
98
186
  function countPlanStatuses(plan) {
99
187
  let completed = 0;
100
188
  let inProgress = 0;
@@ -151,27 +239,43 @@ function formatPlanCall(args, theme, showInvalidArgs) {
151
239
  if ("clear" in details) {
152
240
  return `${text}\n${theme.fg("toolOutput", "Plan cleared.")}`;
153
241
  }
242
+ if ("update" in details) {
243
+ text += `\n${theme.fg("toolOutput", `Update ${details.update.id}`)}`;
244
+ if (details.update.status)
245
+ text += ` ${formatPlanMarker(details.update.status, theme)}`;
246
+ if (details.update.step)
247
+ text += ` ${formatPlanStep(details.update.step, theme)}`;
248
+ if (details.update.success_criteria) {
249
+ for (const criterion of details.update.success_criteria)
250
+ text += `\n ${theme.fg("toolOutput", `- ${criterion}`)}`;
251
+ }
252
+ return text;
253
+ }
154
254
  for (const item of details.plan) {
155
- text += `\n${formatPlanMarker(item.status, theme)} ${formatPlanStep(item.step, theme)}`;
255
+ text += `\n${formatPlanMarker(item.status, theme)} ${theme.fg("toolOutput", item.id)} ${formatPlanStep(item.step, theme)}`;
256
+ for (const criterion of item.success_criteria ?? [])
257
+ text += `\n ${theme.fg("toolOutput", `- ${criterion}`)}`;
156
258
  }
157
259
  return text;
158
260
  }
159
- export function createUpdatePlanToolDefinition() {
261
+ export function createUpdatePlanToolDefinition(_cwd, getCurrentPlan) {
160
262
  return {
161
263
  name: "update_plan",
162
264
  label: "update_plan",
163
- description: "Replace or explicitly clear the full ordered task checklist. Unfinished plans require exactly one in_progress item.",
265
+ description: "Write or clear a plan, or update one step by ID. Use optional success_criteria for validation.",
164
266
  promptSnippet: updatePlanToolSystemPromptContribution.snippet,
165
267
  promptGuidelines: [...updatePlanToolSystemPromptContribution.guidelines],
166
268
  parameters: updatePlanSchema,
167
269
  executionMode: "sequential",
168
270
  async execute(_toolCallId, input) {
169
- const details = parseUpdatePlanOperation(input);
271
+ const details = resolveUpdatePlanOperation(input, getCurrentPlan?.());
170
272
  return {
171
273
  content: [
172
274
  {
173
275
  type: "text",
174
- text: "clear" in details ? "Plan cleared." : formatPlanSummary(details.plan),
276
+ text: "clear" in details
277
+ ? "Plan cleared."
278
+ : `${formatPlanSummary(details.plan)}\n${JSON.stringify({ plan: details.plan }, null, 2)}`,
175
279
  },
176
280
  ],
177
281
  details,
@@ -188,6 +292,6 @@ export function createUpdatePlanToolDefinition() {
188
292
  },
189
293
  };
190
294
  }
191
- export function createUpdatePlanTool() {
192
- return wrapToolDefinition(createUpdatePlanToolDefinition());
295
+ export function createUpdatePlanTool(getCurrentPlan) {
296
+ return wrapToolDefinition(createUpdatePlanToolDefinition(undefined, getCurrentPlan));
193
297
  }
@@ -131,6 +131,11 @@ export class FooterComponent {
131
131
  const contextFull = theme.fg("footerContext", `context ${contextPercent}%`) +
132
132
  theme.fg("borderMuted", " / ") +
133
133
  theme.fg("footerWindow", windowDisplay);
134
+ const { input, cacheRead, cacheWrite } = this.session.getSessionStats().tokens;
135
+ const promptTokens = input + cacheRead + cacheWrite;
136
+ const cacheHitPercent = promptTokens > 0 && (cacheRead > 0 || cacheWrite > 0)
137
+ ? `${((cacheRead / promptTokens) * 100).toFixed(1)}%`
138
+ : undefined;
134
139
  const planMode = getCollaborationMode(this.session) === "plan";
135
140
  const planFull = theme.fg("warning", "Plan mode");
136
141
  const planCompact = theme.fg("warning", "Plan");
@@ -176,6 +181,9 @@ export class FooterComponent {
176
181
  };
177
182
  const tryAddField = (field) => trySetFields([...fields, field]);
178
183
  const tryUpgradeField = (key, text) => trySetFields(fields.map((field) => (field.key === key ? { ...field, text } : field)));
184
+ if (cacheHitPercent !== undefined) {
185
+ tryAddField({ key: "cache", order: 3, text: theme.fg("dim", `cache ${cacheHitPercent}`) });
186
+ }
179
187
  // Extension statuses are public UI state, so admit them before optional
180
188
  // detail upgrades and shortcut hints. Truncate only their own field.
181
189
  const extensionStatuses = this.footerData.getExtensionStatuses();
@@ -189,7 +197,7 @@ export class FooterComponent {
189
197
  if (statusText.length > 0 && availableWidth > 0) {
190
198
  tryAddField({
191
199
  key: "extensions",
192
- order: 4,
200
+ order: 5,
193
201
  text: truncateToWidth(theme.fg("dim", statusText), availableWidth, glyphs.ellipsis),
194
202
  });
195
203
  }
@@ -197,14 +205,17 @@ export class FooterComponent {
197
205
  tryAddField({ key: "thinking", order: 1, text: theme.fg("thinkingText", thinkingLevel) });
198
206
  tryAddField({
199
207
  key: "harness",
200
- order: 3,
208
+ order: 4,
201
209
  text: theme.fg("dim", `h:${harnessVersion}`) + evolutionSpinner,
202
210
  });
203
211
  tryUpgradeField("context", contextMedium);
204
212
  tryUpgradeField("thinking", theme.fg("thinkingText", `thinking ${thinkingLevel}`));
213
+ if (cacheHitPercent !== undefined) {
214
+ tryUpgradeField("cache", theme.fg("dim", `input cache hit ${cacheHitPercent}`));
215
+ }
205
216
  tryUpgradeField("harness", theme.fg("dim", `harness ${harnessVersion}`) + evolutionSpinner);
206
217
  tryUpgradeField("context", contextFull);
207
- tryAddField({ key: "hotkeys", order: 7, text: theme.fg("dim", "/hotkeys") });
218
+ tryAddField({ key: "hotkeys", order: 8, text: theme.fg("dim", "/hotkeys") });
208
219
  const getBindingHint = (action, label) => {
209
220
  try {
210
221
  const binding = keyText(action);
@@ -216,11 +227,11 @@ export class FooterComponent {
216
227
  };
217
228
  const thinkingHint = getBindingHint("app.thinking.cycle", "thinking");
218
229
  if (thinkingHint) {
219
- tryAddField({ key: "thinkingHint", order: 5, text: thinkingHint });
230
+ tryAddField({ key: "thinkingHint", order: 6, text: thinkingHint });
220
231
  }
221
232
  const modelHint = getBindingHint("app.model.cycleForward", "model");
222
233
  if (modelHint) {
223
- tryAddField({ key: "modelHint", order: 6, text: modelHint });
234
+ tryAddField({ key: "modelHint", order: 7, text: modelHint });
224
235
  }
225
236
  return [renderFields(fields)];
226
237
  }
@@ -7,6 +7,7 @@ export declare class ToolGroupComponent implements Component {
7
7
  private pathOrigin;
8
8
  private reusesLeadingGap;
9
9
  private settled;
10
+ private cachedRender?;
10
11
  constructor(outputPad?: number, pathOrigin?: DiffexPathOrigin, options?: {
11
12
  reuseLeadingGap?: boolean;
12
13
  });
@@ -7,6 +7,7 @@ export class ToolGroupComponent {
7
7
  pathOrigin;
8
8
  reusesLeadingGap;
9
9
  settled = false;
10
+ cachedRender;
10
11
  constructor(outputPad = 1, pathOrigin = "standalone", options = {}) {
11
12
  this.outputPad = outputPad;
12
13
  this.pathOrigin = pathOrigin;
@@ -18,13 +19,17 @@ export class ToolGroupComponent {
18
19
  return existing;
19
20
  }
20
21
  this.tools.set(toolCallId, component);
22
+ this.cachedRender = undefined;
21
23
  return component;
22
24
  }
23
25
  getTool(toolCallId) {
24
26
  return this.tools.get(toolCallId);
25
27
  }
26
28
  removeTool(toolCallId) {
27
- return this.tools.delete(toolCallId);
29
+ const removed = this.tools.delete(toolCallId);
30
+ if (removed)
31
+ this.cachedRender = undefined;
32
+ return removed;
28
33
  }
29
34
  replaceTools(tools) {
30
35
  this.tools.clear();
@@ -32,6 +37,7 @@ export class ToolGroupComponent {
32
37
  this.tools.delete(toolCallId);
33
38
  this.tools.set(toolCallId, component);
34
39
  }
40
+ this.cachedRender = undefined;
35
41
  }
36
42
  hasTools() {
37
43
  return this.tools.size > 0;
@@ -42,34 +48,43 @@ export class ToolGroupComponent {
42
48
  setPathOrigin(pathOrigin, options = {}) {
43
49
  this.pathOrigin = pathOrigin;
44
50
  this.reusesLeadingGap = pathOrigin === "standalone" && options.reuseLeadingGap === true;
51
+ this.cachedRender = undefined;
45
52
  }
46
53
  markSettled() {
47
54
  this.settled = true;
55
+ this.cachedRender = undefined;
48
56
  }
49
57
  setOutputPad(padding) {
50
58
  this.outputPad = padding;
59
+ this.cachedRender = undefined;
51
60
  }
52
61
  setExpanded(expanded) {
53
62
  for (const tool of this.tools.values()) {
54
63
  tool.setExpanded(expanded);
55
64
  }
65
+ this.cachedRender = undefined;
56
66
  }
57
67
  setShowImages(show) {
58
68
  for (const tool of this.tools.values()) {
59
69
  tool.setShowImages(show);
60
70
  }
71
+ this.cachedRender = undefined;
61
72
  }
62
73
  setImageWidthCells(width) {
63
74
  for (const tool of this.tools.values()) {
64
75
  tool.setImageWidthCells(width);
65
76
  }
77
+ this.cachedRender = undefined;
66
78
  }
67
79
  invalidate() {
68
80
  for (const tool of this.tools.values()) {
69
81
  tool.invalidate();
70
82
  }
83
+ this.cachedRender = undefined;
71
84
  }
72
85
  render(width) {
86
+ if (this.settled && this.cachedRender?.width === width)
87
+ return this.cachedRender.lines;
73
88
  const safeWidth = Math.max(1, width);
74
89
  const outputPad = Math.min(Math.max(0, this.outputPad), Math.max(0, safeWidth - 1));
75
90
  const outerRailWidth = safeWidth - outputPad > 1 ? 1 : 0;
@@ -87,12 +102,14 @@ export class ToolGroupComponent {
87
102
  }
88
103
  }
89
104
  if (renderedTools.length === 0) {
90
- if (this.pathOrigin !== "continued") {
91
- return [];
92
- }
93
- return this.settled
94
- ? new DiffexPathTerminus(outputPad).render(safeWidth)
95
- : new DiffexPathRail(outputPad).render(safeWidth);
105
+ const lines = this.pathOrigin !== "continued"
106
+ ? []
107
+ : this.settled
108
+ ? new DiffexPathTerminus(outputPad).render(safeWidth)
109
+ : new DiffexPathRail(outputPad).render(safeWidth);
110
+ if (this.settled)
111
+ this.cachedRender = { width, lines };
112
+ return lines;
96
113
  }
97
114
  const label = this.settled ? "Explored" : "Exploring";
98
115
  const continuesPath = this.pathOrigin === "continued";
@@ -118,6 +135,8 @@ export class ToolGroupComponent {
118
135
  result.push(visibleWidth(decoratedLine) > safeWidth ? truncateToWidth(decoratedLine, safeWidth, "") : decoratedLine);
119
136
  }
120
137
  }
138
+ if (this.settled)
139
+ this.cachedRender = { width, lines: result };
121
140
  return result;
122
141
  }
123
142
  }
@@ -460,11 +460,29 @@ async function failToolCallsFromTruncatedMessage(toolCalls, config, signal, emit
460
460
  */
461
461
  async function executeToolCalls(currentContext, assistantMessage, config, signal, emit) {
462
462
  const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall");
463
- const hasSequentialToolCall = toolCalls.some((tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === "sequential");
464
- if (config.toolExecution === "sequential" || hasSequentialToolCall) {
463
+ if (config.toolExecution === "sequential") {
465
464
  return executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, signal, emit);
466
465
  }
467
- return executeToolCallsParallel(currentContext, assistantMessage, toolCalls, config, signal, emit);
466
+ const messages = [];
467
+ let terminate = true;
468
+ for (let index = 0; index < toolCalls.length;) {
469
+ if (signal?.aborted)
470
+ break;
471
+ const sequential = currentContext.tools?.find((tool) => tool.name === toolCalls[index].name)?.executionMode === "sequential";
472
+ let end = index + 1;
473
+ if (!sequential) {
474
+ while (end < toolCalls.length &&
475
+ currentContext.tools?.find((tool) => tool.name === toolCalls[end].name)?.executionMode !== "sequential") {
476
+ end++;
477
+ }
478
+ }
479
+ const executePhase = sequential ? executeToolCallsSequential : executeToolCallsParallel;
480
+ const phase = await executePhase(currentContext, assistantMessage, toolCalls.slice(index, end), config, signal, emit);
481
+ messages.push(...phase.messages);
482
+ terminate = terminate && phase.terminate;
483
+ index = end;
484
+ }
485
+ return { messages, terminate: messages.length > 0 && terminate };
468
486
  }
469
487
  async function executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, signal, emit) {
470
488
  const finalizedCalls = [];
@@ -16,9 +16,11 @@ export type StreamFn = (model: Model<Api>, context: Context, options?: SimpleStr
16
16
  * Configuration for how tool calls from a single assistant message are executed.
17
17
  *
18
18
  * - "sequential": each tool call is prepared, executed, and finalized before the next one starts.
19
- * - "parallel": tool calls are prepared sequentially, then allowed tools execute concurrently.
20
- * `tool_execution_end` is emitted in tool completion order after each tool is finalized,
21
- * while tool-result message artifacts are emitted later in assistant source order.
19
+ * - "parallel": consecutive parallel-capable calls form groups separated by individual
20
+ * tools declaring `executionMode: "sequential"`. Each group is prepared sequentially,
21
+ * then allowed tools execute concurrently. Completion events follow finalization order;
22
+ * tool-result message events follow assistant source order within each phase.
23
+ * Hooks and awaited events settle before the next phase is prepared.
22
24
  */
23
25
  export type ToolExecutionMode = "sequential" | "parallel";
24
26
  /**
@@ -237,9 +239,9 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
237
239
  /**
238
240
  * Tool execution mode.
239
241
  * - "sequential": execute tool calls one by one
240
- * - "parallel": preflight tool calls sequentially, then execute allowed tools concurrently;
241
- * emit `tool_execution_end` in tool completion order after each tool is finalized,
242
- * then emit tool-result message artifacts later in assistant source order
242
+ * - "parallel": execute consecutive parallel-capable calls concurrently, separated by
243
+ * sequential tool barriers. Preflight each phase sequentially and await execution,
244
+ * result hooks, completion events, and source-ordered result events before the next phase.
243
245
  *
244
246
  * Default: "parallel"
245
247
  */
@@ -250,6 +252,8 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
250
252
  * Return `{ block: true }` to prevent execution. The loop emits an error tool result instead.
251
253
  * A blocked result can also set `terminate: true` to participate in the batch early-termination rule.
252
254
  * The hook receives the agent abort signal and is responsible for honoring it.
255
+ * In parallel mode, preflight runs sequentially within the current phase, after all
256
+ * prior phases have settled. Sibling results within that phase are not yet available.
253
257
  */
254
258
  beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined>;
255
259
  /**
@@ -264,6 +268,8 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
264
268
  *
265
269
  * Any omitted fields keep their original values. No deep merge is performed.
266
270
  * The hook receives the agent abort signal and is responsible for honoring it.
271
+ * Parallel siblings can finalize concurrently. All hooks and awaited result events
272
+ * in the current phase settle before preflight begins for the next phase.
267
273
  */
268
274
  afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise<AfterToolCallResult | undefined>;
269
275
  }
@@ -362,11 +368,12 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
362
368
  /** Execute the tool call. Throw on failure instead of encoding errors in `content`. */
363
369
  execute: (toolCallId: string, params: Static<TParameters>, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback<TDetails>) => Promise<AgentToolResult<TDetails>>;
364
370
  /**
365
- * Per-tool execution mode override.
366
- * - "sequential": this tool must execute one at a time with other tool calls.
367
- * - "parallel": this tool can execute concurrently with other tool calls.
371
+ * Per-tool scheduling metadata under global parallel execution.
372
+ * - "sequential": this call is an exclusive barrier in assistant source order.
373
+ * - "parallel": this call can execute concurrently within its consecutive group.
368
374
  *
369
- * If omitted, the default execution mode applies.
375
+ * Omitted metadata is parallel-capable. Global sequential execution always runs
376
+ * calls one by one, including tools explicitly declaring "parallel".
370
377
  */
371
378
  executionMode?: ToolExecutionMode;
372
379
  }
@@ -1,8 +1,8 @@
1
1
  [
2
2
  {
3
3
  "name": "@diffexai/diffex-agent-core",
4
- "version": "0.3.1",
5
- "sourceCommit": "9d59a28db4888b6824740505f0a16146667f036b",
4
+ "version": "0.3.2",
5
+ "sourceCommit": "57dbba8a810d2e4cc81e48a30a4f5736e0e2d5bf",
6
6
  "license": "Apache-2.0",
7
7
  "portions": "Diffex contributions; Pi-derived portions retain MIT terms in LICENSE",
8
8
  "notice": "LICENSE",
@@ -10,12 +10,12 @@
10
10
  },
11
11
  {
12
12
  "name": "@diffexai/diffex-ai",
13
- "version": "^0.3.1",
13
+ "version": "^0.3.2",
14
14
  "distribution": "resolved separately by package manager; not bundled in this npm tarball"
15
15
  },
16
16
  {
17
17
  "name": "@diffexai/diffex-telemetry",
18
- "version": "^0.3.1",
18
+ "version": "^0.3.2",
19
19
  "distribution": "resolved separately by package manager; not bundled in this npm tarball"
20
20
  },
21
21
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "sourceCommit": "9d59a28db4888b6824740505f0a16146667f036b",
2
+ "sourceCommit": "57dbba8a810d2e4cc81e48a30a4f5736e0e2d5bf",
3
3
  "algorithm": "sha256",
4
4
  "files": [
5
5
  {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  {
10
10
  "path": "dist/agent-loop.js",
11
- "sha256": "f9aeefb27c0391e02e3e39de475943b382afe83d948487597595faa8b3889a4d"
11
+ "sha256": "b1b846af2909c11fecd35a06cad1dd79c57a8c2ea8ea7240136d71169b4ecfa8"
12
12
  },
13
13
  {
14
14
  "path": "dist/agent.d.ts",
@@ -380,7 +380,7 @@
380
380
  },
381
381
  {
382
382
  "path": "dist/types.d.ts",
383
- "sha256": "076d88249087ecd918b2111dfa97ea485ac7f2874930f836fa05a9c28f45141a"
383
+ "sha256": "f64317c4ce1d3ccb8284e6eed635b5540f9a27dfbea94ce709eeed18b4625b28"
384
384
  },
385
385
  {
386
386
  "path": "dist/types.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diffexai/diffex-agent-core",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1 +1 @@
1
- {"schemaVersion":3,"generatedAt":"2026-09-20T21:05:14.234Z","structureHash":"5ae57f6cf495fb2f4ede669bf128fd21195cd00e1fbb2c1e884764da807da5d2","files":{"amazon-bedrock.json":"9d3eed9613b9b56f0c5f202a61b279a7168edf01bae6c3218a7ab28664ff80ea","ant-ling.json":"4979fe79d99ed97382d7ce40170c6132e932e77906de1eb54464c042e7d63633","anthropic.json":"6937a397c6c7cf654990d072734efad023b652b294b04961d43a5835d7d08156","azure-openai-responses.json":"787ba62322c46fbc69bc1fcb7c7cfc100b8f74211c72adf9766b6dbf5eae0c41","baseten.json":"c7cf9a7e7e6bee9c53005acf4706c25f954adaa64a9058535fb7e5b1d6920852","cerebras.json":"9eaa554e1b02f3665f8f63e6432d253bf81351af7c72001054dd8108aa528a52","cloudflare-ai-gateway.json":"356a2892803067a5d042c5abb87051edba46f7209589c625f8bcea8c7c5ce5c9","cloudflare-workers-ai.json":"07ef63208ff7517fd1a80962f18143e21de399640423329490b157f459ccf995","deepseek.json":"0dcc807a4e5827b488c6ceac87884ff6e735e01cf4f2ddfec9dd812e6fde041b","fireworks.json":"4c9055f7a2c5345b808bb561b07b227fc29148e0fb774c0ac50db2b34ab89aaf","github-copilot.json":"37cfc1da3a6814e37e14c565ed0a1e41f2eb513b10aa8f11ac18f27fa392308c","google-vertex.json":"e1631109172dc1f7d7977570420a5bb86258bdd1088427e244a2c51545c4f24d","google.json":"7619bf03edd2a7acb06a2274a14b13c3a61354288abe029a758401d4de36a8fd","groq.json":"e4728fd72e358cd8f25e9a518e9b49a521ef44523d3debe38a7db9e0663c4077","huggingface.json":"a488b6f175b382277ccf7d487f877ef0c698994576bb55d2410838bf9c8c7c41","kimi-coding.json":"9da7ea8e9eb8df5d8f73acc25d3da5b41164f522c0f09b45265b4bf104743ac1","minimax-cn.json":"65d37e55a6d9585855f94b3fb7739308d107fe248ebd9e1001d1c5d4606ad2c4","minimax.json":"c7dc5765995ae77242916430929c7cc9a782589f7e483e13f7e01663190d028e","mistral.json":"be522597b22bceeb0bb9a0ba1781647649ffd19637d0c8e67b056d7b0ec33d52","moonshotai-cn.json":"63f77de8782dce6a8d4649b6075ee04c9732863ae85561e0867871220c558bde","moonshotai.json":"2a8dfb97eb905e991c22081dc09bd4a61c05b7fa9ac0d5c72d6930f601f490f1","nvidia.json":"2ff6862206269af88a694e627c4b54753055357710adaf49ac8518d102c17407","openai-codex.json":"4a73818291987693fcb53e9e61b4be3429ffd78b3f64ea877a34c5f9928c89e1","openai.json":"6fa1954abd6c5b301620a02a57412bd60bbfdf20465b9a3eb4d113f88dfb8f7b","opencode-go.json":"fa6d6737da943fbb9c36487cb4cd5bc152d1f2fd548280bbe9d35d0304e79f11","opencode.json":"43c5d9cc6fab109411e6ac8eebac4b9fb0b00cff844cf0eb6c1559a08586525f","openrouter.json":"a3375b66ad7b4bb61f22d867e132c991d3b9851436c529838c109b9794d59b93","qwen-token-plan-cn.json":"a50420854a624ea82155d9621cd88d6cbd7802498d28fb1fd317eb7a1bc26cc7","qwen-token-plan-individual.json":"0e1578a5c0e11d65644bb42d1d6efee5af97a55a4881d0421ab68bd970d4d4ef","qwen-token-plan.json":"24285f68b2350aa1d98ecaeafe0914bce97940fe8798eff20f2a5c6d8c26bd6f","together.json":"927c707d06f2236cdab13f14c870fb58bce0614ba7287bbcd57cc9fd66d191ed","vercel-ai-gateway.json":"03b0830a794af1bc921fb504565534bedec660fcc9482adcf56ec5fb27703e60","xai.json":"282838b0546d63106c81f1703cdf5ed31ebfe0c5f13a4bec0d590472cbbe03f2","xiaomi-token-plan-ams.json":"7ce8ed244672c45737d286ff3ca9c3a2686a5d4b2f0c5f71ab9348c14b14daf3","xiaomi-token-plan-cn.json":"684892b5fe6fa371d6040d2011c0fa33facd650d2eaf19f5dbee8efb33016200","xiaomi-token-plan-sgp.json":"b19e79b82ac4fdcb080e6d220106797504c885ed70b27fc2b3c1aa4810f32963","xiaomi.json":"810368d3828c8bb9498012a0d48639030c0c216b8c13e79da02daa515aa23448","zai-coding-cn.json":"abf4cbc22507b9d545842072b5e34bce230025fc20f7b1cc67db3782c1dc4b56","zai.json":"089af736a622b0a25e417493819f4e174884afba43235c5a1f17814057875627"}}
1
+ {"schemaVersion":3,"generatedAt":"2026-09-29T21:05:24.416Z","structureHash":"2fd45211a507dbf73974cbe675b576aeb2dfae490b569af9b3b90f9f601d7be5","files":{"amazon-bedrock.json":"1e0b4308b61f51c642616571d53efcca4aada249b20d220ca9068df7e5ea52fd","ant-ling.json":"4979fe79d99ed97382d7ce40170c6132e932e77906de1eb54464c042e7d63633","anthropic.json":"b2976a77dd2a3260d401918df7c523bd2124abae2382f7a67229829e2701d899","azure-openai-responses.json":"dfcc219adb7d04783861274cb94ac503b9a0d9f00a4e77c3637ebfc779ee5eb8","baseten.json":"c7cf9a7e7e6bee9c53005acf4706c25f954adaa64a9058535fb7e5b1d6920852","cerebras.json":"3be228c613905c215c9da3d0b3535e10cc594ff2351b05390744c7c34de89b6c","cloudflare-ai-gateway.json":"fac11bff507133190fcb7cb216656a8ad1b8d3b8c37c1b523a6d85f931b0cbda","cloudflare-workers-ai.json":"6012eb769382a5a9fa96ec5f429f6744848e377327c9063573a15006ef0652ce","deepseek.json":"0dcc807a4e5827b488c6ceac87884ff6e735e01cf4f2ddfec9dd812e6fde041b","fireworks.json":"f3e423850d1509690c0d8cfd4071bdb822f01a05e6d691032c309476edc8a927","github-copilot.json":"418a6da7c705b9043ffecc21597cb7d79bf80b6fb117f95a5a7675b68a032539","google-vertex.json":"97d80ac909d256cc1bf19ac54d9d8131c492e7c0c102ad89f68ade528544e0ad","google.json":"68f6cf6283cc0d69b77b0fa79e9c94774e96524f61ebba616319f05d5c9db56c","groq.json":"e4728fd72e358cd8f25e9a518e9b49a521ef44523d3debe38a7db9e0663c4077","huggingface.json":"2f93082144a7b2a1cbf79148244ce7559e0428d6310c5eb0648bca89b15a8b4b","kimi-coding.json":"9da7ea8e9eb8df5d8f73acc25d3da5b41164f522c0f09b45265b4bf104743ac1","minimax-cn.json":"4f636b47b28a507ad33b5eaba33fc4889e5956504d10f2a0d9c61167ae01ff1d","minimax.json":"9fd1764ffeefebded8579039cf0c48854a43e4c5b724f62361fc2ac54f705aa1","mistral.json":"9da97430d9b86ad7f606b0fc088694e7a04c38bfa8963a33e1205b3141c917eb","moonshotai-cn.json":"c1d6024d0095bb34e33847a3281692e8ddb7ddbb61748a0373cdab45f414d264","moonshotai.json":"08b8a7bbb30aa3a5ed077f02bd59ea5e9d32a1a8257e56d1606be8d722091783","nvidia.json":"6493e09b5db8b8fe9c8fae5bc15dc287645f37f83a4ed3a1ad1181e5830b5ff7","openai-codex.json":"4a73818291987693fcb53e9e61b4be3429ffd78b3f64ea877a34c5f9928c89e1","openai.json":"d8107710ade17d9c177b677135bf1ae88d6b884ee765cd64b74afe501f1f0844","opencode-go.json":"af32bbce67a5b2f705c43cd47e301e9fd4548bd3af03daaebb4b63bca864f9bc","opencode.json":"b3b709db64eb71505560758e36d287458280ce3d361d0d87ab5d616d6d6cf8a3","openrouter.json":"21dbdf7e09616dd36bfbf2121801cee9b8f68b5462e69c009bf1f665a2ba4002","qwen-token-plan-cn.json":"a50420854a624ea82155d9621cd88d6cbd7802498d28fb1fd317eb7a1bc26cc7","qwen-token-plan-individual.json":"0e1578a5c0e11d65644bb42d1d6efee5af97a55a4881d0421ab68bd970d4d4ef","qwen-token-plan.json":"24285f68b2350aa1d98ecaeafe0914bce97940fe8798eff20f2a5c6d8c26bd6f","together.json":"b4971d293e5be6b9acaf7b5d9aa588e6374e5317c116e98437128197c39a2fac","vercel-ai-gateway.json":"79ffb1b0c83cec8d26472ddc519ad6184eeb9cb1c960fd02c7dbf1473dcc5871","xai.json":"d036f5d1ececd7280c09ca1a6249553f39a7a1087ddb129a0d689a427fc944c4","xiaomi-token-plan-ams.json":"9581074003df2c31dbe483221dbbd1891e92d05102fcb816d51cbb22542174cf","xiaomi-token-plan-cn.json":"df9f18cca62ed29398124b3c9f50b157eea83c32167579a40ac129a3a673e4d6","xiaomi-token-plan-sgp.json":"5681a7eed774bc4b4e296cdae1a5028176b856b48af96cbd7776a03ba905aab1","xiaomi.json":"506eef7c189328cabce83768a48ec3e59c3f8a575f840d8ffcefa6966c340562","zai-coding-cn.json":"abf4cbc22507b9d545842072b5e34bce230025fc20f7b1cc67db3782c1dc4b56","zai.json":"089af736a622b0a25e417493819f4e174884afba43235c5a1f17814057875627"}}