@cubicecho/agent-core 2.14.0 → 2.15.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/README.md CHANGED
@@ -370,10 +370,17 @@ const { turn, messages, usage, loaded } = await runAgentLoop({
370
370
 
371
371
  Each step is one `runTurn` with the body from `buildBody`, so everything `negotiate` answers is
372
372
  answered here too, and a request that is too big throws `ContextOverflow` whichever side found
373
- out. Between steps the loop runs the calls: sequentially by default, or together with
374
- `parallel: true`, which also makes an identical call — the same name and arguments, byte for byte
375
- once for the run. A call that threw is forgotten rather than cached, so asking again is a real
376
- retry. What a tool throws is what the model reads, and so are arguments that did not parse.
373
+ out. Between steps the loop runs the calls: sequentially by default, or together with `parallel: true`.
374
+ Either way an identical call — the same name and arguments, byte for byte — is made once and its
375
+ answer handed to the repeat, and two still in flight share the request. A call that threw is
376
+ forgotten rather than cached, so asking again is a real retry. What a tool throws is what the model
377
+ reads, and so are arguments that did not parse.
378
+
379
+ The scope is the step, not the run: between steps other tools have run, and the file the model read
380
+ may be the file it has since written. `dedupeToolCalls: false` dispatches everything, and a
381
+ predicate is asked per call — which is how `send_email` opts out, since twice is two emails and
382
+ nothing in an OpenAI tool definition says which tools those are. A pool that reads the MCP
383
+ `readOnlyHint` and `idempotentHint` annotations can answer it; this package cannot.
377
384
 
378
385
  Arguments go through `parseToolArguments`, which is lenient where the model's meaning is plain:
379
386
  JSON held in a string is opened, and the almost-JSON local models write — single quotes, Python's
@@ -382,7 +389,7 @@ string. What still is not an object throws a `ToolArgumentsError` whose `kind` i
382
389
  the turn stopped at the ceiling, with a message telling the model so, and `malformed` otherwise.
383
390
  The repaired JSON is what the transcript keeps, and an unreadable call is replayed as `{}`, because
384
391
  a server that parses replayed arguments refuses the originals on every later request. `dispatch`
385
- is still handed the model's own text as `raw`, and the parallel dedupe compares repaired arguments,
392
+ is still handed the model's own text as `raw`, and the dedupe compares repaired arguments,
386
393
  so `{'a': 1}` and `{"a": 1}` are one call.
387
394
 
388
395
  A server whose tool-call parser was written for another template streams the model's call as
@@ -152,12 +152,26 @@ export interface AgentLoopOptions {
152
152
  /** Runs one tool call and returns what the model reads. What it throws, the model reads too. */
153
153
  dispatch: (call: ToolCallRequest, signal?: AbortSignal) => Promise<string>;
154
154
  /**
155
- * Runs a step's calls together rather than one after another, and makes an identical call —
156
- * the same name and arguments, word for word once for the run, handing a repeat the first
157
- * answer. A call that threw is not an answer and is made again. Results still go into the
158
- * transcript in the order the model asked.
155
+ * Runs a step's calls together rather than one after another. Results still go into the
156
+ * transcript in the order the model asked. See `dedupeToolCalls`, which applies either way.
159
157
  */
160
158
  parallel?: boolean;
159
+ /**
160
+ * Answers an identical repeat of a call — the same name and the same arguments, word for word —
161
+ * within one step from the first one, rather than dispatching it again.
162
+ *
163
+ * On by default, and on whether or not the calls run in `parallel`: a model that asks the same
164
+ * question twice in one reply gets one answer, and two that are still in flight share the
165
+ * request. A call that threw is not an answer and is made again. The scope is the step and not
166
+ * the run, because between steps other tools have run and the file the model read may be the
167
+ * file it has since written.
168
+ *
169
+ * `false` dispatches every call. A predicate is asked per call and is how a tool that does
170
+ * something rather than reads something opts out — `send_email` twice is two emails, and this
171
+ * package cannot tell which tools those are. A pool that reads the MCP `readOnlyHint` and
172
+ * `idempotentHint` annotations can answer it; nothing in an OpenAI tool definition can.
173
+ */
174
+ dedupeToolCalls?: boolean | ((call: ToolCallRequest) => boolean);
161
175
  /** Hooks gathered onto the question before the first request, and told the reply after. */
162
176
  hooks?: AgentLoopHooks;
163
177
  /**
@@ -202,7 +202,8 @@ function cacheDiagnosis(previous, messages, tools, usage) {
202
202
  */
203
203
  export async function runAgentLoop(options) {
204
204
  const { config, system = "", tools = [], catalog = [], dispatch, hooks, signal } = options;
205
- const { onTurn, beforeStep, parallel = false, recoverToolCalls: recover = true, maxContinuations = 0, toolOrder = true, } = options;
205
+ const { onTurn, beforeStep, parallel = false, recoverToolCalls: recover = true, maxContinuations = 0, toolOrder = true, dedupeToolCalls = true, } = options;
206
+ const dedupable = typeof dedupeToolCalls === "function" ? dedupeToolCalls : () => dedupeToolCalls;
206
207
  const started = Date.now();
207
208
  // What the loop emitted, less the token deltas, for `runMetrics` at the end. Stamped here rather
208
209
  // than by the bus, which the loop does not know about.
@@ -259,7 +260,6 @@ export async function runAgentLoop(options) {
259
260
  : { context: "", notes: [] };
260
261
  const usage = { prompt: 0, completion: 0, total: 0, cached: 0 };
261
262
  const toolCalls = [];
262
- const answered = new Map();
263
263
  const loads = { toolsLoaded: 0, redundantLoads: 0, unknownToolNames: 0 };
264
264
  let previous;
265
265
  for (let step = 0; step < config.maxToolIterations; step++) {
@@ -411,6 +411,9 @@ export async function runAgentLoop(options) {
411
411
  },
412
412
  };
413
413
  }
414
+ // Per step, not per run: the answer to a call made two steps ago was true before the tools in
415
+ // between ran, and the file the model read may be the file it has since written.
416
+ const answered = new Map();
414
417
  const run = async ({ call, args, error: unreadable, normal }) => {
415
418
  const { name, arguments: raw } = call.function;
416
419
  onEvent({ kind: "tool-call", name, text: preview(raw) });
@@ -436,7 +439,7 @@ export async function runAgentLoop(options) {
436
439
  loaded.add(name);
437
440
  used.add(name);
438
441
  const request = { id: call.id, name, args, raw };
439
- content = parallel
442
+ content = dedupable(request)
440
443
  ? await once(answered, `${name}\0${normal}`, () => dispatch(request, signal))
441
444
  : await dispatch(request, signal);
442
445
  }
@@ -470,8 +473,8 @@ export async function runAgentLoop(options) {
470
473
  }
471
474
  /**
472
475
  * Makes a call at most once per key, sharing the in-flight promise so two identical calls in one
473
- * step make one request between them. A call that rejected is forgotten, so asking again is a
474
- * real retry rather than a replayed failure.
476
+ * step make one request between them whether they run together or one after the other. A call
477
+ * that rejected is forgotten, so asking again is a real retry rather than a replayed failure.
475
478
  */
476
479
  async function once(answered, key, make) {
477
480
  const previous = answered.get(key);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "2.14.0",
3
+ "version": "2.15.0",
4
4
  "description": "The endpoint-agnostic half of an OpenAI-compatible agent loop: tool-schema compatibility, on-demand tool loading, one-shot side tasks, run events, and a pooled client.",
5
5
  "keywords": [
6
6
  "openai",