@duckcodeailabs/dql-agent 1.14.3-rc.1 → 1.14.4

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 (128) hide show
  1. package/dist/agent-run-engine.d.ts +177 -3
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +2061 -57
  4. package/dist/agent-run-engine.js.map +1 -1
  5. package/dist/agent-run-gates.d.ts.map +1 -1
  6. package/dist/agent-run-gates.js +51 -3
  7. package/dist/agent-run-gates.js.map +1 -1
  8. package/dist/agent-run-store.d.ts.map +1 -1
  9. package/dist/agent-run-store.js +544 -9
  10. package/dist/agent-run-store.js.map +1 -1
  11. package/dist/agentic/analyst-loop.d.ts +2 -0
  12. package/dist/agentic/analyst-loop.d.ts.map +1 -1
  13. package/dist/agentic/analyst-loop.js +41 -4
  14. package/dist/agentic/analyst-loop.js.map +1 -1
  15. package/dist/agentic/ledger-tools.d.ts.map +1 -1
  16. package/dist/agentic/ledger-tools.js +8 -1
  17. package/dist/agentic/ledger-tools.js.map +1 -1
  18. package/dist/agentic/orchestrator-policy.d.ts +3 -1
  19. package/dist/agentic/orchestrator-policy.d.ts.map +1 -1
  20. package/dist/agentic/orchestrator-policy.js +1 -1
  21. package/dist/agentic/orchestrator-policy.js.map +1 -1
  22. package/dist/agentic/tool-loop.d.ts +13 -1
  23. package/dist/agentic/tool-loop.d.ts.map +1 -1
  24. package/dist/agentic/tool-loop.js +441 -32
  25. package/dist/agentic/tool-loop.js.map +1 -1
  26. package/dist/analytical-frame.d.ts +19 -0
  27. package/dist/analytical-frame.d.ts.map +1 -1
  28. package/dist/analytical-frame.js +170 -12
  29. package/dist/analytical-frame.js.map +1 -1
  30. package/dist/analytical-orchestration.d.ts +949 -10
  31. package/dist/analytical-orchestration.d.ts.map +1 -1
  32. package/dist/analytical-orchestration.js +499 -56
  33. package/dist/analytical-orchestration.js.map +1 -1
  34. package/dist/answer-loop.d.ts +166 -2
  35. package/dist/answer-loop.d.ts.map +1 -1
  36. package/dist/answer-loop.js +654 -18
  37. package/dist/answer-loop.js.map +1 -1
  38. package/dist/answer-shape.d.ts +27 -0
  39. package/dist/answer-shape.d.ts.map +1 -1
  40. package/dist/answer-shape.js +55 -0
  41. package/dist/answer-shape.js.map +1 -1
  42. package/dist/ask-observability/instrumentation.d.ts.map +1 -1
  43. package/dist/ask-observability/instrumentation.js +52 -1
  44. package/dist/ask-observability/instrumentation.js.map +1 -1
  45. package/dist/ask-observability/portable.js +562 -0
  46. package/dist/ask-observability/portable.js.map +1 -1
  47. package/dist/ask-observability/types.d.ts +26 -3
  48. package/dist/ask-observability/types.d.ts.map +1 -1
  49. package/dist/ask-observability/types.js.map +1 -1
  50. package/dist/ask-runtime/ask-agent-runtime-v2.d.ts +850 -0
  51. package/dist/ask-runtime/ask-agent-runtime-v2.d.ts.map +1 -0
  52. package/dist/ask-runtime/ask-agent-runtime-v2.js +1716 -0
  53. package/dist/ask-runtime/ask-agent-runtime-v2.js.map +1 -0
  54. package/dist/ask-runtime/ask-analyst-runtime.d.ts +95 -0
  55. package/dist/ask-runtime/ask-analyst-runtime.d.ts.map +1 -0
  56. package/dist/ask-runtime/ask-analyst-runtime.js +8280 -0
  57. package/dist/ask-runtime/ask-analyst-runtime.js.map +1 -0
  58. package/dist/conversation/session-store.d.ts +31 -0
  59. package/dist/conversation/session-store.d.ts.map +1 -1
  60. package/dist/conversation/session-store.js +38 -0
  61. package/dist/conversation/session-store.js.map +1 -1
  62. package/dist/conversation/snapshot.d.ts +4 -1
  63. package/dist/conversation/snapshot.d.ts.map +1 -1
  64. package/dist/conversation/snapshot.js +85 -3
  65. package/dist/conversation/snapshot.js.map +1 -1
  66. package/dist/conversation/turn-trust.d.ts.map +1 -1
  67. package/dist/conversation/turn-trust.js +2 -0
  68. package/dist/conversation/turn-trust.js.map +1 -1
  69. package/dist/embeddings/provider.d.ts +1 -0
  70. package/dist/embeddings/provider.d.ts.map +1 -1
  71. package/dist/embeddings/provider.js +18 -0
  72. package/dist/embeddings/provider.js.map +1 -1
  73. package/dist/generated-analytical-proposal.js +37 -2
  74. package/dist/generated-analytical-proposal.js.map +1 -1
  75. package/dist/index.d.ts +15 -8
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +9 -4
  78. package/dist/index.js.map +1 -1
  79. package/dist/intent-controller.d.ts +77 -1
  80. package/dist/intent-controller.d.ts.map +1 -1
  81. package/dist/intent-controller.js +1 -1
  82. package/dist/intent-controller.js.map +1 -1
  83. package/dist/kg/build.js +32 -2
  84. package/dist/kg/build.js.map +1 -1
  85. package/dist/meaning-resolution.d.ts +129 -0
  86. package/dist/meaning-resolution.d.ts.map +1 -1
  87. package/dist/meaning-resolution.js +108 -15
  88. package/dist/meaning-resolution.js.map +1 -1
  89. package/dist/metadata/analysis-planner.js +57 -6
  90. package/dist/metadata/analysis-planner.js.map +1 -1
  91. package/dist/metadata/catalog.d.ts +14 -0
  92. package/dist/metadata/catalog.d.ts.map +1 -1
  93. package/dist/metadata/catalog.js +28 -1
  94. package/dist/metadata/catalog.js.map +1 -1
  95. package/dist/metadata/meaning-evidence.d.ts +19 -0
  96. package/dist/metadata/meaning-evidence.d.ts.map +1 -1
  97. package/dist/metadata/meaning-evidence.js +113 -6
  98. package/dist/metadata/meaning-evidence.js.map +1 -1
  99. package/dist/provider-egress.d.ts +4 -1
  100. package/dist/provider-egress.d.ts.map +1 -1
  101. package/dist/provider-egress.js +50 -5
  102. package/dist/provider-egress.js.map +1 -1
  103. package/dist/providers/claude.d.ts +2 -2
  104. package/dist/providers/claude.d.ts.map +1 -1
  105. package/dist/providers/claude.js +191 -28
  106. package/dist/providers/claude.js.map +1 -1
  107. package/dist/providers/index.d.ts +1 -1
  108. package/dist/providers/index.d.ts.map +1 -1
  109. package/dist/providers/index.js.map +1 -1
  110. package/dist/providers/openai.d.ts +2 -2
  111. package/dist/providers/openai.d.ts.map +1 -1
  112. package/dist/providers/openai.js +191 -21
  113. package/dist/providers/openai.js.map +1 -1
  114. package/dist/providers/types.d.ts +79 -1
  115. package/dist/providers/types.d.ts.map +1 -1
  116. package/dist/providers/types.js.map +1 -1
  117. package/dist/requirement-clauses.d.ts +23 -0
  118. package/dist/requirement-clauses.d.ts.map +1 -0
  119. package/dist/requirement-clauses.js +70 -0
  120. package/dist/requirement-clauses.js.map +1 -0
  121. package/dist/resolved-analytical-plan.d.ts.map +1 -1
  122. package/dist/resolved-analytical-plan.js +201 -15
  123. package/dist/resolved-analytical-plan.js.map +1 -1
  124. package/dist/router.d.ts +109 -2
  125. package/dist/router.d.ts.map +1 -1
  126. package/dist/router.js +753 -76
  127. package/dist/router.js.map +1 -1
  128. package/package.json +4 -4
@@ -39,9 +39,11 @@ export async function runAgenticToolLoopDetailed(provider, messages, tools, opti
39
39
  const usable = tools
40
40
  .filter((tool) => tool.name && tool.description)
41
41
  .map((tool) => guardToolOutput(tool, options, resultRowBudgetUsage));
42
- const policyMessages = options.toolPolicy
43
- ? [{ role: 'system', content: options.toolPolicy }]
44
- : [];
42
+ const initialToolPolicy = renderCurrentToolPolicy(options, usable);
43
+ const policyMessages = [
44
+ ...(options.toolPolicy ? [{ role: 'system', content: options.toolPolicy }] : []),
45
+ ...(initialToolPolicy ? [{ role: 'system', content: initialToolPolicy }] : []),
46
+ ];
45
47
  if (usable.length === 0) {
46
48
  try {
47
49
  return { text: await provider.generate([...messages, ...policyMessages], options), stop: 'final', toolCalls: 0 };
@@ -56,8 +58,16 @@ export async function runAgenticToolLoopDetailed(provider, messages, tools, opti
56
58
  // Native tool use owns its own loop; hand it the same policy + tools.
57
59
  if (provider.generateWithTools) {
58
60
  try {
61
+ const native = await provider.generateWithTools([...messages, ...policyMessages], usable, options);
62
+ if (isNativeToolLoopStop(native)) {
63
+ return {
64
+ text: native.text,
65
+ stop: native.kind,
66
+ toolCalls: native.toolCalls,
67
+ };
68
+ }
59
69
  return {
60
- text: await provider.generateWithTools([...messages, ...policyMessages], usable, options),
70
+ text: native,
61
71
  stop: 'final',
62
72
  toolCalls: 0,
63
73
  };
@@ -71,13 +81,80 @@ export async function runAgenticToolLoopDetailed(provider, messages, tools, opti
71
81
  }
72
82
  return runTextProtocolToolLoopDetailed(provider, [...messages, ...policyMessages], usable, options);
73
83
  }
84
+ function isNativeToolLoopStop(value) {
85
+ return Boolean(value
86
+ && typeof value === 'object'
87
+ && !Array.isArray(value)
88
+ && value.version === 1
89
+ && (value.kind === 'tool_budget_exhausted'
90
+ || value.kind === 'provider_dispatch_budget_exhausted'
91
+ || value.kind === 'invalid_tool_response'
92
+ || value.kind === 'run_soft_target_exceeded'
93
+ || value.kind === 'run_deadline_insufficient')
94
+ && typeof value.text === 'string'
95
+ && typeof value.toolCalls === 'number');
96
+ }
97
+ function currentToolPolicy(options, tools) {
98
+ const policy = options.getCurrentToolPolicy?.();
99
+ const available = new Set(tools.map((tool) => tool.name));
100
+ const allowedToolNames = new Set((policy?.allowedToolNames ?? tools.map((tool) => tool.name))
101
+ .filter((name) => available.has(name)));
102
+ const terminalActionToolNames = new Set((policy?.terminalActionToolNames ?? [])
103
+ .filter((name) => allowedToolNames.has(name)));
104
+ return { policy, allowedToolNames, terminalActionToolNames };
105
+ }
106
+ /**
107
+ * Keep the text protocol honest about the same narrowing that native
108
+ * transports receive in their next API `tools` declaration. This is a safe
109
+ * controller instruction, never hidden reasoning or mutable business context.
110
+ */
111
+ function renderCurrentToolPolicy(options, tools) {
112
+ const { policy, allowedToolNames, terminalActionToolNames } = currentToolPolicy(options, tools);
113
+ if (!policy)
114
+ return undefined;
115
+ const allowed = [...allowedToolNames];
116
+ const terminal = [...terminalActionToolNames];
117
+ const instruction = policy.instruction?.trim();
118
+ // Re-state the SIGNATURES of the tools still on the table, not just their
119
+ // names. The response contract is sent once, at the start, listing every
120
+ // tool; a model reading it later has no way to tell that the host has since
121
+ // narrowed the set, so it keeps proposing a tool that can only be refused —
122
+ // and each refusal costs a dispatch until the turn dies with nothing run.
123
+ // Naming the remaining options in full makes the next legal move the
124
+ // easiest one to make.
125
+ const allowedSignatures = allowed.length && allowed.length < tools.length
126
+ ? tools
127
+ .filter((tool) => allowedToolNames.has(tool.name))
128
+ .map((tool) => {
129
+ const props = tool.inputSchema?.properties;
130
+ return `- ${tool.name}(${props ? Object.keys(props).join(', ') : ''}): ${tool.description}`;
131
+ })
132
+ : [];
133
+ return [
134
+ `Runtime tool availability update. You may call only: ${allowed.length ? allowed.join(', ') : 'no tools'}.`,
135
+ terminal.length ? `If this is the final controller turn, use only: ${terminal.join(', ')}.` : undefined,
136
+ instruction,
137
+ allowedSignatures.length ? `\nStill available to you:\n${allowedSignatures.join('\n')}` : undefined,
138
+ ].filter((part) => Boolean(part)).join(' ');
139
+ }
74
140
  function providerDispatchTerminal(error) {
141
+ const code = toolLoopErrorCode(error);
142
+ if (code === 'PROVIDER_DISPATCH_BUDGET_EXHAUSTED') {
143
+ return { text: '', stop: 'provider_dispatch_budget_exhausted', toolCalls: 0 };
144
+ }
145
+ if (code === 'RUN_SOFT_TARGET_EXCEEDED') {
146
+ return { text: '', stop: 'run_soft_target_exceeded', toolCalls: 0 };
147
+ }
148
+ if (code === 'RUN_DEADLINE_INSUFFICIENT') {
149
+ return { text: '', stop: 'run_deadline_insufficient', toolCalls: 0 };
150
+ }
151
+ return undefined;
152
+ }
153
+ function toolLoopErrorCode(error) {
75
154
  const code = error && typeof error === 'object' && 'code' in error
76
155
  ? error.code
77
156
  : undefined;
78
- return code === 'PROVIDER_DISPATCH_BUDGET_EXHAUSTED'
79
- ? { text: '', stop: 'provider_dispatch_budget_exhausted', toolCalls: 0 }
80
- : undefined;
157
+ return typeof code === 'string' ? code : undefined;
81
158
  }
82
159
  function guardToolOutput(tool, options, resultRowBudgetUsage) {
83
160
  const policy = options.providerPayloadGuard;
@@ -91,10 +168,12 @@ function guardToolOutput(tool, options, resultRowBudgetUsage) {
91
168
  const budgetGroup = policy.resultRowBudgetGroupByTool?.[tool.name] ?? tool.name;
92
169
  const cumulativeLimit = policy.cumulativeResultRowBudgets?.[budgetGroup] ?? maxResultRows;
93
170
  const alreadyUsed = resultRowBudgetUsage.get(budgetGroup) ?? 0;
94
- const bounded = boundProviderResultRows(output, Math.max(0, cumulativeLimit - alreadyUsed));
171
+ const bounded = boundProviderResultRows(output, Math.max(0, cumulativeLimit - alreadyUsed), policy.maxResultColumns, policy.maxResultCells);
95
172
  const shape = assertProviderPayloadAllowed(bounded.value, {
96
173
  allowResultRows: maxResultRows > 0,
97
174
  maxResultRows: Math.max(0, cumulativeLimit - alreadyUsed),
175
+ ...(typeof policy.maxResultColumns === 'number' ? { maxResultColumns: policy.maxResultColumns } : {}),
176
+ ...(typeof policy.maxResultCells === 'number' ? { maxResultCells: policy.maxResultCells } : {}),
98
177
  purpose: policy.purpose,
99
178
  });
100
179
  const cumulativeResultRowCount = alreadyUsed + shape.resultRowCount;
@@ -131,6 +210,13 @@ export async function runTextProtocolToolLoopDetailed(provider, baseMessages, to
131
210
  // composition instead of silently spending it on another observation.
132
211
  const dispatchLimit = Math.max(1, Math.min(30, options.maxProviderDispatches ?? (maxToolCalls + 1)));
133
212
  const effectiveToolBudget = Math.min(maxToolCalls, Math.max(0, dispatchLimit - 1));
213
+ // `onProviderDispatch` is an optional transport callback. Subscription and
214
+ // test providers can legitimately be callback-silent, so it cannot be the
215
+ // source of truth for whether this is the first model turn or a tool
216
+ // follow-up. Keep a local invocation count for the text protocol itself;
217
+ // the server-side wrapper remains the authority for physical admission and
218
+ // the hard send cap.
219
+ let providerTurns = 0;
134
220
  let physicalDispatches = 0;
135
221
  const outerObserver = options.onProviderDispatch;
136
222
  const runOptions = {
@@ -147,39 +233,143 @@ export async function runTextProtocolToolLoopDetailed(provider, baseMessages, to
147
233
  ...baseMessages,
148
234
  // Tell the model the *effective* ceiling, not a larger policy cap that
149
235
  // cannot physically leave room for its final response.
150
- { role: 'system', content: buildTextToolContract(tools, effectiveToolBudget) },
236
+ { role: 'system', content: (options.textToolContract ?? buildTextToolContract)(tools, effectiveToolBudget) },
151
237
  ];
238
+ const initialPolicy = renderCurrentToolPolicy(options, tools);
239
+ if (initialPolicy)
240
+ messages.push({ role: 'system', content: initialPolicy });
152
241
  let lastText = '';
153
242
  let toolCalls = 0;
243
+ let requiredActionSignature = '';
244
+ let requiredActionProseRetries = 0;
245
+ // Only canonical V2 lanes with a real host finish control need to reserve a
246
+ // physical send after execution. Generic tool users retain their historical
247
+ // final-action behavior at the cap.
248
+ const requiresPostExecutionFinish = Boolean(options.getCurrentToolPolicy)
249
+ && tools.some((tool) => tool.name === 'finish_answer');
154
250
  while (true) {
251
+ // Keep the physical Ask V2 budget meaningful. With the standard six
252
+ // sends, the fifth send may be the controller-selected execution action
253
+ // and the sixth is reserved for the host-required finish/narration
254
+ // control. This is a transport constraint only: the kernel still gives
255
+ // the model the candidate-bound execution choices.
256
+ const livePolicyBeforeDispatch = currentToolPolicy(options, tools);
257
+ const nextRequiredActionSignature = [...livePolicyBeforeDispatch.terminalActionToolNames].sort().join('|');
258
+ if (nextRequiredActionSignature !== requiredActionSignature) {
259
+ requiredActionSignature = nextRequiredActionSignature;
260
+ requiredActionProseRetries = 0;
261
+ }
262
+ const narrationControlRound = livePolicyBeforeDispatch.terminalActionToolNames.has('finish_answer');
263
+ // Only an execution action needs a second, post-result finish/narration
264
+ // send. A host-issued clarification is itself the terminal control, so
265
+ // reserving a phantom narration slot would prematurely reject a malformed
266
+ // clarification instead of returning its typed observation to the model.
267
+ const terminalExecutionAction = [...livePolicyBeforeDispatch.terminalActionToolNames]
268
+ .some((name) => !isAskV2TerminalControlTool(name));
269
+ const reservePostExecutionNarration = requiresPostExecutionFinish && terminalExecutionAction;
270
+ const finalExecutionActionRound = !narrationControlRound
271
+ && livePolicyBeforeDispatch.terminalActionToolNames.size > 0
272
+ && providerTurns >= Math.max(0, dispatchLimit - (reservePostExecutionNarration ? 2 : 1));
273
+ const terminalActionRound = livePolicyBeforeDispatch.terminalActionToolNames.size > 0
274
+ && (narrationControlRound
275
+ ? providerTurns >= Math.max(0, dispatchLimit - 1)
276
+ : finalExecutionActionRound);
277
+ if (terminalActionRound) {
278
+ messages.push({
279
+ role: 'system',
280
+ content: `Final controller action for this phase. Call exactly one of: ${[...livePolicyBeforeDispatch.terminalActionToolNames].join(', ')}. Do not inspect more context or answer in prose.`,
281
+ });
282
+ }
155
283
  let text;
156
284
  try {
157
- text = await provider.generate(messages, runOptions);
285
+ // Authoritative Ask V2 labels its first model-controlled transport
286
+ // separately from later tool-follow-up transports. This is only a
287
+ // server-owned accounting detail: it does not grant a different tool,
288
+ // route, or egress policy. Keeping it here makes text-only providers
289
+ // truthful in the same way native multi-tool providers are.
290
+ // Once an execution has completed, the only remaining controller action
291
+ // is host-local finish_answer. Account the request for that action from
292
+ // the narration allowance instead of treating it as more discovery.
293
+ const dispatchOptions = providerTurns > 0 && runOptions.dispatchPhase === 'agent_control'
294
+ ? {
295
+ ...runOptions,
296
+ dispatchPhase: narrationControlRound
297
+ ? 'narration'
298
+ : 'tool_followup',
299
+ }
300
+ : runOptions;
301
+ providerTurns += 1;
302
+ text = await provider.generate(messages, dispatchOptions);
158
303
  }
159
304
  catch (error) {
160
- const code = error && typeof error === 'object' && 'code' in error
161
- ? error.code
162
- : undefined;
163
- if (code === 'PROVIDER_DISPATCH_BUDGET_EXHAUSTED') {
164
- return { text: lastText, stop: 'provider_dispatch_budget_exhausted', toolCalls };
165
- }
305
+ const terminal = providerDispatchTerminal(error);
306
+ if (terminal)
307
+ return { ...terminal, text: lastText, toolCalls };
166
308
  throw error;
167
309
  }
168
- if (text.trim())
169
- lastText = text;
170
310
  const requestedCall = parseTextToolCall(text);
171
- if (!requestedCall)
311
+ if (!requestedCall) {
312
+ const policy = livePolicyBeforeDispatch;
313
+ // A live V2 policy can require one concrete next action. A prose reply
314
+ // at this point is neither a valid answer nor a safe terminal: discard
315
+ // it and spend the next admissible controller send on the host-approved
316
+ // action only. This prevents a model from escaping the semantic/DQL/SQL
317
+ // boundary just by answering in prose after an inspection.
318
+ if (policy.terminalActionToolNames.size > 0) {
319
+ // One constrained retry is enough to distinguish a transient
320
+ // text-protocol miss from a provider that cannot honor a required
321
+ // host action. Do not burn the remaining Ask budget on repeated
322
+ // prose, and do not mislabel that transport fault as missing context.
323
+ // If the admitted physical send was already the last one, there is
324
+ // no constrained retry to attempt. Preserve the distinct transport
325
+ // boundary: a post-result narration can then retain deterministic
326
+ // facts, while a pre-freeze controller is told precisely that its
327
+ // dispatch reserve is exhausted. `invalid_tool_response` means the
328
+ // provider ignored the same narrowed action *twice*.
329
+ if (providerTurns >= dispatchLimit) {
330
+ return { text: '', stop: 'provider_dispatch_budget_exhausted', toolCalls };
331
+ }
332
+ if (requiredActionProseRetries >= 1) {
333
+ return { text: '', stop: 'invalid_tool_response', toolCalls };
334
+ }
335
+ requiredActionProseRetries += 1;
336
+ messages.push({
337
+ role: 'user',
338
+ content: `Controller progression required. Discard the prior prose and call exactly one of: ${[...policy.terminalActionToolNames].join(', ')}. Do not answer in prose.`,
339
+ });
340
+ continue;
341
+ }
342
+ if (text.trim())
343
+ lastText = text;
172
344
  return { text: text || lastText, stop: 'final', toolCalls };
345
+ }
346
+ if (text.trim())
347
+ lastText = text;
173
348
  // A tool-shaped reply is not a final answer merely because the host has no
174
349
  // dispatch left for another observation. Keep this typed distinction so a
175
350
  // caller cannot mistake it for executable SQL or prose.
176
- if (toolCalls >= effectiveToolBudget) {
351
+ // Ask V2 has two host-owned terminal controls. They may use the reserved
352
+ // final dispatch only when the tool backend confirms the terminal result.
353
+ // A model can still propose either control too early; that rejected
354
+ // proposal is a normal pre-freeze observation which must reach the next
355
+ // controller turn rather than ending the loop as if an answer existed.
356
+ const isTerminalControlCall = isAskV2TerminalControlTool(requestedCall.name);
357
+ const responsePolicy = livePolicyBeforeDispatch;
358
+ if (terminalActionRound && !responsePolicy.terminalActionToolNames.has(requestedCall.name)) {
359
+ // The narrowed final-action send is not another discovery opportunity.
360
+ // Do not execute an out-of-policy request or spend the narration reserve
361
+ // trying to repair it. The lane projects this exact stop as
362
+ // provider/dispatch_budget rather than a metadata gap.
363
+ return { text: '', stop: 'provider_dispatch_budget_exhausted', toolCalls };
364
+ }
365
+ if (toolCalls >= effectiveToolBudget && !isTerminalControlCall) {
177
366
  return { text: text || lastText, stop: 'tool_budget_exhausted', toolCalls };
178
367
  }
179
368
  const call = requestedCall;
180
369
  const tool = toolMap.get(call.name);
181
370
  let output;
182
371
  let isError = false;
372
+ let deadlineStop;
183
373
  const startedAt = Date.now();
184
374
  if (!tool) {
185
375
  output = { error: `Unknown tool: ${call.name}. Available: ${tools.map((t) => t.name).join(', ')}` };
@@ -187,19 +377,144 @@ export async function runTextProtocolToolLoopDetailed(provider, baseMessages, to
187
377
  }
188
378
  else {
189
379
  try {
190
- assertMayStartToolCall(options);
380
+ assertMayStartToolCall(options, call.name);
191
381
  output = await tool.run(call.input ?? {});
192
382
  }
193
383
  catch (err) {
194
- output = { error: err instanceof Error ? err.message : String(err) };
384
+ const code = toolLoopErrorCode(err);
385
+ output = {
386
+ error: err instanceof Error ? err.message : String(err),
387
+ ...(code ? { code } : {}),
388
+ };
195
389
  isError = true;
390
+ deadlineStop = providerDispatchTerminal(err);
196
391
  }
197
392
  }
198
393
  toolCalls += 1;
199
- options.onToolCall?.({ name: call.name, input: call.input, output, isError, durationMs: Date.now() - startedAt });
394
+ // Tool-call observers are diagnostics only. In particular, Ask V2 records
395
+ // a terminal `finish_answer` through this callback; an observer bug must
396
+ // never turn an already-authorized execution into a second provider turn
397
+ // (or overwrite it as a planner/budget failure).
398
+ notifyToolCall(options, { name: call.name, input: call.input, output, isError, durationMs: Date.now() - startedAt });
399
+ if (deadlineStop)
400
+ return { ...deadlineStop, text: lastText, toolCalls };
200
401
  messages.push({ role: 'assistant', content: text });
201
402
  messages.push({ role: 'user', content: renderObservation(call.name, output) });
403
+ const progressInstruction = renderCurrentToolPolicy(options, tools);
404
+ if (progressInstruction)
405
+ messages.push({ role: 'system', content: progressInstruction });
406
+ // A *completed* terminal host control carries its final answer or stable
407
+ // clarification in the tool result. Do not spend another provider send
408
+ // to ask the model to repeat it. Crucially, a denied/ineligible terminal
409
+ // proposal does not have this marker: its safe-next-tool observation is
410
+ // fed into the next controller dispatch below.
411
+ if (isTerminalControlCall && !isError && isCompletedAskV2TerminalControlOutput(output)) {
412
+ return { text, stop: 'final', toolCalls };
413
+ }
414
+ // An execution action at the final tool-followup slot must leave the next
415
+ // physical send for `finish_answer`. A failed/ineligible final action has
416
+ // no safe room for a second discovery attempt, so preserve the precise
417
+ // dispatch-budget boundary rather than emitting a misleading coverage
418
+ // terminal. A completed execution loops once more for host narration.
419
+ if (terminalActionRound) {
420
+ const nowRequiresNarration = currentToolPolicy(options, tools).terminalActionToolNames.has('finish_answer');
421
+ if (isError || (isTerminalControlCall && !isCompletedAskV2TerminalControlOutput(output))) {
422
+ return { text, stop: 'provider_dispatch_budget_exhausted', toolCalls };
423
+ }
424
+ if (nowRequiresNarration) {
425
+ // The execution is validated and the next loop iteration emits the
426
+ // sixth, narration-phase physical send with only finish_answer exposed.
427
+ continue;
428
+ }
429
+ if (!reservePostExecutionNarration)
430
+ return { text, stop: 'final', toolCalls };
431
+ return { text, stop: 'provider_dispatch_budget_exhausted', toolCalls };
432
+ }
202
433
  if (toolCalls >= effectiveToolBudget) {
434
+ const policy = currentToolPolicy(options, tools);
435
+ // A bounded Ask controller may reserve the last physical send for one
436
+ // terminal *action* (for example, semantic compilation) rather than
437
+ // prose. This remains model-controlled: the host only narrows the
438
+ // tool set after prior observations make repeated discovery unsafe or
439
+ // wasteful. Other tools cannot use this reserve.
440
+ if (policy.terminalActionToolNames.size > 0) {
441
+ messages.push({
442
+ role: 'user',
443
+ content: `Final controller action turn. Call exactly one of: ${[...policy.terminalActionToolNames].join(', ')}. Do not inspect more context or write a prose answer.`,
444
+ });
445
+ const finalDispatchOptions = providerTurns > 0 && runOptions.dispatchPhase === 'agent_control'
446
+ ? {
447
+ ...runOptions,
448
+ dispatchPhase: policy.terminalActionToolNames.has('finish_answer')
449
+ ? 'narration'
450
+ : 'tool_followup',
451
+ }
452
+ : runOptions;
453
+ providerTurns += 1;
454
+ let finalText;
455
+ try {
456
+ finalText = await provider.generate(messages, finalDispatchOptions);
457
+ }
458
+ catch (error) {
459
+ const terminal = providerDispatchTerminal(error);
460
+ if (terminal)
461
+ return { ...terminal, text: lastText, toolCalls };
462
+ throw error;
463
+ }
464
+ if (!finalText.trim()) {
465
+ return { text: lastText, stop: 'provider_dispatch_budget_exhausted', toolCalls };
466
+ }
467
+ const terminalCall = parseTextToolCall(finalText);
468
+ if (!terminalCall || !policy.terminalActionToolNames.has(terminalCall.name)) {
469
+ return {
470
+ text: '',
471
+ stop: terminalCall ? 'tool_budget_exhausted' : 'provider_dispatch_budget_exhausted',
472
+ toolCalls,
473
+ };
474
+ }
475
+ const terminalTool = toolMap.get(terminalCall.name);
476
+ let terminalOutput;
477
+ let terminalError = false;
478
+ let terminalDeadlineStop;
479
+ const terminalStartedAt = Date.now();
480
+ if (!terminalTool) {
481
+ terminalOutput = { error: `Unknown terminal tool: ${terminalCall.name}` };
482
+ terminalError = true;
483
+ }
484
+ else {
485
+ try {
486
+ assertMayStartToolCall(options, terminalCall.name);
487
+ terminalOutput = await terminalTool.run(terminalCall.input ?? {});
488
+ }
489
+ catch (err) {
490
+ const code = toolLoopErrorCode(err);
491
+ terminalOutput = {
492
+ error: err instanceof Error ? err.message : String(err),
493
+ ...(code ? { code } : {}),
494
+ };
495
+ terminalError = true;
496
+ terminalDeadlineStop = providerDispatchTerminal(err);
497
+ }
498
+ }
499
+ toolCalls += 1;
500
+ notifyToolCall(options, {
501
+ name: terminalCall.name,
502
+ input: terminalCall.input,
503
+ output: terminalOutput,
504
+ isError: terminalError,
505
+ durationMs: Date.now() - terminalStartedAt,
506
+ });
507
+ if (terminalDeadlineStop)
508
+ return { ...terminalDeadlineStop, text: lastText, toolCalls };
509
+ return {
510
+ text: finalText,
511
+ stop: terminalError || (isAskV2TerminalControlTool(terminalCall.name)
512
+ && !isCompletedAskV2TerminalControlOutput(terminalOutput))
513
+ ? 'tool_budget_exhausted'
514
+ : 'final',
515
+ toolCalls,
516
+ };
517
+ }
203
518
  messages.push({
204
519
  role: 'user',
205
520
  content: 'Tool budget reached — do not call any more tools. Answer now using only the tool results above, as a single ```json fenced object with summary, sql, viz, outputs.',
@@ -207,14 +522,26 @@ export async function runTextProtocolToolLoopDetailed(provider, baseMessages, to
207
522
  // The next iteration is the reserved final dispatch. If the model
208
523
  // nevertheless emits a tool shape, `call` is deliberately disabled and
209
524
  // the caller receives that text as a typed terminal, not an execution.
210
- const finalText = await provider.generate(messages, runOptions).catch((error) => {
211
- const code = error && typeof error === 'object' && 'code' in error
212
- ? error.code
213
- : undefined;
214
- if (code === 'PROVIDER_DISPATCH_BUDGET_EXHAUSTED')
215
- return '';
525
+ const finalPolicy = currentToolPolicy(options, tools);
526
+ const finalDispatchOptions = providerTurns > 0 && runOptions.dispatchPhase === 'agent_control'
527
+ ? {
528
+ ...runOptions,
529
+ dispatchPhase: finalPolicy.terminalActionToolNames.has('finish_answer')
530
+ ? 'narration'
531
+ : 'tool_followup',
532
+ }
533
+ : runOptions;
534
+ providerTurns += 1;
535
+ let finalText;
536
+ try {
537
+ finalText = await provider.generate(messages, finalDispatchOptions);
538
+ }
539
+ catch (error) {
540
+ const terminal = providerDispatchTerminal(error);
541
+ if (terminal)
542
+ return { ...terminal, text: lastText, toolCalls };
216
543
  throw error;
217
- });
544
+ }
218
545
  if (!finalText.trim()) {
219
546
  return {
220
547
  text: lastText,
@@ -222,21 +549,103 @@ export async function runTextProtocolToolLoopDetailed(provider, baseMessages, to
222
549
  toolCalls,
223
550
  };
224
551
  }
552
+ const terminalCall = parseTextToolCall(finalText);
553
+ // The reserved composition dispatch may legally be an Ask V2 terminal
554
+ // control. Execute it locally only when it reports a completed terminal
555
+ // outcome. A premature/denied finish or clarification has consumed the
556
+ // final physical send, so preserve the precise budget stop rather than
557
+ // pretending that it produced a final answer.
558
+ if (terminalCall && isAskV2TerminalControlTool(terminalCall.name)) {
559
+ const terminalTool = toolMap.get(terminalCall.name);
560
+ let terminalOutput;
561
+ let terminalError = false;
562
+ let terminalDeadlineStop;
563
+ const terminalStartedAt = Date.now();
564
+ if (!terminalTool) {
565
+ terminalOutput = { error: 'Unknown tool: finish_answer' };
566
+ terminalError = true;
567
+ }
568
+ else {
569
+ try {
570
+ assertMayStartToolCall(options, terminalCall.name);
571
+ terminalOutput = await terminalTool.run(terminalCall.input ?? {});
572
+ }
573
+ catch (err) {
574
+ const code = toolLoopErrorCode(err);
575
+ terminalOutput = {
576
+ error: err instanceof Error ? err.message : String(err),
577
+ ...(code ? { code } : {}),
578
+ };
579
+ terminalError = true;
580
+ terminalDeadlineStop = providerDispatchTerminal(err);
581
+ }
582
+ }
583
+ toolCalls += 1;
584
+ notifyToolCall(options, {
585
+ name: terminalCall.name,
586
+ input: terminalCall.input,
587
+ output: terminalOutput,
588
+ isError: terminalError,
589
+ durationMs: Date.now() - terminalStartedAt,
590
+ });
591
+ if (terminalDeadlineStop)
592
+ return { ...terminalDeadlineStop, text: lastText, toolCalls };
593
+ return {
594
+ text: finalText,
595
+ stop: terminalError || !isCompletedAskV2TerminalControlOutput(terminalOutput)
596
+ ? 'tool_budget_exhausted'
597
+ : 'final',
598
+ toolCalls,
599
+ };
600
+ }
225
601
  return {
226
602
  text: finalText,
227
- stop: parseTextToolCall(finalText) ? 'tool_budget_exhausted' : 'final',
603
+ stop: terminalCall ? 'tool_budget_exhausted' : 'final',
228
604
  toolCalls,
229
605
  };
230
606
  }
231
607
  }
232
608
  }
233
- function assertMayStartToolCall(options) {
609
+ /**
610
+ * Only explicit Ask V2 controls can terminate a transport early. Execution
611
+ * and retrieval tools may return useful `{ finished: true }`-shaped payloads
612
+ * for their own protocols, but they do not own final answer authority.
613
+ */
614
+ function isAskV2TerminalControlTool(name) {
615
+ return name === 'finish_answer' || name === 'request_clarification';
616
+ }
617
+ function isCompletedAskV2TerminalControlOutput(value) {
618
+ return Boolean(value
619
+ && typeof value === 'object'
620
+ && !Array.isArray(value)
621
+ && value.finished === true);
622
+ }
623
+ function assertMayStartToolCall(options, toolName) {
624
+ // finish_answer is a host-local terminal control following an already
625
+ // admitted provider response. It cannot start discovery or a warehouse
626
+ // operation, so the final control itself may consume the narration reserve.
627
+ if (toolName === 'finish_answer')
628
+ return;
234
629
  if (options.mayStartToolCall?.() === false) {
235
630
  throw Object.assign(new Error('The run soft target elapsed before this tool branch could start.'), {
236
631
  code: 'RUN_SOFT_TARGET_EXCEEDED',
237
632
  });
238
633
  }
239
634
  }
635
+ /**
636
+ * Observability must be fail-open with respect to the bounded tool runtime.
637
+ * Provider/tool callbacks are outside the execution authority and cannot be
638
+ * allowed to reopen a finished response or alter its terminal result.
639
+ */
640
+ function notifyToolCall(options, event) {
641
+ try {
642
+ options.onToolCall?.(event);
643
+ }
644
+ catch {
645
+ // Receipt recording has its own error handling at the host boundary. The
646
+ // transport still has a valid, typed tool outcome to return to the caller.
647
+ }
648
+ }
240
649
  /**
241
650
  * Parse a text-protocol tool call. A tool call is a JSON object with a `tool`
242
651
  * (string) field, optionally in a ```json fence. Anything else — prose, or a JSON