@cubicecho/agent-core 4.16.0 → 4.18.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 +36 -4
- package/dist/index.d.ts +1 -1
- package/dist/run/agent-loop.d.ts +21 -1
- package/dist/run/agent-loop.js +77 -19
- package/dist/run/run-calls.d.ts +2 -0
- package/dist/run/run-calls.js +6 -1
- package/dist/turn/continuation.d.ts +3 -1
- package/dist/turn/continuation.js +29 -2
- package/dist/wire/errors.d.ts +14 -2
- package/dist/wire/errors.js +8 -0
- package/dist/wire/stream.d.ts +39 -1
- package/dist/wire/stream.js +29 -1
- package/llms.txt +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -213,6 +213,11 @@ is worth another attempt. The content-free `{"role":"assistant"}` most servers o
|
|
|
213
213
|
does not set it: nothing has been shown to anybody yet, so an endpoint that primes the stream and
|
|
214
214
|
then wedges is retried like one that never answered at all.
|
|
215
215
|
|
|
216
|
+
`inFlight` is the other box `streamTurn` takes, for what an attempt had said once it has thrown:
|
|
217
|
+
each call sets its `read` as it starts, so after a retry it reads the attempt that failed last and
|
|
218
|
+
nothing of the one before. `runTurn` and `continueTurn` pass it through, the second with the
|
|
219
|
+
answer it is continuing joined on, and `runAgentLoop` reads it into a failure's `partial`.
|
|
220
|
+
|
|
216
221
|
`idleMs` is silence, not a deadline: the timer is rearmed on every chunk, so a model that is still
|
|
217
222
|
talking is never cut off however long it takes, and one that has stopped answering raises
|
|
218
223
|
`EndpointSilent` rather than hanging the run. `timeoutMs(config)` returns `undefined` for a
|
|
@@ -470,6 +475,7 @@ const { turn, messages, usage, loaded } = await runAgentLoop({
|
|
|
470
475
|
messages: history, // ending in the question; not written to
|
|
471
476
|
tools, // every tool the run may reach
|
|
472
477
|
catalog, // the same, name-only, for on-demand and proxied loading
|
|
478
|
+
hostTools, // the host's own, declared in every mode and never catalogued
|
|
473
479
|
serverInstructions: true, // the catalogue's `instructions` after `system`; off by default
|
|
474
480
|
preselected, // from `preselect`, if a small model chose
|
|
475
481
|
dispatch: ({ name, args }, signal) => pool.call(name, args, signal),
|
|
@@ -688,6 +694,27 @@ a `tool-call` and a `tool-result` event, told to `onToolCall` and `onToolResult`
|
|
|
688
694
|
id is numbered by where the call lands in the transcript, so two questions do not share one. A name
|
|
689
695
|
with no definition in `tools`, or outside the catalogue, is left out, and none left is no exchange.
|
|
690
696
|
|
|
697
|
+
A host with tools of its own — a question for a person, a resource read — passes them as
|
|
698
|
+
`hostTools`, and they are declared on every step in every mode: after `PROXY_TOOLS` proxied, after
|
|
699
|
+
`load_tools` and ahead of anything loaded on demand, after `tools` in eager mode, and after the
|
|
700
|
+
shortlist on a preselected first step. They reach `dispatch` like any other call and are kept out
|
|
701
|
+
of discovery: taken out of `catalog`, `loaded` and `preselected` before the run starts, never
|
|
702
|
+
counted as a load, and a `call_tool` naming one is refused with a pointer to call it directly.
|
|
703
|
+
Proxied and eager, the array is one set for the whole run, so the cache holds whatever `toolOrder`
|
|
704
|
+
does with it; on demand, `toolOrder: false` keeps them in the prefix every load appends after.
|
|
705
|
+
|
|
706
|
+
```ts
|
|
707
|
+
await runAgentLoop({
|
|
708
|
+
config: { ...config, toolDiscovery: "proxy" },
|
|
709
|
+
messages: history,
|
|
710
|
+
tools: pool.tools(),
|
|
711
|
+
catalog: pool.catalog(),
|
|
712
|
+
hostTools: [ASK_PERSON_DEFINITION], // declared beside load_tools and call_tool, on every step
|
|
713
|
+
dispatch: (call, signal) =>
|
|
714
|
+
call.name === ASK_PERSON ? ask(call.args) : pool.call(call.name, call.args, signal),
|
|
715
|
+
});
|
|
716
|
+
```
|
|
717
|
+
|
|
691
718
|
### What the servers said on the way
|
|
692
719
|
|
|
693
720
|
A server can log while a call runs — a warning that the cache was stale, an error it recovered
|
|
@@ -924,7 +951,7 @@ try {
|
|
|
924
951
|
store.add(sessionId, turn?.reasoning ? { ...message, reasoning_content: turn.reasoning } : message),
|
|
925
952
|
});
|
|
926
953
|
} catch (error) {
|
|
927
|
-
const run = failedRun(error); // messages, usage, toolCalls, loaded, used, preselected — or undefined
|
|
954
|
+
const run = failedRun(error); // messages, partial, usage, toolCalls, loaded, used, preselected — or undefined
|
|
928
955
|
if (run) await store.keep(sessionId, { usage: run.usage, loaded: run.loaded });
|
|
929
956
|
if (signal.aborted) return; // a stop, told by the signal as it always was
|
|
930
957
|
if (error instanceof ContextOverflow) return compactAndRetry(run?.messages);
|
|
@@ -951,9 +978,14 @@ A failure while a step's tools are running leaves a transcript that can be sent
|
|
|
951
978
|
that came back are kept, a call the run was stopped during is answered `Stopped before this call
|
|
952
979
|
finished.`, and one the loop never reached `Not run: the run stopped first.` — a call with no
|
|
953
980
|
result is a transcript an endpoint refuses. Those are announced through `onMessage` like any other
|
|
954
|
-
result. The reply of a request that was in flight when the run stopped
|
|
955
|
-
|
|
956
|
-
|
|
981
|
+
result. The reply of a request that was in flight when the run stopped or failed — a stop, the
|
|
982
|
+
endpoint going silent, a stream that broke after its first token — is not in `messages`, because it
|
|
983
|
+
is not a turn an endpoint will take back. It is on the failure as `partial`: its `content`, its
|
|
984
|
+
`reasoning` and the `toolCalls` it had begun, assembled as far as they had arrived, with a
|
|
985
|
+
continuation's piece joined onto the answer it was continuing and, after a retry, only the attempt
|
|
986
|
+
that was in flight. A request that had said nothing leaves no `partial`, and neither does a
|
|
987
|
+
failure while the tools run, since that step's reply is already in `messages`. Whether half an
|
|
988
|
+
answer belongs in the history is the host's call.
|
|
957
989
|
|
|
958
990
|
`resolveApiKey` is exported and not applied, because which key an endpoint gets is a rule a
|
|
959
991
|
consumer states and a library guessing it could send one where it was not meant to go. The rule
|
package/dist/index.d.ts
CHANGED
|
@@ -37,7 +37,7 @@ export { type RunTurnOptions, runTurn } from './turn/run-turn.ts';
|
|
|
37
37
|
export { type AskJsonOptions, ask, askJson, askReply, clean, listLines, parseJson, resetHints, type SideTask, type SideTaskInput, type SideTaskOptions, type SideTaskReply, taskCall, tryAsk, } from './turn/side-task.ts';
|
|
38
38
|
export { AgentLoopError, type AgentLoopFailure, AgentLoopOverflow, errorMessage, failedRun, ToolIterationLimit, } from './wire/errors.ts';
|
|
39
39
|
export { backoffMs, ContextOverflow, EndpointSilent, isModelLoading, isOverflow, isTransient, SMALLEST_LIKELY_WINDOW, } from './wire/retry.ts';
|
|
40
|
-
export { CacheBreakReason, type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, } from './wire/stream.ts';
|
|
40
|
+
export { CacheBreakReason, type InFlight, type PartialTurn, type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, } from './wire/stream.ts';
|
|
41
41
|
export { ALL_FENCES, DEFAULT_FENCES, type Fence, FenceSplitter, type FenceSplitterOptions, SPLIT_OUTPUT, SPLIT_REASONING, type Split, stripThinking, THINK_FENCE, } from './wire/thinking.ts';
|
|
42
42
|
export { type ContextBreakdown, type ContextBreakdownOptions, compact, contextChars, contextTokens, estimateTokens, messageTokens, requestChars, requestTokens, type TokenEstimateOptions, } from './wire/tokens.ts';
|
|
43
43
|
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';
|
package/dist/run/agent-loop.d.ts
CHANGED
|
@@ -152,6 +152,23 @@ export interface AgentLoopOptions {
|
|
|
152
152
|
* loaded so far, by name; proxied mode declares none, and answers a load with them instead.
|
|
153
153
|
*/
|
|
154
154
|
tools?: OpenAI.ChatCompletionTool[];
|
|
155
|
+
/**
|
|
156
|
+
* The host's own tools — a question for a person, a resource read — declared on every step in
|
|
157
|
+
* every discovery mode and dispatched like any other call, but never catalogued, loaded, or
|
|
158
|
+
* reached through `call_tool`.
|
|
159
|
+
*
|
|
160
|
+
* @remarks
|
|
161
|
+
* Each sits after the loop's own definitions: after `PROXY_TOOLS` proxied, after `load_tools`
|
|
162
|
+
* and ahead of anything loaded on demand, after `tools` in eager mode, and after the shortlist
|
|
163
|
+
* on a preselected first step; `toolOrder` then orders the array as it always does. Proxied and
|
|
164
|
+
* eager, the array is one set for the whole run, so the cache holds in either order; on demand,
|
|
165
|
+
* `toolOrder: false` keeps them in the prefix every load appends after.
|
|
166
|
+
*
|
|
167
|
+
* A name of one is taken out of `catalog`, `loaded` and `preselected` before the run starts, is
|
|
168
|
+
* never counted as a load, and is declared once where `tools` or the loop has it too. A
|
|
169
|
+
* `call_tool` naming one is refused with a pointer to call it directly.
|
|
170
|
+
*/
|
|
171
|
+
hostTools?: OpenAI.ChatCompletionTool[];
|
|
155
172
|
/**
|
|
156
173
|
* The same tools as a name-only catalogue. On-demand and proxied modes need it, and are eager
|
|
157
174
|
* without it.
|
|
@@ -560,7 +577,8 @@ export interface AgentLoopResult {
|
|
|
560
577
|
* @remarks
|
|
561
578
|
* What it throws carries the run as it stood — `messages`, `usage`, `toolCalls`, `loaded`, `used`
|
|
562
579
|
* and `preselected` — because the steps before a failure were real and a host that stores its
|
|
563
|
-
* transcript has nothing else to store them from.
|
|
580
|
+
* transcript has nothing else to store them from. What the request in flight had said is on it
|
|
581
|
+
* as `partial`, and not in `messages`. A spent budget is a `ToolIterationLimit`, an
|
|
564
582
|
* overflow is an `AgentLoopOverflow`, which is still a `ContextOverflow`, and anything else is an
|
|
565
583
|
* `AgentLoopError` with what was caught as its `cause` and its message as its own; `failedRun`
|
|
566
584
|
* reads the run off any of the three. A failure while a step's tools run leaves the transcript
|
|
@@ -595,6 +613,8 @@ export interface AgentLoopResult {
|
|
|
595
613
|
* tool array to go into, so it is written into the transcript as a `load_tools` exchange after
|
|
596
614
|
* the question. `call_tool` is honoured on demand too, unless the host has a tool of that name —
|
|
597
615
|
* a session switched out of proxied mode still has it in its history, and the model copies it.
|
|
616
|
+
* The host's own `hostTools` are declared beside whatever the mode sends, in every mode, and are
|
|
617
|
+
* never catalogued.
|
|
598
618
|
*
|
|
599
619
|
* Neither side task is run unless asked for. `preselect` has the loop make the preselection and
|
|
600
620
|
* `compact` has it fold the transcript, each by the task `config.tasks` carries for it and on
|
package/dist/run/agent-loop.js
CHANGED
|
@@ -77,7 +77,8 @@ function cacheDiagnosis(previous, next, usage) {
|
|
|
77
77
|
* @remarks
|
|
78
78
|
* What it throws carries the run as it stood — `messages`, `usage`, `toolCalls`, `loaded`, `used`
|
|
79
79
|
* and `preselected` — because the steps before a failure were real and a host that stores its
|
|
80
|
-
* transcript has nothing else to store them from.
|
|
80
|
+
* transcript has nothing else to store them from. What the request in flight had said is on it
|
|
81
|
+
* as `partial`, and not in `messages`. A spent budget is a `ToolIterationLimit`, an
|
|
81
82
|
* overflow is an `AgentLoopOverflow`, which is still a `ContextOverflow`, and anything else is an
|
|
82
83
|
* `AgentLoopError` with what was caught as its `cause` and its message as its own; `failedRun`
|
|
83
84
|
* reads the run off any of the three. A failure while a step's tools run leaves the transcript
|
|
@@ -112,6 +113,8 @@ function cacheDiagnosis(previous, next, usage) {
|
|
|
112
113
|
* tool array to go into, so it is written into the transcript as a `load_tools` exchange after
|
|
113
114
|
* the question. `call_tool` is honoured on demand too, unless the host has a tool of that name —
|
|
114
115
|
* a session switched out of proxied mode still has it in its history, and the model copies it.
|
|
116
|
+
* The host's own `hostTools` are declared beside whatever the mode sends, in every mode, and are
|
|
117
|
+
* never catalogued.
|
|
115
118
|
*
|
|
116
119
|
* Neither side task is run unless asked for. `preselect` has the loop make the preselection and
|
|
117
120
|
* `compact` has it fold the transcript, each by the task `config.tasks` carries for it and on
|
|
@@ -133,9 +136,10 @@ export async function runAgentLoop(options) {
|
|
|
133
136
|
}),
|
|
134
137
|
};
|
|
135
138
|
let result;
|
|
139
|
+
const apart = hostToolsApart(options);
|
|
136
140
|
try {
|
|
137
|
-
pin = await pinSession(
|
|
138
|
-
result = await runSteps(
|
|
141
|
+
pin = await pinSession(apart);
|
|
142
|
+
result = await runSteps(apart, standing, pin);
|
|
139
143
|
}
|
|
140
144
|
catch (error) {
|
|
141
145
|
// Wrapped rather than annotated: an abort's reason is one object shared by every run under
|
|
@@ -399,6 +403,43 @@ function recorder(heard) {
|
|
|
399
403
|
};
|
|
400
404
|
return { recorded, onEvent };
|
|
401
405
|
}
|
|
406
|
+
/**
|
|
407
|
+
* The names of the host's own tools.
|
|
408
|
+
*
|
|
409
|
+
* @param [tools] - The host's definitions. One that is not a function has no name, and is left out.
|
|
410
|
+
* @returns A new set, empty where there are none.
|
|
411
|
+
*/
|
|
412
|
+
const hostToolNames = (tools = []) => new Set(tools.flatMap((tool) => toolName(tool) ?? []));
|
|
413
|
+
/**
|
|
414
|
+
* The run's options with the host's own tools kept out of discovery: off the catalogue, and out of
|
|
415
|
+
* what was carried and what was picked.
|
|
416
|
+
*
|
|
417
|
+
* @param options - What `runAgentLoop` was given. Not written to.
|
|
418
|
+
* @returns `options` itself where there are no host tools, and otherwise a copy with `catalog`,
|
|
419
|
+
* `loaded` and `preselected` filtered, a server that offers none of them kept as it was.
|
|
420
|
+
*
|
|
421
|
+
* @remarks
|
|
422
|
+
* Done once, here, so that preselection, `load_tools`, `call_tool` and the catalogue prompt all
|
|
423
|
+
* read one catalogue, and a host tool is never loadable however a name of one reaches the loop.
|
|
424
|
+
*/
|
|
425
|
+
function hostToolsApart(options) {
|
|
426
|
+
const own = hostToolNames(options.hostTools);
|
|
427
|
+
if (own.size === 0) {
|
|
428
|
+
return options;
|
|
429
|
+
}
|
|
430
|
+
const isCatalogued = (name) => own.has(name) === false;
|
|
431
|
+
const { catalog, loaded, preselected } = options;
|
|
432
|
+
const apart = (server) => server.tools.every((entry) => isCatalogued(entry.name))
|
|
433
|
+
? server
|
|
434
|
+
: { ...server, tools: server.tools.filter((entry) => isCatalogued(entry.name)) };
|
|
435
|
+
return {
|
|
436
|
+
...options,
|
|
437
|
+
...(catalog ? { catalog: catalog.map(apart) } : {}),
|
|
438
|
+
...(loaded ? { loaded: [...loaded].filter(isCatalogued) } : {}),
|
|
439
|
+
// Undefined stays undefined: an empty shortlist is a host saying it has already decided.
|
|
440
|
+
...(preselected ? { preselected: preselected.filter(isCatalogued) } : {}),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
402
443
|
/** What opens the notice naming the loop's own picks, ahead of the names. */
|
|
403
444
|
export const PRESELECTED_NOTICE = 'tools picked before the run: ';
|
|
404
445
|
/**
|
|
@@ -570,11 +611,17 @@ async function runSteps(options, standing, pin) {
|
|
|
570
611
|
const summarise = ownSummariser(options, notice);
|
|
571
612
|
const used = new Set();
|
|
572
613
|
const definitions = definitionsByName(tools);
|
|
614
|
+
// Each once, by name, so one given twice is declared once.
|
|
615
|
+
const hostTools = loadedTools([], options.hostTools ?? []);
|
|
616
|
+
const hostNames = hostToolNames(hostTools);
|
|
617
|
+
// After the head the mode declares, which a host tool of the same name does not displace.
|
|
618
|
+
const withHost = (head) => (hostTools.length ? loadedTools(head, hostTools) : head);
|
|
573
619
|
// Looked through unless the host has a tool by that name, which is then the host's to answer.
|
|
574
|
-
const proxies = onDemand && !definitions.has(CALL_TOOL);
|
|
620
|
+
const proxies = onDemand && !definitions.has(CALL_TOOL) && !hostNames.has(CALL_TOOL);
|
|
575
621
|
// What a call the model wrote as text may name: the host's tools, and the loop's own on demand.
|
|
576
622
|
const recoverable = [
|
|
577
623
|
...tools.map(toolName).filter((name) => name !== undefined),
|
|
624
|
+
...hostNames,
|
|
578
625
|
...(onDemand ? [LOAD_TOOLS, ...(proxies ? [CALL_TOOL] : [])] : []),
|
|
579
626
|
];
|
|
580
627
|
// In the order the names are given, not the order of `tools`: `loaded` is a set, which iterates
|
|
@@ -584,16 +631,23 @@ async function runSteps(options, standing, pin) {
|
|
|
584
631
|
let usage = noUsage();
|
|
585
632
|
const toolCalls = [];
|
|
586
633
|
const artifacts = new Map();
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
634
|
+
// A box per request, emptied once its turn is in `messages`: a failure while the tools run had
|
|
635
|
+
// no request in flight.
|
|
636
|
+
let inFlight = {};
|
|
637
|
+
standing.read = () => {
|
|
638
|
+
const partial = inFlight.read?.();
|
|
639
|
+
return {
|
|
640
|
+
messages,
|
|
641
|
+
...(partial ? { partial } : {}),
|
|
642
|
+
usage,
|
|
643
|
+
toolCalls,
|
|
644
|
+
artifacts: [...artifacts.values()],
|
|
645
|
+
// As the result would have said it: proxied, nothing is loaded that the history does not hold.
|
|
646
|
+
loaded: proxied ? [] : [...loaded],
|
|
647
|
+
used: [...used],
|
|
648
|
+
preselected: [...preselected],
|
|
649
|
+
};
|
|
650
|
+
};
|
|
597
651
|
const announce = announcer(onMessage);
|
|
598
652
|
// Held by reference rather than by index, so a `beforeStep` that folds the head into a summary
|
|
599
653
|
// moves the question without losing it — and one that summarises the question away takes the
|
|
@@ -626,6 +680,7 @@ async function runSteps(options, standing, pin) {
|
|
|
626
680
|
proxied,
|
|
627
681
|
proxies,
|
|
628
682
|
definitions,
|
|
683
|
+
hostNames,
|
|
629
684
|
loaded,
|
|
630
685
|
loads,
|
|
631
686
|
used,
|
|
@@ -691,15 +746,15 @@ async function runSteps(options, standing, pin) {
|
|
|
691
746
|
// Ordered here rather than left to `buildBody`, so `names` below is what the request actually
|
|
692
747
|
// declared — a diagnosis reading an order the server never saw calls an untouched tool array
|
|
693
748
|
// `tools-changed`.
|
|
694
|
-
let offered = tools;
|
|
749
|
+
let offered = withHost(tools);
|
|
695
750
|
if (routed) {
|
|
696
|
-
offered = byName(new Set(preselected));
|
|
751
|
+
offered = withHost(byName(new Set(preselected)));
|
|
697
752
|
}
|
|
698
753
|
else if (proxied) {
|
|
699
|
-
offered = [...PROXY_TOOLS];
|
|
754
|
+
offered = withHost([...PROXY_TOOLS]);
|
|
700
755
|
}
|
|
701
756
|
else if (onDemand) {
|
|
702
|
-
offered = loadedTools([LOAD_TOOLS_DEFINITION], byName(loaded));
|
|
757
|
+
offered = loadedTools(withHost([LOAD_TOOLS_DEFINITION]), byName(loaded));
|
|
703
758
|
}
|
|
704
759
|
const declared = orderTools(offered, toolOrder);
|
|
705
760
|
// Unmarked, so the system prompt is the same text on every step and a load does not throw
|
|
@@ -717,8 +772,10 @@ async function runSteps(options, standing, pin) {
|
|
|
717
772
|
...withContext(messages, questionAt(), gathered.context, preface),
|
|
718
773
|
];
|
|
719
774
|
const build = (supported, refused) => buildBody(sending, supported, refused, request, declared, toolOrder);
|
|
775
|
+
inFlight = {};
|
|
720
776
|
const turnOptions = {
|
|
721
777
|
model: config.model,
|
|
778
|
+
inFlight,
|
|
722
779
|
droppable: Object.keys(extraBody ?? {}),
|
|
723
780
|
...(pin ? { headers: pin.headers } : {}),
|
|
724
781
|
maxRetries,
|
|
@@ -772,6 +829,7 @@ async function runSteps(options, standing, pin) {
|
|
|
772
829
|
const turn = maxContinuations > 0
|
|
773
830
|
? await continueTurn(client, supports, build, first, { ...turnOptions, maxContinuations })
|
|
774
831
|
: first;
|
|
832
|
+
inFlight = {};
|
|
775
833
|
previous = {
|
|
776
834
|
messages: request,
|
|
777
835
|
tools: names,
|
|
@@ -783,7 +841,7 @@ async function runSteps(options, standing, pin) {
|
|
|
783
841
|
if (turn.finishReason === FinishReason.Length) {
|
|
784
842
|
notice(`the model stopped at maxTokens (${config.maxTokens}); this turn is cut short`);
|
|
785
843
|
}
|
|
786
|
-
const canRecover = recover && (tools.length > 0 || onDemand);
|
|
844
|
+
const canRecover = recover && (tools.length > 0 || hostTools.length > 0 || onDemand);
|
|
787
845
|
const { calls, content } = turnCalls(turn, canRecover ? recoverable : undefined, notice);
|
|
788
846
|
const shown = { ...turn, content, toolCalls: calls };
|
|
789
847
|
onTurn?.(shown, step);
|
package/dist/run/run-calls.d.ts
CHANGED
|
@@ -86,6 +86,8 @@ export interface Calling {
|
|
|
86
86
|
/** Whether a `call_tool` is looked through, which it is unless the host has a tool by that name. */
|
|
87
87
|
proxies: boolean;
|
|
88
88
|
definitions: ReadonlyMap<string, OpenAI.ChatCompletionTool>;
|
|
89
|
+
/** The host's own tools by name: declared on every step, and never reached through `call_tool`. */
|
|
90
|
+
hostNames: ReadonlySet<string>;
|
|
89
91
|
loaded: Set<string>;
|
|
90
92
|
loads: {
|
|
91
93
|
toolsLoaded: number;
|
package/dist/run/run-calls.js
CHANGED
|
@@ -256,7 +256,12 @@ function prepare(run, entry) {
|
|
|
256
256
|
// nothing a load could not, and the refusal is the call's answer.
|
|
257
257
|
let inner = args ?? {};
|
|
258
258
|
let refused;
|
|
259
|
-
if (proxy && args) {
|
|
259
|
+
if (proxy && args && run.hostNames.has(name)) {
|
|
260
|
+
// Declared already, so the model is pointed at the call it can make rather than told the
|
|
261
|
+
// tool does not exist.
|
|
262
|
+
refused = { error: new Error(`${name} is not in the catalogue; call it directly, not through ${CALL_TOOL}.`) };
|
|
263
|
+
}
|
|
264
|
+
else if (proxy && args) {
|
|
260
265
|
try {
|
|
261
266
|
inner = proxiedCall(args, catalog).input;
|
|
262
267
|
}
|
|
@@ -62,6 +62,8 @@ export declare const isContinuable: (turn: Turn) => boolean;
|
|
|
62
62
|
* server that takes the request and ignores the prefill — hosted OpenAI among them — shows itself;
|
|
63
63
|
* that check is only as good as a restart being word for word. Either way the answer so far is
|
|
64
64
|
* kept, with a notice. So is it when the continuation fails any other way, since the tokens
|
|
65
|
-
* already in hand are worth more than the error; only a stop is thrown.
|
|
65
|
+
* already in hand are worth more than the error; only a stop is thrown. An `inFlight` box reads
|
|
66
|
+
* the answer so far with the continuation's piece joined on, so a stop mid-continuation keeps it
|
|
67
|
+
* all.
|
|
66
68
|
*/
|
|
67
69
|
export declare function continueTurn(client: OpenAI, supports: Capabilities, request: RequestBuilder, turn: Turn, { maxContinuations, ...options }?: ContinueTurnOptions): Promise<Turn>;
|
|
@@ -97,6 +97,22 @@ function joinUsage(first, next) {
|
|
|
97
97
|
}
|
|
98
98
|
return joined;
|
|
99
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* The answer so far with the piece a continuation in flight had added to it.
|
|
102
|
+
*
|
|
103
|
+
* @param sofar - The turn being continued, which may itself be a joined one. Not written to.
|
|
104
|
+
* @param [piece] - What the continuation had said. Absent adds nothing.
|
|
105
|
+
* @returns The two joined as `continueTurn` joins turns: content and reasoning in order, and the
|
|
106
|
+
* continuation's own calls. `reasoning` and `toolCalls` are left out where empty.
|
|
107
|
+
*/
|
|
108
|
+
function joinPartial(sofar, piece) {
|
|
109
|
+
const reasoning = sofar.reasoning + (piece?.reasoning ?? '');
|
|
110
|
+
return {
|
|
111
|
+
content: sofar.content + (piece?.content ?? ''),
|
|
112
|
+
...(reasoning ? { reasoning } : {}),
|
|
113
|
+
...(piece?.toolCalls ? { toolCalls: piece.toolCalls } : {}),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
100
116
|
/**
|
|
101
117
|
* Carries on an answer the token ceiling cut off, by sending the transcript again with the answer
|
|
102
118
|
* so far as a trailing assistant message, and joins the pieces into one turn.
|
|
@@ -124,17 +140,28 @@ function joinUsage(first, next) {
|
|
|
124
140
|
* server that takes the request and ignores the prefill — hosted OpenAI among them — shows itself;
|
|
125
141
|
* that check is only as good as a restart being word for word. Either way the answer so far is
|
|
126
142
|
* kept, with a notice. So is it when the continuation fails any other way, since the tokens
|
|
127
|
-
* already in hand are worth more than the error; only a stop is thrown.
|
|
143
|
+
* already in hand are worth more than the error; only a stop is thrown. An `inFlight` box reads
|
|
144
|
+
* the answer so far with the continuation's piece joined on, so a stop mid-continuation keeps it
|
|
145
|
+
* all.
|
|
128
146
|
*/
|
|
129
147
|
export async function continueTurn(client, supports, request, turn, { maxContinuations = 1, ...options } = {}) {
|
|
130
148
|
const refused = options.model === undefined ? undefined : modelCapabilitiesFor(supports, options.model);
|
|
131
149
|
const who = modelLabel(options.model);
|
|
150
|
+
// The caller's box reads the whole answer, not only the piece the continuation streams.
|
|
151
|
+
const outer = options.inFlight;
|
|
152
|
+
const piece = {};
|
|
153
|
+
const told = outer ? { inFlight: piece } : {};
|
|
132
154
|
let joined = turn;
|
|
133
155
|
for (let count = 0; count < maxContinuations && isContinuable(joined); count++) {
|
|
134
156
|
if (refused?.assistantPrefill === false) {
|
|
135
157
|
break;
|
|
136
158
|
}
|
|
137
159
|
const answer = joined.content;
|
|
160
|
+
if (outer) {
|
|
161
|
+
const sofar = joined;
|
|
162
|
+
piece.read = undefined;
|
|
163
|
+
outer.read = () => joinPartial(sofar, piece.read?.());
|
|
164
|
+
}
|
|
138
165
|
let next;
|
|
139
166
|
try {
|
|
140
167
|
next = await runTurn(client, supports, (capabilities, forModel) => {
|
|
@@ -143,7 +170,7 @@ export async function continueTurn(client, supports, request, turn, { maxContinu
|
|
|
143
170
|
...body,
|
|
144
171
|
messages: [...body.messages, { role: Role.Assistant, content: answer }],
|
|
145
172
|
};
|
|
146
|
-
}, { ...options, startInReasoning: false });
|
|
173
|
+
}, { ...options, ...told, startInReasoning: false });
|
|
147
174
|
}
|
|
148
175
|
catch (error) {
|
|
149
176
|
if (options.signal?.aborted) {
|
package/dist/wire/errors.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type OpenAI from 'openai';
|
|
2
2
|
import { ContextOverflow } from './retry.ts';
|
|
3
|
-
import type { TurnUsage } from './stream.ts';
|
|
3
|
+
import type { PartialTurn, TurnUsage } from './stream.ts';
|
|
4
4
|
import type { RunArtifact, ToolCallOutcome } from './tool-calls.ts';
|
|
5
5
|
/**
|
|
6
6
|
* What went wrong, as a string.
|
|
@@ -27,9 +27,19 @@ export interface AgentLoopFailure {
|
|
|
27
27
|
/**
|
|
28
28
|
* The transcript, as the result's `messages` would have been: every assistant turn and tool
|
|
29
29
|
* result the run added, and no system prompt. Well-formed — a call the run was stopped during
|
|
30
|
-
* has a result saying so. The reply of a request that was in flight is not in it
|
|
30
|
+
* has a result saying so. The reply of a request that was in flight is not in it: see `partial`.
|
|
31
31
|
*/
|
|
32
32
|
messages: OpenAI.ChatCompletionMessageParam[];
|
|
33
|
+
/**
|
|
34
|
+
* What the request in flight had said when the run stopped or failed — a stop, the endpoint
|
|
35
|
+
* going silent, a stream that broke — continuations joined on. Absent where no request was in
|
|
36
|
+
* flight or it had said nothing.
|
|
37
|
+
*
|
|
38
|
+
* @remarks
|
|
39
|
+
* Not in `messages`, because it is not a turn an endpoint will take back. It is what a person
|
|
40
|
+
* watching had been shown, and whether it belongs in the history is the host's call.
|
|
41
|
+
*/
|
|
42
|
+
partial?: PartialTurn;
|
|
33
43
|
/** Summed over the turns that came back. A request that failed or was stopped adds nothing. */
|
|
34
44
|
usage: TurnUsage;
|
|
35
45
|
/** Every call that was made, in order. One the run was stopped during is `ok: false`. */
|
|
@@ -59,6 +69,7 @@ export interface AgentLoopFailure {
|
|
|
59
69
|
export declare class AgentLoopError extends Error implements AgentLoopFailure {
|
|
60
70
|
readonly name: string;
|
|
61
71
|
readonly messages: OpenAI.ChatCompletionMessageParam[];
|
|
72
|
+
readonly partial?: PartialTurn;
|
|
62
73
|
readonly usage: TurnUsage;
|
|
63
74
|
readonly toolCalls: ToolCallOutcome[];
|
|
64
75
|
readonly artifacts: RunArtifact[];
|
|
@@ -93,6 +104,7 @@ export declare class ToolIterationLimit extends AgentLoopError {
|
|
|
93
104
|
*/
|
|
94
105
|
export declare class AgentLoopOverflow extends ContextOverflow implements AgentLoopFailure {
|
|
95
106
|
readonly messages: OpenAI.ChatCompletionMessageParam[];
|
|
107
|
+
readonly partial?: PartialTurn;
|
|
96
108
|
readonly usage: TurnUsage;
|
|
97
109
|
readonly toolCalls: ToolCallOutcome[];
|
|
98
110
|
readonly artifacts: RunArtifact[];
|
package/dist/wire/errors.js
CHANGED
|
@@ -24,6 +24,7 @@ export const errorMessage = (error) => (error instanceof Error ? error.message :
|
|
|
24
24
|
export class AgentLoopError extends Error {
|
|
25
25
|
name = 'AgentLoopError';
|
|
26
26
|
messages;
|
|
27
|
+
partial;
|
|
27
28
|
usage;
|
|
28
29
|
toolCalls;
|
|
29
30
|
artifacts;
|
|
@@ -38,6 +39,9 @@ export class AgentLoopError extends Error {
|
|
|
38
39
|
constructor(message, run, options) {
|
|
39
40
|
super(message, options);
|
|
40
41
|
this.messages = run.messages;
|
|
42
|
+
if (run.partial) {
|
|
43
|
+
this.partial = run.partial;
|
|
44
|
+
}
|
|
41
45
|
this.usage = run.usage;
|
|
42
46
|
this.toolCalls = run.toolCalls;
|
|
43
47
|
this.artifacts = run.artifacts ?? [];
|
|
@@ -67,6 +71,7 @@ export class ToolIterationLimit extends AgentLoopError {
|
|
|
67
71
|
*/
|
|
68
72
|
export class AgentLoopOverflow extends ContextOverflow {
|
|
69
73
|
messages;
|
|
74
|
+
partial;
|
|
70
75
|
usage;
|
|
71
76
|
toolCalls;
|
|
72
77
|
artifacts;
|
|
@@ -81,6 +86,9 @@ export class AgentLoopOverflow extends ContextOverflow {
|
|
|
81
86
|
constructor(message, run, options) {
|
|
82
87
|
super(message, options);
|
|
83
88
|
this.messages = run.messages;
|
|
89
|
+
if (run.partial) {
|
|
90
|
+
this.partial = run.partial;
|
|
91
|
+
}
|
|
84
92
|
this.usage = run.usage;
|
|
85
93
|
this.toolCalls = run.toolCalls;
|
|
86
94
|
this.artifacts = run.artifacts ?? [];
|
package/dist/wire/stream.d.ts
CHANGED
|
@@ -213,6 +213,39 @@ export interface Turn {
|
|
|
213
213
|
export interface Produced {
|
|
214
214
|
any: boolean;
|
|
215
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* What a turn had said when it ended without coming back: the answer, the scratchpad and the calls,
|
|
218
|
+
* as far as each had arrived.
|
|
219
|
+
*
|
|
220
|
+
* @remarks
|
|
221
|
+
* It is not a `Turn`, and not a message an endpoint will take back: it has no usage, no reason it
|
|
222
|
+
* ended, and a call in it may stop mid-JSON. It is what a person watching had been shown, for a
|
|
223
|
+
* host to keep or to show.
|
|
224
|
+
*/
|
|
225
|
+
export interface PartialTurn {
|
|
226
|
+
/** The answer so far, fences taken out; `""` where it had not begun. */
|
|
227
|
+
content: string;
|
|
228
|
+
/** The scratchpad so far, from its own field or a fence. Absent where none had arrived. */
|
|
229
|
+
reasoning?: string;
|
|
230
|
+
/**
|
|
231
|
+
* The calls begun, assembled as `Turn.toolCalls` would have been — a name may be missing and
|
|
232
|
+
* arguments may stop mid-JSON. Absent where none had begun.
|
|
233
|
+
*/
|
|
234
|
+
toolCalls?: ToolCall[];
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Where the request in flight can be read from, after it has thrown.
|
|
238
|
+
*
|
|
239
|
+
* @remarks
|
|
240
|
+
* A box for the reason `Produced` is one: a rejected promise carries no stream state, and the
|
|
241
|
+
* error is often the signal's reason, which is shared by every run under it. Each `streamTurn`
|
|
242
|
+
* sets `read` as it starts, so after a retry or a downgrade it reads the latest attempt and
|
|
243
|
+
* nothing of the one before.
|
|
244
|
+
*/
|
|
245
|
+
export interface InFlight {
|
|
246
|
+
/** What the stream had said, or `undefined` where it had said nothing. */
|
|
247
|
+
read?: () => PartialTurn | undefined;
|
|
248
|
+
}
|
|
216
249
|
/** What `streamTurn` takes besides the request body. */
|
|
217
250
|
export interface StreamTurnOptions {
|
|
218
251
|
/** Cancels the request and the stream being read from it. */
|
|
@@ -235,6 +268,11 @@ export interface StreamTurnOptions {
|
|
|
235
268
|
firstChunkMs?: number;
|
|
236
269
|
/** Set by the first chunk that carries anything, so a failed call knows if it can be retried. */
|
|
237
270
|
produced?: Produced;
|
|
271
|
+
/**
|
|
272
|
+
* Given a `read` of what this call has said so far, as the call starts. Read it once the call has
|
|
273
|
+
* thrown; a turn that came back has it all, and the box is not cleared.
|
|
274
|
+
*/
|
|
275
|
+
inFlight?: InFlight;
|
|
238
276
|
/**
|
|
239
277
|
* The fences that mark a scratchpad written into `content`.
|
|
240
278
|
*
|
|
@@ -282,4 +320,4 @@ export interface StreamTurnOptions {
|
|
|
282
320
|
* which run it is: `step` is the caller's flow concept, and wrapping these into an `emit` is one
|
|
283
321
|
* line at the call site.
|
|
284
322
|
*/
|
|
285
|
-
export declare function streamTurn(client: OpenAI, body: OpenAI.ChatCompletionCreateParamsStreaming, { signal, idleMs, firstChunkMs, produced, fences, startInReasoning, onThinking, onOutput, headers, }?: StreamTurnOptions): Promise<Turn>;
|
|
323
|
+
export declare function streamTurn(client: OpenAI, body: OpenAI.ChatCompletionCreateParamsStreaming, { signal, idleMs, firstChunkMs, produced, inFlight, fences, startInReasoning, onThinking, onOutput, headers, }?: StreamTurnOptions): Promise<Turn>;
|
package/dist/wire/stream.js
CHANGED
|
@@ -188,6 +188,25 @@ function assembledCalls(calls) {
|
|
|
188
188
|
}
|
|
189
189
|
/** The largest delay a timer takes, which is as close to none as the SDK's timeout option goes. */
|
|
190
190
|
const NO_SDK_TIMEOUT = 2_147_483_647;
|
|
191
|
+
/**
|
|
192
|
+
* A partial turn from what a stream had put together, or nothing where it had put nothing.
|
|
193
|
+
*
|
|
194
|
+
* @param content - The answer so far.
|
|
195
|
+
* @param reasoning - The scratchpad so far.
|
|
196
|
+
* @param calls - The calls begun, as they arrived. Not written to.
|
|
197
|
+
* @returns `undefined` where all three are empty; otherwise the content, with `reasoning` and
|
|
198
|
+
* `toolCalls` only where they hold something.
|
|
199
|
+
*/
|
|
200
|
+
function partialTurn(content, reasoning, calls) {
|
|
201
|
+
if (content === '' && reasoning === '' && calls.length === 0) {
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
content,
|
|
206
|
+
...(reasoning ? { reasoning } : {}),
|
|
207
|
+
...(calls.length ? { toolCalls: assembledCalls(calls) } : {}),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
191
210
|
/**
|
|
192
211
|
* Runs one turn as a stream, reporting tokens as they arrive and assembling them back into a
|
|
193
212
|
* message.
|
|
@@ -208,7 +227,7 @@ const NO_SDK_TIMEOUT = 2_147_483_647;
|
|
|
208
227
|
* which run it is: `step` is the caller's flow concept, and wrapping these into an `emit` is one
|
|
209
228
|
* line at the call site.
|
|
210
229
|
*/
|
|
211
|
-
export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, produced, fences = DEFAULT_FENCES, startInReasoning, onThinking, onOutput, headers, } = {}) {
|
|
230
|
+
export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, produced, inFlight, fences = DEFAULT_FENCES, startInReasoning, onThinking, onOutput, headers, } = {}) {
|
|
212
231
|
// Silence, not duration: the timer is rearmed on every chunk, so a model that is still
|
|
213
232
|
// talking is never cut off however long it takes, and one that has stopped talking does not
|
|
214
233
|
// hang the run until someone notices. A request that never answers at all is the same case
|
|
@@ -229,6 +248,10 @@ export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, p
|
|
|
229
248
|
idle = setTimeout(() => watchdog.abort(), ms);
|
|
230
249
|
}
|
|
231
250
|
};
|
|
251
|
+
if (inFlight) {
|
|
252
|
+
// Before the request, so a box an earlier attempt filled reads nothing of it.
|
|
253
|
+
inFlight.read = () => undefined;
|
|
254
|
+
}
|
|
232
255
|
try {
|
|
233
256
|
rearm(false);
|
|
234
257
|
const started = Date.now();
|
|
@@ -271,6 +294,11 @@ export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, p
|
|
|
271
294
|
// In arrival order, sorted by index at the end; a call from a server that sent none keeps
|
|
272
295
|
// its place in the order they arrived.
|
|
273
296
|
const calls = [];
|
|
297
|
+
if (inFlight) {
|
|
298
|
+
// Lazy, so a stream nobody stops pays nothing for it. Text held back as a possible tag is
|
|
299
|
+
// left out: it is a few characters that may yet be a marker.
|
|
300
|
+
inFlight.read = () => partialTurn(splitter.output, reasoning.join('') + splitter.reasoning, calls);
|
|
301
|
+
}
|
|
274
302
|
const usage = noUsage();
|
|
275
303
|
// The usage block's own cache count wins over llama.cpp's, which is the same number in the
|
|
276
304
|
// servers that send both; kept apart so the order the two arrive in does not decide.
|
package/llms.txt
CHANGED
|
@@ -387,6 +387,8 @@ Everything about a request failing that is not about what the request said.
|
|
|
387
387
|
Reading one streamed turn back into a message.
|
|
388
388
|
|
|
389
389
|
- `CacheBreakReason` — Any one of the reasons in `CacheBreakReason`.
|
|
390
|
+
- `InFlight` (type) — Where the request in flight can be read from, after it has thrown.
|
|
391
|
+
- `PartialTurn` (type) — What a turn had said when it ended without coming back: the answer, the scratchpad and the calls, as far as each had arrived.
|
|
390
392
|
- `Produced` (type) — Whether the model has said anything a second attempt would say twice.
|
|
391
393
|
- `StreamTurnOptions` (type) — What `streamTurn` takes besides the request body.
|
|
392
394
|
- `streamTurn` — Runs one turn as a stream, reporting tokens as they arrive and assembling them back into a message.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cubicecho/agent-core",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.18.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",
|