@cubicecho/agent-core 4.10.0 → 4.11.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
@@ -532,6 +532,41 @@ its calls made on the error too, read off with `failedRun`.
532
532
  A link meant for the model alone — one whose MCP `audience` leaves out `"user"` — is the
533
533
  dispatcher's to drop, since that is MCP's rule and this package knows nothing of MCP.
534
534
 
535
+ ### A call that ends the run
536
+
537
+ A tool whose call is the end of the job — handing off, asking a person — says so with `end` on
538
+ its `ToolAnswer`, `true` or `{ reason }`. The loop answers every call of that step, sends no
539
+ further request, and resolves with `ended`; aborting the run's signal from inside `dispatch` was
540
+ the way out before, and that is a failure the host then has to dig the run back out of.
541
+
542
+ ```ts
543
+ const result = await runAgentLoop({
544
+ // ...
545
+ dispatch: async ({ name, args }, signal) =>
546
+ name === "ask_person"
547
+ ? { text: "Asked. This run ends here.", end: { reason: String(args.question) } }
548
+ : pool.call(name, args, { signal }),
549
+ });
550
+ if (result.ended) {
551
+ // { callId, name: "ask_person", reason }
552
+ const { callId, name, reason = "" } = result.ended;
553
+ emit(runId, { kind: RunEventKind.Done, ok: true, id: callId, name, text: reason });
554
+ }
555
+ ```
556
+
557
+ The step's other calls still run, whichever side of the end the model asked for them and however
558
+ `parallel` batches them: they were asked for in the same turn, and a model that says "write the
559
+ file, then hand off" wants the file written in either order. Where two calls end it, `ended` is
560
+ the first in the order asked. Everything else is a finished run's — `usage`, `metrics`,
561
+ `messages`, `artifacts`, the `afterTurn` hooks — with `metrics.outcome` `ended` and a notice
562
+ saying which tool ended it. `turn` is the turn that made the call, so its `content` is whatever
563
+ the model wrote beside its calls, often nothing; the run's answer is what the ending tool took.
564
+ The tool, its definition and its wording stay the host's: this is only the exit.
565
+
566
+ A host's `done` that carries the call — `id`, `name` the tool, `text` the reason — is read by
567
+ `runMetrics` as `ended` too, so the outcome is the same whether it is read off the result or the
568
+ events.
569
+
535
570
  Arguments go through `parseToolArguments`, which is lenient where the model's meaning is plain:
536
571
  JSON held in a string is opened, and the almost-JSON local models write — single quotes, Python's
537
572
  `True` and `None`, bare keys, a trailing comma — is repaired, without touching what is inside a
package/dist/index.d.ts CHANGED
@@ -39,5 +39,5 @@ export { backoffMs, ContextOverflow, EndpointSilent, isModelLoading, isOverflow,
39
39
  export { CacheBreakReason, type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, } from './wire/stream.ts';
40
40
  export { ALL_FENCES, DEFAULT_FENCES, type Fence, FenceSplitter, type FenceSplitterOptions, SPLIT_OUTPUT, SPLIT_REASONING, type Split, stripThinking, THINK_FENCE, } from './wire/thinking.ts';
41
41
  export { type ContextBreakdown, type ContextBreakdownOptions, compact, contextChars, contextTokens, estimateTokens, messageTokens, requestChars, requestTokens, type TokenEstimateOptions, } from './wire/tokens.ts';
42
- export { ARGUMENTS_MALFORMED, ARGUMENTS_TRUNCATED, parseToolArguments, type ResourceLink, type RunArtifact, recoverToolCalls, type ServerLogEntry, ServerLogLevel, type ToolAnswer, ToolArgumentsError, type ToolCall, type ToolCallOutcome, type ToolCallReport, type ToolCallRequest, type ToolCallResult, } from './wire/tool-calls.ts';
42
+ export { ARGUMENTS_MALFORMED, ARGUMENTS_TRUNCATED, type EndedBy, parseToolArguments, type ResourceLink, type RunArtifact, recoverToolCalls, type ServerLogEntry, ServerLogLevel, type ToolAnswer, ToolArgumentsError, type ToolCall, type ToolCallOutcome, type ToolCallReport, type ToolCallRequest, type ToolCallResult, } from './wire/tool-calls.ts';
43
43
  export { FinishReason, FUNCTION_TOOL, JSON_SCHEMA_FORMAT, PartType, Role, SchemaType } from './wire/wire.ts';
@@ -10,7 +10,7 @@ import { type KeywordPreselectOptions } from '../tools/preselect.ts';
10
10
  import { type ToolOrder } from '../tools/tool-loading.ts';
11
11
  import { type SideTask } from '../turn/side-task.ts';
12
12
  import { type Turn, type TurnUsage } from '../wire/stream.ts';
13
- import { type RunArtifact, type ToolCallOutcome, type ToolCallRequest, type ToolCallResult } from '../wire/tool-calls.ts';
13
+ import { type EndedBy, type RunArtifact, type ToolCallOutcome, type ToolCallRequest, type ToolCallResult } from '../wire/tool-calls.ts';
14
14
  import { type RunEventInput, type RunMetrics } from './events.ts';
15
15
  import { type ToolDispatch } from './run-calls.ts';
16
16
  /**
@@ -243,7 +243,8 @@ export interface AgentLoopOptions {
243
243
  * @remarks
244
244
  * A `ToolAnswer` in place of the string adds what the call produced: its `text` is the whole of
245
245
  * what goes into the transcript, and its `links` become the result's `artifacts`, an `artifact`
246
- * event the first time each uri is seen, and `links` on what `onToolResult` is handed.
246
+ * event the first time each uri is seen, and `links` on what `onToolResult` is handed. Its `end`
247
+ * ends the run once the step's calls are answered; see `AgentLoopResult.ended`.
247
248
  */
248
249
  dispatch: ToolDispatch;
249
250
  /**
@@ -395,8 +396,29 @@ export interface AgentLoopOptions {
395
396
  }
396
397
  /** What a finished loop hands back. */
397
398
  export interface AgentLoopResult {
398
- /** The last turn: the one that asked for no tools, as `onTurn` was handed it. */
399
+ /**
400
+ * The last turn, as `onTurn` was handed it: the one that asked for no tools, or — where a call
401
+ * `ended` the run — the one that made that call.
402
+ *
403
+ * @remarks
404
+ * Ended by a call, its `content` is what the model wrote beside its calls, which is often
405
+ * nothing, and its `toolCalls` are not empty. The run's answer is then whatever the ending tool
406
+ * took — the question it asked, the work it handed off — and not this text.
407
+ */
399
408
  turn: Turn;
409
+ /**
410
+ * The call whose answer said `end`, where one did: the first in the order the model asked, once
411
+ * every call of its step was answered. Absent where the run ended on a turn that asked for no
412
+ * tools.
413
+ *
414
+ * @remarks
415
+ * Every other field is as a finished run has it: `messages` close on the step's tool results
416
+ * with no assistant turn after them, the hooks are told `afterTurn` with `turn.content`, and
417
+ * `metrics.outcome` is `ended`. No `beforeStep` or `compact` runs after the end, since no request
418
+ * follows it. A host saying `done` passes the call on it — `id` the call, `name` the tool, `text`
419
+ * the reason — which is how `runMetrics` reads the same outcome off the events.
420
+ */
421
+ ended?: EndedBy;
400
422
  /** The transcript, with every assistant turn and tool result the run added. No system prompt. */
401
423
  messages: OpenAI.ChatCompletionMessageParam[];
402
424
  /** Summed over every turn of the run. */
@@ -463,14 +485,16 @@ export interface AgentLoopResult {
463
485
  }
464
486
  /**
465
487
  * Runs a question to its answer: one `runTurn` per step, the tools it asks for between them,
466
- * until a turn asks for none. Throws when `maxToolIterations` is spent, when stopped, and on
467
- * whatever `runTurn` throws — `ContextOverflow` among them, however it was found out.
488
+ * until a turn asks for none or a call's answer ends the run. Throws when `maxToolIterations` is
489
+ * spent, when stopped, and on whatever `runTurn` throws — `ContextOverflow` among them, however it
490
+ * was found out.
468
491
  *
469
492
  * @param options - The config, transcript, tools and dispatcher, plus the optional hooks, events
470
493
  * and cancellation. See `AgentLoopOptions`.
471
- * @returns The turn that asked for no tools and the run around it: the transcript with what the
472
- * run added, usage, every call, what is loaded and used, the hooks' notes and context, metrics
473
- * and the ledger. See `AgentLoopResult`.
494
+ * @returns The turn that asked for no tools — or made the call that ended the run, with `ended`
495
+ * naming it — and the run around it: the transcript with what the run added, usage, every call,
496
+ * what is loaded and used, the hooks' notes and context, metrics and the ledger. See
497
+ * `AgentLoopResult`.
474
498
  *
475
499
  * @remarks
476
500
  * What it throws carries the run as it stood — `messages`, `usage`, `toolCalls`, `loaded`, `used`
@@ -482,6 +506,11 @@ export interface AgentLoopResult {
482
506
  * well-formed: the results that came back are kept, and each call without one is answered with a
483
507
  * line saying it was stopped or never run, since a call with no result is refused on replay.
484
508
  *
509
+ * A tool that finishes the job — a hand-off, a question for a person — ends the run with a
510
+ * `ToolAnswer` whose `end` is set, rather than by aborting it: the step's calls are all run and
511
+ * answered, no further request is sent, and the loop resolves with `ended`. An end in the last step
512
+ * `maxToolIterations` allows resolves too, since nothing was left undone.
513
+ *
485
514
  * On-demand loading is handled here, `load_tools` and all: the catalogue rides on the system
486
515
  * prompt unchanged from step to step, a load adds to the tool array — which every request sends
487
516
  * in the stable order `toolOrder` asks for — and
@@ -63,14 +63,16 @@ function cacheDiagnosis(previous, next, usage) {
63
63
  }
64
64
  /**
65
65
  * Runs a question to its answer: one `runTurn` per step, the tools it asks for between them,
66
- * until a turn asks for none. Throws when `maxToolIterations` is spent, when stopped, and on
67
- * whatever `runTurn` throws — `ContextOverflow` among them, however it was found out.
66
+ * until a turn asks for none or a call's answer ends the run. Throws when `maxToolIterations` is
67
+ * spent, when stopped, and on whatever `runTurn` throws — `ContextOverflow` among them, however it
68
+ * was found out.
68
69
  *
69
70
  * @param options - The config, transcript, tools and dispatcher, plus the optional hooks, events
70
71
  * and cancellation. See `AgentLoopOptions`.
71
- * @returns The turn that asked for no tools and the run around it: the transcript with what the
72
- * run added, usage, every call, what is loaded and used, the hooks' notes and context, metrics
73
- * and the ledger. See `AgentLoopResult`.
72
+ * @returns The turn that asked for no tools — or made the call that ended the run, with `ended`
73
+ * naming it — and the run around it: the transcript with what the run added, usage, every call,
74
+ * what is loaded and used, the hooks' notes and context, metrics and the ledger. See
75
+ * `AgentLoopResult`.
74
76
  *
75
77
  * @remarks
76
78
  * What it throws carries the run as it stood — `messages`, `usage`, `toolCalls`, `loaded`, `used`
@@ -82,6 +84,11 @@ function cacheDiagnosis(previous, next, usage) {
82
84
  * well-formed: the results that came back are kept, and each call without one is answered with a
83
85
  * line saying it was stopped or never run, since a call with no result is refused on replay.
84
86
  *
87
+ * A tool that finishes the job — a hand-off, a question for a person — ends the run with a
88
+ * `ToolAnswer` whose `end` is set, rather than by aborting it: the step's calls are all run and
89
+ * answered, no further request is sent, and the loop resolves with `ended`. An end in the last step
90
+ * `maxToolIterations` allows resolves too, since nothing was left undone.
91
+ *
85
92
  * On-demand loading is handled here, `load_tools` and all: the catalogue rides on the system
86
93
  * prompt unchanged from step to step, a load adds to the tool array — which every request sends
87
94
  * in the stable order `toolOrder` asks for — and
@@ -471,8 +478,8 @@ function turnCalls(turn, recoverable, notice) {
471
478
  * @param standing - Written to: once the steps have set themselves up, its `read` is theirs, so the
472
479
  * caller can read the run as it stands whether they return or throw.
473
480
  * @param [pin] - What the run's session adds to each request. Absent adds nothing.
474
- * @returns The result of the turn that asked for no tools, or `undefined` when `maxToolIterations`
475
- * is spent.
481
+ * @returns The result of the turn that asked for no tools or whose calls ended the run, or
482
+ * `undefined` when `maxToolIterations` is spent.
476
483
  */
477
484
  async function runSteps(options, standing, pin) {
478
485
  const { config, system = '', tools = [], catalog = [], dispatch, hooks, signal } = options;
@@ -706,13 +713,27 @@ async function runSteps(options, standing, pin) {
706
713
  const assistant = assistantMessage(content, parsed);
707
714
  messages.push(assistant);
708
715
  await announce(assistant, step, shown);
709
- if (!calls.length) {
716
+ const isAnswer = calls.length === 0;
717
+ const ended = isAnswer ? undefined : await runCalls(calling, parsed, messages, step);
718
+ if (ended) {
719
+ const by = ended.reason ? `: ${ended.reason}` : '';
720
+ notice(`${ended.name} ended the run${by}`);
721
+ }
722
+ if (isAnswer || ended) {
710
723
  // Not awaited: the answer is ready, and remembering it is not something to hold it for.
711
724
  // Handed back instead, for a host that wants its notes.
712
725
  const afterTurn = hooks ? tellAfterTurn(hooks, messages, questionAt(), turn.content) : Promise.resolve([]);
713
726
  const metrics = runMetrics(recorded, { contextLength: config.contextLength });
727
+ let outcome = RunOutcome.Answered;
728
+ if (ended) {
729
+ outcome = RunOutcome.Ended;
730
+ }
731
+ else if (turn.finishReason === FinishReason.Length) {
732
+ outcome = RunOutcome.Truncated;
733
+ }
714
734
  return {
715
735
  turn: shown,
736
+ ...(ended ? { ended } : {}),
716
737
  messages,
717
738
  usage,
718
739
  toolCalls,
@@ -728,12 +749,11 @@ async function runSteps(options, standing, pin) {
728
749
  ...metrics,
729
750
  ...(onDemand ? loads : {}),
730
751
  wallMs: Date.now() - started,
731
- outcome: turn.finishReason === FinishReason.Length ? RunOutcome.Truncated : RunOutcome.Answered,
752
+ outcome,
732
753
  },
733
754
  ledger,
734
755
  };
735
756
  }
736
- await runCalls(calling, parsed, messages, step);
737
757
  }
738
758
  return undefined;
739
759
  }
@@ -109,7 +109,10 @@ export declare const RunEventKind: {
109
109
  * measured either way.
110
110
  */
111
111
  readonly Usage: 'usage';
112
- /** The run ended. Always last, and always sent. */
112
+ /**
113
+ * The run ended. Always last, and always sent. A run a tool call ended carries that call: `id`
114
+ * the call's, `name` the tool, and `text` the reason, as `AgentLoopResult.ended` has them.
115
+ */
113
116
  readonly Done: 'done';
114
117
  };
115
118
  /** Any one of the kinds in `RunEventKind`. */
@@ -171,7 +174,7 @@ export interface RunEvent {
171
174
  kind: RunEventKind;
172
175
  /** The delta, the arguments, the result, or the reason — whatever the kind carries. */
173
176
  text: string;
174
- /** Tool name on the tool kinds, otherwise empty. */
177
+ /** Tool name on the tool kinds, and on a `done` the tool that ended the run; otherwise empty. */
175
178
  name: string;
176
179
  /**
177
180
  * The call's id on the tool kinds, which pairs a `tool-result` with its `tool-call` when one
@@ -390,6 +393,11 @@ export declare const RunOutcome: {
390
393
  readonly Answered: 'answered';
391
394
  /** The run did not fail, and its last turn is an answer the reply ceiling cut off. */
392
395
  readonly Truncated: 'truncated';
396
+ /**
397
+ * The run did not fail, and a tool call ended it: `AgentLoopResult.ended` says which, and so
398
+ * does a `done` that names the tool.
399
+ */
400
+ readonly Ended: 'ended';
393
401
  /**
394
402
  * `done` said the run was not ok. Whether that was an error, a stop or the tool budget is in
395
403
  * the host's own `done` text and not here.
@@ -76,7 +76,10 @@ export const RunEventKind = {
76
76
  * measured either way.
77
77
  */
78
78
  Usage: 'usage',
79
- /** The run ended. Always last, and always sent. */
79
+ /**
80
+ * The run ended. Always last, and always sent. A run a tool call ended carries that call: `id`
81
+ * the call's, `name` the tool, and `text` the reason, as `AgentLoopResult.ended` has them.
82
+ */
80
83
  Done: 'done',
81
84
  };
82
85
  /**
@@ -471,6 +474,11 @@ export const RunOutcome = {
471
474
  Answered: 'answered',
472
475
  /** The run did not fail, and its last turn is an answer the reply ceiling cut off. */
473
476
  Truncated: 'truncated',
477
+ /**
478
+ * The run did not fail, and a tool call ended it: `AgentLoopResult.ended` says which, and so
479
+ * does a `done` that names the tool.
480
+ */
481
+ Ended: 'ended',
474
482
  /**
475
483
  * `done` said the run was not ok. Whether that was an error, a stop or the tool budget is in
476
484
  * the host's own `done` text and not here.
@@ -480,15 +488,21 @@ export const RunOutcome = {
480
488
  /**
481
489
  * How a run that reported `done` ended.
482
490
  *
483
- * @param ok - What the `done` event said. Only `false` is a failure; anything else is read off the turn.
491
+ * @param done - The `done` event. Only an `ok` of `false` is a failure; one that names a tool was
492
+ * ended by it; anything else is read off the turn.
484
493
  * @param last - The last turn a `usage` event reported, if any did.
485
- * @returns `failed` for a `done` that was not ok, `truncated` where the last turn ran into the
486
- * reply ceiling, and `answered` otherwise.
494
+ * @returns `failed` for a `done` that was not ok, `ended` for one that names the tool that ended
495
+ * the run, `truncated` where the last turn ran into the reply ceiling, and `answered` otherwise.
487
496
  */
488
- function outcomeOf(ok, last) {
497
+ function outcomeOf({ ok, name }, last) {
489
498
  if (ok === false) {
490
499
  return RunOutcome.Failed;
491
500
  }
501
+ // Read as a truthy string, so an event put together by hand without one is not ended.
502
+ const isEnded = Boolean(name);
503
+ if (isEnded) {
504
+ return RunOutcome.Ended;
505
+ }
492
506
  if (last?.finishReason === FinishReason.Length) {
493
507
  return RunOutcome.Truncated;
494
508
  }
@@ -614,7 +628,7 @@ export function runMetrics(events, { contextLength } = {}) {
614
628
  }
615
629
  }
616
630
  else if (event.kind === RunEventKind.Done) {
617
- metrics.outcome = outcomeOf(event.ok, last);
631
+ metrics.outcome = outcomeOf(event, last);
618
632
  }
619
633
  }
620
634
  if (events.length > 1) {
@@ -1,7 +1,7 @@
1
1
  import type OpenAI from 'openai';
2
2
  import type { CatalogServer } from '../tools/catalog.ts';
3
3
  import type { Turn } from '../wire/stream.ts';
4
- import { type RunArtifact, type ToolAnswer, type ToolCall, type ToolCallOutcome, type ToolCallReport, type ToolCallRequest, type ToolCallResult } from '../wire/tool-calls.ts';
4
+ import { type EndedBy, type RunArtifact, type ToolAnswer, type ToolCall, type ToolCallOutcome, type ToolCallReport, type ToolCallRequest, type ToolCallResult } from '../wire/tool-calls.ts';
5
5
  import type { RunEventInput } from './events.ts';
6
6
  /**
7
7
  * The tools between two turns: a step's calls read, run and written into the transcript.
@@ -114,13 +114,21 @@ export interface Calling {
114
114
  * @param parsed - The step's calls, with their arguments read.
115
115
  * @param messages - The transcript the results are written into.
116
116
  * @param step - Which step this is, for the host told of each result.
117
+ * @returns The first call, in the order asked, whose answer ended the run, or `undefined` where
118
+ * none did.
117
119
  *
118
120
  * @remarks
119
121
  * A failure part-way leaves the transcript well-formed before it is rethrown: a call with no
120
122
  * result is a transcript no endpoint takes back, so every call of the step is answered — with what
121
123
  * it returned where it had, and otherwise with a line saying it was stopped, or never run.
124
+ *
125
+ * An answer that ends the run does not end the step. The calls after it still run, in whatever
126
+ * batch `parallel` puts them, because the model asked for them in the same breath and how a host
127
+ * schedules calls is not what they mean: a reply that says "write the file, then hand off" in one
128
+ * turn wants the file written whichever order it named the two in, and a call batched beside the
129
+ * end was running already. A tool that must have nothing after it says so in its description.
122
130
  */
123
- export declare function runCalls(run: Calling, parsed: ReadCall[], messages: OpenAI.ChatCompletionMessageParam[], step: number): Promise<void>;
131
+ export declare function runCalls(run: Calling, parsed: ReadCall[], messages: OpenAI.ChatCompletionMessageParam[], step: number): Promise<EndedBy | undefined>;
124
132
  /**
125
133
  * Writes a proxied preselection into the transcript as a `load_tools` exchange: the tool array is
126
134
  * fixed, so a shortlist has nowhere else to go, and it is answered as though the model had loaded
@@ -128,9 +128,19 @@ function reportFor(run, id, name) {
128
128
  * A dispatcher's answer in the one shape, whichever it gave.
129
129
  *
130
130
  * @param answer - What `dispatch` resolved to.
131
- * @returns The text and the links, a string answer's being none.
131
+ * @returns The text, the links and whether it ends the run, a string answer having no links and
132
+ * ending nothing.
132
133
  */
133
- const answerOf = (answer) => typeof answer === 'string' ? { text: answer, links: [] } : { text: answer.text, links: answer.links ?? [] };
134
+ function answerOf(answer) {
135
+ if (typeof answer === 'string') {
136
+ return { text: answer, links: [], end: false };
137
+ }
138
+ const { text, links = [], end = false } = answer;
139
+ if (typeof end === 'object') {
140
+ return { text, links, end: { reason: end.reason } };
141
+ }
142
+ return { text, links, end: end ? {} : false };
143
+ }
134
144
  /**
135
145
  * What a call is shown, counted and dispatched as: a `call_tool` as the tool it names.
136
146
  *
@@ -322,9 +332,9 @@ function batchEnd(run, ready, from, total, answered) {
322
332
  * @param prepared - The call, with its request built.
323
333
  * @param answered - The step's calls already made, by tool and arguments, for `dedupeToolCalls`.
324
334
  * @param step - Which step this is, for the artifacts the call's links become.
325
- * @returns The call's id, the name it was shown under, whether it worked, the whole of what the
326
- * model reads, and the links its answer carried, where it carried any. Rejects where the run was
327
- * stopped or a host callback threw.
335
+ * @returns The result — the call's id, the name it was shown under, whether it worked, the whole
336
+ * of what the model reads, and the links its answer carried, where it carried any — and whether
337
+ * the answer ended the run. Rejects where the run was stopped or a host callback threw.
328
338
  */
329
339
  async function runCall(run, prepared, answered, step) {
330
340
  const { catalog, onDemand, proxied, signal } = run;
@@ -336,6 +346,7 @@ async function runCall(run, prepared, answered, step) {
336
346
  const { report, close } = reportFor(run, call.id, name);
337
347
  let content;
338
348
  let links = [];
349
+ let end = false;
339
350
  let ok = true;
340
351
  // Answered from an earlier identical call of the step, whose links are already counted.
341
352
  let shared = false;
@@ -364,7 +375,7 @@ async function runCall(run, prepared, answered, step) {
364
375
  const answer = dedupable
365
376
  ? await once(answered, key, () => run.dispatch(request, signal, report))
366
377
  : await run.dispatch(request, signal, report);
367
- ({ text: content, links } = answerOf(answer));
378
+ ({ text: content, links, end } = answerOf(answer));
368
379
  }
369
380
  }
370
381
  catch (error) {
@@ -384,7 +395,7 @@ async function runCall(run, prepared, answered, step) {
384
395
  }
385
396
  const result = { id: call.id, name, ok, content, ...(links.length ? { links } : {}) };
386
397
  run.onToolResult?.(result);
387
- return result;
398
+ return { result, end };
388
399
  }
389
400
  /**
390
401
  * Runs a step's calls and writes each result into the transcript, in the order asked.
@@ -393,11 +404,19 @@ async function runCall(run, prepared, answered, step) {
393
404
  * @param parsed - The step's calls, with their arguments read.
394
405
  * @param messages - The transcript the results are written into.
395
406
  * @param step - Which step this is, for the host told of each result.
407
+ * @returns The first call, in the order asked, whose answer ended the run, or `undefined` where
408
+ * none did.
396
409
  *
397
410
  * @remarks
398
411
  * A failure part-way leaves the transcript well-formed before it is rethrown: a call with no
399
412
  * result is a transcript no endpoint takes back, so every call of the step is answered — with what
400
413
  * it returned where it had, and otherwise with a line saying it was stopped, or never run.
414
+ *
415
+ * An answer that ends the run does not end the step. The calls after it still run, in whatever
416
+ * batch `parallel` puts them, because the model asked for them in the same breath and how a host
417
+ * schedules calls is not what they mean: a reply that says "write the file, then hand off" in one
418
+ * turn wants the file written whichever order it named the two in, and a call batched beside the
419
+ * end was running already. A tool that must have nothing after it says so in its description.
401
420
  */
402
421
  export async function runCalls(run, parsed, messages, step) {
403
422
  const { signal, toolCalls } = run;
@@ -406,6 +425,7 @@ export async function runCalls(run, parsed, messages, step) {
406
425
  const answered = new Map();
407
426
  // By position, so a call that came back while another was being stopped is still found.
408
427
  const outcomes = parsed.map(() => undefined);
428
+ const ends = parsed.map(() => false);
409
429
  // How many of the step's calls were handed off, and how many have their result in the
410
430
  // transcript. Both count from the front, since results are written in the order asked.
411
431
  let begun = 0;
@@ -436,7 +456,9 @@ export async function runCalls(run, parsed, messages, step) {
436
456
  const to = batchEnd(run, ready, start, parsed.length, answered);
437
457
  begun = to;
438
458
  await Promise.all(parsed.slice(start, to).map(async (_, offset) => {
439
- outcomes[start + offset] = await runCall(run, ready(start + offset), answered, step);
459
+ const { result, end } = await runCall(run, ready(start + offset), answered, step);
460
+ outcomes[start + offset] = result;
461
+ ends[start + offset] = end;
440
462
  }));
441
463
  for (const { id, name, ok, content } of outcomes.slice(from, to).flatMap((outcome) => outcome ?? [])) {
442
464
  toolCalls.push({ id, name, ok });
@@ -444,6 +466,15 @@ export async function runCalls(run, parsed, messages, step) {
444
466
  }
445
467
  from = to;
446
468
  }
469
+ // The first by position rather than by finishing: a batch's calls end in no order worth
470
+ // reporting, and a deduplicated repeat shares its answer's `end` with the call before it.
471
+ const at = ends.findIndex((end) => end !== false);
472
+ const end = ends[at];
473
+ const outcome = outcomes[at];
474
+ if (end === undefined || end === false || outcome === undefined) {
475
+ return undefined;
476
+ }
477
+ return { callId: outcome.id, name: outcome.name, ...(end.reason === undefined ? {} : { reason: end.reason }) };
447
478
  }
448
479
  catch (error) {
449
480
  // Only a call that was made counts in `toolCalls`.
@@ -188,6 +188,27 @@ export interface ToolAnswer {
188
188
  text: string;
189
189
  /** What the call produced, in the order the tool gave them. Never sent to the model. */
190
190
  links?: ResourceLink[];
191
+ /**
192
+ * Ends the run once this step's calls are answered: no further request is sent, and the loop
193
+ * resolves with `ended` rather than throwing. An object says why, for the host and its `done`.
194
+ *
195
+ * @remarks
196
+ * The step's other calls still run and are answered, so the transcript stays one an endpoint
197
+ * will take back. For a tool whose call is the end of the job — handing off, asking a person —
198
+ * where the alternative is an abort, which the loop reads as a failure.
199
+ */
200
+ end?: boolean | {
201
+ reason: string;
202
+ };
203
+ }
204
+ /** The call that ended a run through its answer's `end`, as `AgentLoopResult.ended` says it. */
205
+ export interface EndedBy {
206
+ /** The call's id. */
207
+ callId: string;
208
+ /** The tool, under the name it was announced by: for a `call_tool`, the tool it names. */
209
+ name: string;
210
+ /** Why, where the answer said: absent for `end: true`. */
211
+ reason?: string;
191
212
  }
192
213
  /**
193
214
  * The levels a server's log message is sent at, quietest first: MCP's, which are syslog's.
package/llms.txt CHANGED
@@ -163,7 +163,7 @@ The loop above a turn: send, run the tools the model asked for, send again, unti
163
163
  - `PRESELECT_APPEND` — The `preselectRouting` that loads the shortlist after what was carried, and sends the first step like any other.
164
164
  - `PRESELECT_EXCLUSIVE` — The `preselectRouting` that sends the first step the shortlist alone.
165
165
  - `PRESELECTED_NOTICE` — What opens the notice naming the loop's own picks, ahead of the names.
166
- - `runAgentLoop` — Runs a question to its answer: one `runTurn` per step, the tools it asks for between them, until a turn asks for none.
166
+ - `runAgentLoop` — Runs a question to its answer: one `runTurn` per step, the tools it asks for between them, until a turn asks for none or a call's answer ends the run.
167
167
  - `StepWindow` (type) — How full the window is as a step is about to be sent, as `beforeStep` is told it.
168
168
 
169
169
  ### events
@@ -402,6 +402,7 @@ Reading what a model meant by a tool call when it did not write one cleanly.
402
402
 
403
403
  - `ARGUMENTS_MALFORMED` — The `ToolArgumentsError` kind for a call the model wrote wrongly.
404
404
  - `ARGUMENTS_TRUNCATED` — The `ToolArgumentsError` kind for a call cut off at the reply ceiling.
405
+ - `EndedBy` (type) — The call that ended a run through its answer's `end`, as `AgentLoopResult.ended` says it.
405
406
  - `parseToolArguments` — A tool call's arguments as the object the tool is handed.
406
407
  - `ResourceLink` (type) — A handle to something a tool call made or found — a document it wrote, a file it saved.
407
408
  - `RunArtifact` (type) — One thing a run's tool calls produced: the first link to a uri, and where it came from.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "4.10.0",
3
+ "version": "4.11.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",