@oh-my-pi/pi-agent-core 18.3.1 → 18.3.3
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/CHANGELOG.md +13 -0
- package/dist/types/agent-loop.d.ts +8 -1
- package/dist/types/agent.d.ts +13 -0
- package/dist/types/compaction/pruning.d.ts +9 -0
- package/dist/types/compaction/transcript-tokens.d.ts +11 -1
- package/dist/types/types.d.ts +7 -0
- package/package.json +8 -8
- package/src/agent-loop.ts +59 -2
- package/src/agent.ts +76 -22
- package/src/compaction/anthropic.ts +1 -1
- package/src/compaction/pruning.ts +1 -1
- package/src/compaction/transcript-tokens.ts +33 -2
- package/src/types.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.3.3] - 2026-09-27
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added live steering support, allowing models to receive and act on user steering messages during an active stream.
|
|
10
|
+
|
|
11
|
+
## [18.3.2] - 2026-09-25
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Fixed tool calls that put their payload in the intent field `i` (for example a file body in `write`) silently running with the leftover arguments; they now fail with an error telling the model to retry ([#13140](https://github.com/can1357/oh-my-pi/issues/13140), [#13141](https://github.com/can1357/oh-my-pi/pull/13141) by [@radkawar](https://github.com/radkawar))
|
|
16
|
+
- Fixed the Anthropic compaction failure log omitting why no compaction block came back; it now names the stop reason ([#13300](https://github.com/can1357/oh-my-pi/pull/13300) by [@alphastorm](https://github.com/alphastorm))
|
|
17
|
+
|
|
5
18
|
## [18.3.1] - 2026-09-25
|
|
6
19
|
|
|
7
20
|
### Added
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { type AssistantMessage, type Context, EventStream, type ToolResultMessage } from "@oh-my-pi/pi-ai";
|
|
6
6
|
import { type Dialect } from "@oh-my-pi/pi-ai/dialect";
|
|
7
7
|
import { type AgentRunCoverage, type AgentRunSummary } from "./run-collector.js";
|
|
8
|
-
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, StreamFn } from "./types.js";
|
|
8
|
+
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, SteeringQueueState, StreamFn } from "./types.js";
|
|
9
9
|
/** Stop-details marker for a provider error after assistant content/tool args already streamed. */
|
|
10
10
|
export declare const STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL = "stream_interrupted_after_content";
|
|
11
11
|
/**
|
|
@@ -106,6 +106,13 @@ export interface NormalizeToolsOptions {
|
|
|
106
106
|
pruneDescriptions?: boolean;
|
|
107
107
|
}
|
|
108
108
|
export declare function normalizeTools(tools: AgentContext["tools"], options: NormalizeToolsOptions): Context["tools"];
|
|
109
|
+
/**
|
|
110
|
+
* Classify the first `count` steering messages for a tool-batch interrupt:
|
|
111
|
+
* any user-authored message wins, then agent-attributed user messages, else
|
|
112
|
+
* system steering (advisor cards, hidden directives). Shared by the agent's
|
|
113
|
+
* queue peek and the loop's live-taken steering.
|
|
114
|
+
*/
|
|
115
|
+
export declare function steeringQueueState(messages: readonly AgentMessage[], count?: number): SteeringQueueState;
|
|
109
116
|
/** Resolve the human-readable reason an abort carried. A caller that aborts via
|
|
110
117
|
* `AbortController.abort(reason)` with a string or a non-`AbortError` `Error`
|
|
111
118
|
* (e.g. the coding agent's user-interrupt label) gets that text surfaced on the
|
package/dist/types/agent.d.ts
CHANGED
|
@@ -411,6 +411,14 @@ export declare class Agent {
|
|
|
411
411
|
addBeforeQueuedMessageDequeueHook(hook: (signal?: AbortSignal) => Promise<void> | void): () => void;
|
|
412
412
|
/** Register an independently removable hook that runs immediately before each model call. */
|
|
413
413
|
addBeforeModelCallHook(hook: (signal?: AbortSignal) => Promise<void> | void): () => void;
|
|
414
|
+
/**
|
|
415
|
+
* Take back live-steered messages ahead of an abort (Esc restores them to the editor):
|
|
416
|
+
* the aborted run then neither records nor requeues them.
|
|
417
|
+
*/
|
|
418
|
+
withdrawLiveSteering(): AgentMessage[];
|
|
419
|
+
/** Steering live steering took for the streaming response; the transcript records it once
|
|
420
|
+
* that response (or its tool batch) ends, which is when the model switches to it. */
|
|
421
|
+
peekLiveSteeredMessages(): AgentMessage[];
|
|
414
422
|
setProviderResponseInterceptor(fn: SimpleStreamOptions["onResponse"] | undefined): void;
|
|
415
423
|
setRawSseEventInterceptor(fn: SimpleStreamOptions["onSseEvent"] | undefined): void;
|
|
416
424
|
setAssistantMessageEventInterceptor(fn: ((message: AssistantMessage, event: AssistantMessageEvent) => void) | undefined): void;
|
|
@@ -476,6 +484,11 @@ export declare class Agent {
|
|
|
476
484
|
* single source of truth. Includes exclusively claimed originals while
|
|
477
485
|
* preparation is pending, so editor restoration can cancel their delivery. */
|
|
478
486
|
peekSteeringQueue(): readonly AgentMessage[];
|
|
487
|
+
/** Dequeued messages not yet in the transcript, e.g. steering a provider
|
|
488
|
+
* took into its in-flight response via live steering. Aborting the run
|
|
489
|
+
* requeues them, so the session's empty-submit interrupt counts them as
|
|
490
|
+
* pending input even though {@link peekSteeringQueue} no longer does. */
|
|
491
|
+
peekUndeliveredQueuedMessages(): AgentMessage[];
|
|
479
492
|
/** Non-consuming view of the pending follow-up queue. See
|
|
480
493
|
* {@link peekSteeringQueue}. */
|
|
481
494
|
peekFollowUpQueue(): readonly AgentMessage[];
|
|
@@ -82,6 +82,15 @@ export interface SupersedePruneConfig {
|
|
|
82
82
|
/** Tool-result protection matchers (same contract as {@link PruneConfig.protectedTools}). */
|
|
83
83
|
protectedTools: ProtectedToolMatcher[];
|
|
84
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Generic age-based pruning floor. Below this, blanking a result to
|
|
87
|
+
* `[Output truncated - N tokens]` recovers nothing — the placeholder itself
|
|
88
|
+
* costs ~8 tokens, so a sub-floor result grows the context (and churns the
|
|
89
|
+
* prompt cache) instead of shrinking it. Superseded/useless results keep their
|
|
90
|
+
* own rules: useless already drops no-savings candidates, superseded prunes for
|
|
91
|
+
* correctness regardless of size.
|
|
92
|
+
*/
|
|
93
|
+
export declare const MIN_PRUNE_TOKENS = 50;
|
|
85
94
|
/**
|
|
86
95
|
* Prune superseded tool results (e.g. stale `read` outputs replaced by a newer
|
|
87
96
|
* read of the same file) and, when `pruneUseless` is set, results their tool
|
|
@@ -37,6 +37,14 @@ export interface TranscriptUsageAnchor {
|
|
|
37
37
|
* the session-entry walkers.
|
|
38
38
|
*/
|
|
39
39
|
export declare function isTranscriptUsageAnchor(message: AgentMessage): message is AssistantMessage;
|
|
40
|
+
/** Options for {@link findTranscriptUsageAnchor}. */
|
|
41
|
+
export interface TranscriptUsageAnchorOptions {
|
|
42
|
+
/**
|
|
43
|
+
* Also treat usage reported at or before the newest `prunedAt` as stale,
|
|
44
|
+
* the same rule {@link findRequestUsageAnchor} applies to request contexts.
|
|
45
|
+
*/
|
|
46
|
+
skipPrunedAnchors?: boolean;
|
|
47
|
+
}
|
|
40
48
|
/**
|
|
41
49
|
* Newest assistant turn in `messages[fromIndex..]` whose usage can anchor the
|
|
42
50
|
* transcript, or `undefined` when none qualifies (fresh context, or every
|
|
@@ -45,7 +53,7 @@ export declare function isTranscriptUsageAnchor(message: AgentMessage): message
|
|
|
45
53
|
* `fromIndex` excludes turns whose usage is stale — anything a compaction
|
|
46
54
|
* summarized away describes a prompt that is no longer sent.
|
|
47
55
|
*/
|
|
48
|
-
export declare function findTranscriptUsageAnchor(messages: readonly AgentMessage[], fromIndex?: number): TranscriptUsageAnchor | undefined;
|
|
56
|
+
export declare function findTranscriptUsageAnchor(messages: readonly AgentMessage[], fromIndex?: number, options?: TranscriptUsageAnchorOptions): TranscriptUsageAnchor | undefined;
|
|
49
57
|
/**
|
|
50
58
|
* Newest assistant turn in a provider request's `messages` whose usage still
|
|
51
59
|
* describes the prefix it sits on, or `undefined` when none does.
|
|
@@ -67,6 +75,8 @@ export interface TranscriptTokenOptions {
|
|
|
67
75
|
* accounting is governed separately by {@link countFromIndex}.
|
|
68
76
|
*/
|
|
69
77
|
anchorFromIndex?: number;
|
|
78
|
+
/** Forwarded to {@link findTranscriptUsageAnchor}. */
|
|
79
|
+
skipPrunedAnchors?: boolean;
|
|
70
80
|
/**
|
|
71
81
|
* First message whose content is counted locally when no anchor is found.
|
|
72
82
|
* Defaults to 0 (count the whole transcript), which is what a floor
|
package/dist/types/types.d.ts
CHANGED
|
@@ -240,6 +240,13 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
240
240
|
* {@link getSteeringMessages}.
|
|
241
241
|
*/
|
|
242
242
|
waitForSteeringMessages?: (signal?: AbortSignal) => Promise<void>;
|
|
243
|
+
/**
|
|
244
|
+
* Called when live steering dequeues messages via {@link getSteeringMessages}
|
|
245
|
+
* for the response being streamed. The loop records them in the transcript
|
|
246
|
+
* after that response (or its tool batch); an abort before then leaves them
|
|
247
|
+
* unrecorded for the host to requeue.
|
|
248
|
+
*/
|
|
249
|
+
onLiveSteeringTaken?: (messages: AgentMessage[]) => void;
|
|
243
250
|
/**
|
|
244
251
|
* Peeks whether IRC messages should interrupt an interruptible waiting tool.
|
|
245
252
|
*
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-agent-core",
|
|
4
|
-
"version": "18.3.
|
|
4
|
+
"version": "18.3.3",
|
|
5
5
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": {
|
|
@@ -38,16 +38,16 @@
|
|
|
38
38
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@oh-my-pi/pi-ai": "18.3.
|
|
42
|
-
"@oh-my-pi/pi-catalog": "18.3.
|
|
43
|
-
"@oh-my-pi/pi-natives": "18.3.
|
|
44
|
-
"@oh-my-pi/pi-utils": "18.3.
|
|
45
|
-
"@oh-my-pi/pi-wire": "18.3.
|
|
46
|
-
"@oh-my-pi/snapcompact": "18.3.
|
|
41
|
+
"@oh-my-pi/pi-ai": "18.3.3",
|
|
42
|
+
"@oh-my-pi/pi-catalog": "18.3.3",
|
|
43
|
+
"@oh-my-pi/pi-natives": "18.3.3",
|
|
44
|
+
"@oh-my-pi/pi-utils": "18.3.3",
|
|
45
|
+
"@oh-my-pi/pi-wire": "18.3.3",
|
|
46
|
+
"@oh-my-pi/snapcompact": "18.3.3",
|
|
47
47
|
"@opentelemetry/api": "^1.9.1"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@oh-my-pi/omptype": "18.3.
|
|
50
|
+
"@oh-my-pi/omptype": "18.3.3",
|
|
51
51
|
"@opentelemetry/context-async-hooks": "^2.9.0",
|
|
52
52
|
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
|
53
53
|
"@types/bun": "^1.3.14"
|
package/src/agent-loop.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
getStreamingPartialJson,
|
|
40
40
|
kCursorExecResolved,
|
|
41
41
|
} from "@oh-my-pi/pi-ai/utils/block-symbols";
|
|
42
|
+
import { schemaDefinesProperty } from "@oh-my-pi/pi-ai/utils/schema/json-schema-validator";
|
|
42
43
|
import { stamp } from "@oh-my-pi/pi-ai/utils/schema/stamps";
|
|
43
44
|
import {
|
|
44
45
|
createHarmonyAuditEvent,
|
|
@@ -1022,6 +1023,13 @@ function resolveIntentMode(intent: AgentTool["intent"]): "require" | "optional"
|
|
|
1022
1023
|
return "require";
|
|
1023
1024
|
}
|
|
1024
1025
|
|
|
1026
|
+
/**
|
|
1027
|
+
* Longest `i` value accepted as an intent. The injected field is described as
|
|
1028
|
+
* a "concise intent" (INTENT_FIELD_DESCRIPTION); anything past this is a tool
|
|
1029
|
+
* payload the model put in the wrong field, not a label.
|
|
1030
|
+
*/
|
|
1031
|
+
const MAX_INTENT_LENGTH = 200;
|
|
1032
|
+
|
|
1025
1033
|
function extractIntent(args: Record<string, unknown>): { intent?: string; strippedArgs: Record<string, unknown> } {
|
|
1026
1034
|
const { [INTENT_FIELD]: intent, ...strippedArgs } = args;
|
|
1027
1035
|
if (typeof intent !== "string") {
|
|
@@ -1239,6 +1247,7 @@ async function runLoopBody(
|
|
|
1239
1247
|
config,
|
|
1240
1248
|
telemetry,
|
|
1241
1249
|
invokeAgentSpan,
|
|
1250
|
+
[],
|
|
1242
1251
|
);
|
|
1243
1252
|
for (const result of executionResult.toolResults) {
|
|
1244
1253
|
currentContext.messages.push(result);
|
|
@@ -1611,6 +1620,7 @@ async function runLoopBody(
|
|
|
1611
1620
|
config,
|
|
1612
1621
|
telemetry,
|
|
1613
1622
|
invokeAgentSpan,
|
|
1623
|
+
[...liveAccepted, ...liveDeferred],
|
|
1614
1624
|
);
|
|
1615
1625
|
toolResults.push(...executionResult.toolResults);
|
|
1616
1626
|
|
|
@@ -1792,6 +1802,27 @@ interface PreparedProviderCall {
|
|
|
1792
1802
|
liveSteering?: LiveSteeringChannel;
|
|
1793
1803
|
}
|
|
1794
1804
|
|
|
1805
|
+
/**
|
|
1806
|
+
* Classify the first `count` steering messages for a tool-batch interrupt:
|
|
1807
|
+
* any user-authored message wins, then agent-attributed user messages, else
|
|
1808
|
+
* system steering (advisor cards, hidden directives). Shared by the agent's
|
|
1809
|
+
* queue peek and the loop's live-taken steering.
|
|
1810
|
+
*/
|
|
1811
|
+
export function steeringQueueState(messages: readonly AgentMessage[], count = messages.length): SteeringQueueState {
|
|
1812
|
+
if (count === 0) return { queued: false };
|
|
1813
|
+
let hasAgentSteering = false;
|
|
1814
|
+
for (let i = 0; i < count; i++) {
|
|
1815
|
+
const message = messages[i];
|
|
1816
|
+
const role = "role" in message ? message.role : undefined;
|
|
1817
|
+
const attribution = "attribution" in message ? message.attribution : undefined;
|
|
1818
|
+
if (attribution === "user") return { queued: true, source: "user" };
|
|
1819
|
+
if (role !== "user") continue;
|
|
1820
|
+
if (attribution !== "agent") return { queued: true, source: "user" };
|
|
1821
|
+
hasAgentSteering = true;
|
|
1822
|
+
}
|
|
1823
|
+
return { queued: true, source: hasAgentSteering ? "agent" : "system" };
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1795
1826
|
/**
|
|
1796
1827
|
* Offer queued steering to a provider that can deliver it into the response it
|
|
1797
1828
|
* is streaming. Latency decides whether steering lands before the model commits
|
|
@@ -1810,7 +1841,11 @@ function openLiveSteering(
|
|
|
1810
1841
|
const bound = (signal: AbortSignal): AbortSignal => (loopSignal ? AbortSignal.any([signal, loopSignal]) : signal);
|
|
1811
1842
|
return new LiveSteeringChannel({
|
|
1812
1843
|
wait: signal => waitForSteeringMessages(bound(signal)),
|
|
1813
|
-
take: signal =>
|
|
1844
|
+
take: async signal => {
|
|
1845
|
+
const messages = await getSteeringMessages(bound(signal));
|
|
1846
|
+
if (messages.length > 0) config.onLiveSteeringTaken?.(messages);
|
|
1847
|
+
return messages;
|
|
1848
|
+
},
|
|
1814
1849
|
toProvider: async (messages, signal) => {
|
|
1815
1850
|
const transformed = config.transformContext
|
|
1816
1851
|
? await config.transformContext(messages, bound(signal))
|
|
@@ -2830,6 +2865,19 @@ async function prepareToolCallDispatch(
|
|
|
2830
2865
|
if (intentTracing) {
|
|
2831
2866
|
const { intent, strippedArgs } = extractIntent(toolCall.arguments);
|
|
2832
2867
|
argsForExecution = strippedArgs;
|
|
2868
|
+
// A payload in `i` would be stripped and the tool run with the leftover
|
|
2869
|
+
// args. Unknown tools fall through to the not-found error; a tool that
|
|
2870
|
+
// owns `i` as a real parameter has nowhere else to put the value.
|
|
2871
|
+
if (
|
|
2872
|
+
intent !== undefined &&
|
|
2873
|
+
intent.length > MAX_INTENT_LENGTH &&
|
|
2874
|
+
tool &&
|
|
2875
|
+
!schemaDefinesProperty(toolWireSchema(tool), INTENT_FIELD)
|
|
2876
|
+
) {
|
|
2877
|
+
entry.args = strippedArgs;
|
|
2878
|
+
entry.validationErrorMessage = `\`${INTENT_FIELD}\` is a short intent label (at most ${MAX_INTENT_LENGTH} chars); the value you sent is ${intent.length} chars. The tool was not run. Put that content in the tool's own parameters and retry with a brief \`${INTENT_FIELD}\`.`;
|
|
2879
|
+
continue;
|
|
2880
|
+
}
|
|
2833
2881
|
if (intent) {
|
|
2834
2882
|
toolCall.intent = intent;
|
|
2835
2883
|
} else if (typeof tool?.intent === "function") {
|
|
@@ -2981,6 +3029,9 @@ async function executeToolCalls(
|
|
|
2981
3029
|
config: AgentLoopConfig,
|
|
2982
3030
|
telemetry: AgentTelemetry | undefined,
|
|
2983
3031
|
invokeAgentSpan: Span | undefined,
|
|
3032
|
+
// Steering the provider took off the queue during the response that emitted
|
|
3033
|
+
// this batch; it injects at this batch's boundary like queued steering.
|
|
3034
|
+
liveSteering: readonly AgentMessage[],
|
|
2984
3035
|
): Promise<{ toolResults: ToolResultMessage[]; additionalContext?: string }> {
|
|
2985
3036
|
const tools = currentContext.tools;
|
|
2986
3037
|
const {
|
|
@@ -3122,7 +3173,10 @@ async function executeToolCalls(
|
|
|
3122
3173
|
// injection boundary below; polling it here would strand or drop messages.
|
|
3123
3174
|
let steeringQueued = false;
|
|
3124
3175
|
let steeringSource: SteeringInterruptSource | undefined;
|
|
3125
|
-
if (
|
|
3176
|
+
if (liveSteering.length > 0) {
|
|
3177
|
+
steeringQueued = true;
|
|
3178
|
+
steeringSource = steeringQueueState(liveSteering).source;
|
|
3179
|
+
} else if (hasSteeringMessages) {
|
|
3126
3180
|
const queuedState = await hasSteeringMessages();
|
|
3127
3181
|
if (typeof queuedState === "boolean") {
|
|
3128
3182
|
steeringQueued = queuedState;
|
|
@@ -3469,6 +3523,9 @@ async function executeToolCalls(
|
|
|
3469
3523
|
const watchSteeringWhileRunning =
|
|
3470
3524
|
(softInterrupts || records.some(record => record.interruptible)) &&
|
|
3471
3525
|
(hasSteeringMessages !== undefined || hasAsidePeek);
|
|
3526
|
+
// Live-taken steering is already pending: interrupt before any call starts
|
|
3527
|
+
// so not-yet-started interruptible waits are skipped outright.
|
|
3528
|
+
if (liveSteering.length > 0) await checkSteering();
|
|
3472
3529
|
const eventDrivenSteeringWatch =
|
|
3473
3530
|
watchSteeringWhileRunning && config.waitForSteeringMessages !== undefined && hasSteeringMessages !== undefined;
|
|
3474
3531
|
const steeringWatchAbortController = new AbortController();
|
package/src/agent.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
normalizeMessagesForProvider,
|
|
35
35
|
normalizeTools,
|
|
36
36
|
resolveOwnedDialectFromEnv,
|
|
37
|
+
steeringQueueState,
|
|
37
38
|
unpairedToolCallTail,
|
|
38
39
|
} from "./agent-loop";
|
|
39
40
|
import type { AppendOnlyContextManager } from "./append-only-context";
|
|
@@ -411,6 +412,14 @@ export class Agent {
|
|
|
411
412
|
#steeringQueue: AgentMessage[] = [];
|
|
412
413
|
#followUpQueue: AgentMessage[] = [];
|
|
413
414
|
#queuedMessageClaims: Partial<Record<QueuedMessageQueue, QueuedMessageClaim>> = {};
|
|
415
|
+
/**
|
|
416
|
+
* Steering live steering took for the in-flight response (`onLiveSteeringTaken`) that the
|
|
417
|
+
* transcript has not recorded yet, whether or not the provider accepted it. Kept apart from
|
|
418
|
+
* {@link #queuedMessageDeliveries}: the loop drops it on abort instead of recording it, so queue
|
|
419
|
+
* replacement must not drop it too; the run's end requeues whatever it did not record, and
|
|
420
|
+
* {@link withdrawLiveSteering} takes it back ahead of an abort.
|
|
421
|
+
*/
|
|
422
|
+
#liveSteered: { message: AgentMessage; controller: AbortController | undefined }[] = [];
|
|
414
423
|
/** Dequeued originals remain recoverable until their transcript events arrive. */
|
|
415
424
|
#queuedMessageDeliveries = new Set<{
|
|
416
425
|
queue: QueuedMessageQueue;
|
|
@@ -992,8 +1001,14 @@ export class Agent {
|
|
|
992
1001
|
}
|
|
993
1002
|
|
|
994
1003
|
#restoreUndeliveredQueuedMessages(controller: AbortController): void {
|
|
995
|
-
if (this.#queuedMessageDeliveries.size === 0) return;
|
|
1004
|
+
if (this.#queuedMessageDeliveries.size === 0 && this.#liveSteered.length === 0) return;
|
|
996
1005
|
const restored: Record<QueuedMessageQueue, AgentMessage[]> = { steering: [], followUp: [] };
|
|
1006
|
+
// Live steering was taken before anything the run still holds undelivered.
|
|
1007
|
+
this.#liveSteered = this.#liveSteered.filter(entry => {
|
|
1008
|
+
if (entry.controller !== controller) return true;
|
|
1009
|
+
restored.steering.push(entry.message);
|
|
1010
|
+
return false;
|
|
1011
|
+
});
|
|
997
1012
|
for (const delivery of this.#queuedMessageDeliveries) {
|
|
998
1013
|
if (delivery.controller !== controller) continue;
|
|
999
1014
|
this.#queuedMessageDeliveries.delete(delivery);
|
|
@@ -1008,6 +1023,40 @@ export class Agent {
|
|
|
1008
1023
|
if (restored.followUp.length > 0) this.#followUpQueue = [...restored.followUp, ...this.#followUpQueue];
|
|
1009
1024
|
}
|
|
1010
1025
|
|
|
1026
|
+
/** Move steering live steering took out of the queue-delivery records into {@link #liveSteered}. */
|
|
1027
|
+
#adoptLiveSteering(taken: readonly AgentMessage[]): void {
|
|
1028
|
+
for (const delivery of this.#queuedMessageDeliveries) {
|
|
1029
|
+
const pending = delivery.messages.slice(delivery.next);
|
|
1030
|
+
const kept = pending.filter(message => !taken.includes(message));
|
|
1031
|
+
if (kept.length === pending.length) continue;
|
|
1032
|
+
for (const message of pending) {
|
|
1033
|
+
if (taken.includes(message)) this.#liveSteered.push({ message, controller: delivery.controller });
|
|
1034
|
+
}
|
|
1035
|
+
if (kept.length === 0) {
|
|
1036
|
+
this.#queuedMessageDeliveries.delete(delivery);
|
|
1037
|
+
} else {
|
|
1038
|
+
delivery.messages = kept;
|
|
1039
|
+
delivery.next = 0;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Take back live-steered messages ahead of an abort (Esc restores them to the editor):
|
|
1046
|
+
* the aborted run then neither records nor requeues them.
|
|
1047
|
+
*/
|
|
1048
|
+
withdrawLiveSteering(): AgentMessage[] {
|
|
1049
|
+
const messages = this.peekLiveSteeredMessages();
|
|
1050
|
+
this.#liveSteered = [];
|
|
1051
|
+
return messages;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/** Steering live steering took for the streaming response; the transcript records it once
|
|
1055
|
+
* that response (or its tool batch) ends, which is when the model switches to it. */
|
|
1056
|
+
peekLiveSteeredMessages(): AgentMessage[] {
|
|
1057
|
+
return this.#liveSteered.map(entry => entry.message);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1011
1060
|
setProviderResponseInterceptor(fn: SimpleStreamOptions["onResponse"] | undefined): void {
|
|
1012
1061
|
this.#onResponse = fn;
|
|
1013
1062
|
}
|
|
@@ -1147,6 +1196,11 @@ export class Agent {
|
|
|
1147
1196
|
|
|
1148
1197
|
appendMessage(m: AgentMessage) {
|
|
1149
1198
|
this.#state.messages.push(m);
|
|
1199
|
+
const live = this.#liveSteered.findIndex(entry => entry.message === m);
|
|
1200
|
+
if (live >= 0) {
|
|
1201
|
+
this.#liveSteered.splice(live, 1);
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1150
1204
|
for (const delivery of this.#queuedMessageDeliveries) {
|
|
1151
1205
|
if (delivery.messages[delivery.next] !== m) continue;
|
|
1152
1206
|
if (++delivery.next === delivery.messages.length) this.#queuedMessageDeliveries.delete(delivery);
|
|
@@ -1202,6 +1256,7 @@ export class Agent {
|
|
|
1202
1256
|
clearAllQueues() {
|
|
1203
1257
|
this.#steeringQueue = [];
|
|
1204
1258
|
this.#followUpQueue = [];
|
|
1259
|
+
this.#liveSteered = [];
|
|
1205
1260
|
this.#cancelQueuedMessagePreparation("steering");
|
|
1206
1261
|
this.#cancelQueuedMessagePreparation("followUp");
|
|
1207
1262
|
this.#notifySteeringWaiters();
|
|
@@ -1227,6 +1282,18 @@ export class Agent {
|
|
|
1227
1282
|
return claim ? [...claim.messages, ...this.#steeringQueue] : this.#steeringQueue;
|
|
1228
1283
|
}
|
|
1229
1284
|
|
|
1285
|
+
/** Dequeued messages not yet in the transcript, e.g. steering a provider
|
|
1286
|
+
* took into its in-flight response via live steering. Aborting the run
|
|
1287
|
+
* requeues them, so the session's empty-submit interrupt counts them as
|
|
1288
|
+
* pending input even though {@link peekSteeringQueue} no longer does. */
|
|
1289
|
+
peekUndeliveredQueuedMessages(): AgentMessage[] {
|
|
1290
|
+
const messages = this.peekLiveSteeredMessages();
|
|
1291
|
+
for (const delivery of this.#queuedMessageDeliveries) {
|
|
1292
|
+
for (let i = delivery.next; i < delivery.messages.length; i++) messages.push(delivery.messages[i]);
|
|
1293
|
+
}
|
|
1294
|
+
return messages;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1230
1297
|
/** Non-consuming view of the pending follow-up queue. See
|
|
1231
1298
|
* {@link peekSteeringQueue}. */
|
|
1232
1299
|
peekFollowUpQueue(): readonly AgentMessage[] {
|
|
@@ -1702,28 +1769,15 @@ export class Agent {
|
|
|
1702
1769
|
}
|
|
1703
1770
|
return this.#dequeueSteeringMessagesAfterHooks(signal ?? loopSignal);
|
|
1704
1771
|
},
|
|
1705
|
-
hasSteeringMessages: () =>
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
const message = this.#steeringQueue[i];
|
|
1713
|
-
const role = "role" in message ? message.role : undefined;
|
|
1714
|
-
const attribution = "attribution" in message ? message.attribution : undefined;
|
|
1715
|
-
if (attribution === "user") {
|
|
1716
|
-
return { queued: true, source: "user" };
|
|
1717
|
-
}
|
|
1718
|
-
if (role !== "user") continue;
|
|
1719
|
-
if (attribution !== "agent") {
|
|
1720
|
-
return { queued: true, source: "user" };
|
|
1721
|
-
}
|
|
1722
|
-
hasAgentSteering = true;
|
|
1723
|
-
}
|
|
1724
|
-
return { queued: true, source: hasAgentSteering ? "agent" : "system" };
|
|
1725
|
-
},
|
|
1772
|
+
hasSteeringMessages: () =>
|
|
1773
|
+
steeringQueueState(
|
|
1774
|
+
this.#steeringQueue,
|
|
1775
|
+
this.#steeringMode === "one-at-a-time"
|
|
1776
|
+
? Math.min(1, this.#steeringQueue.length)
|
|
1777
|
+
: this.#steeringQueue.length,
|
|
1778
|
+
),
|
|
1726
1779
|
waitForSteeringMessages: signal => this.#waitForSteeringMessages(signal),
|
|
1780
|
+
onLiveSteeringTaken: messages => this.#adoptLiveSteering(messages),
|
|
1727
1781
|
hasIrcInterrupts: this.hasIrcInterrupts,
|
|
1728
1782
|
hasBackgroundCompletions: this.hasBackgroundCompletions,
|
|
1729
1783
|
getFollowUpMessages: signal => this.#dequeueFollowUpMessagesAfterHooks(signal ?? loopSignal),
|
|
@@ -255,7 +255,7 @@ export async function requestAnthropicNativeCompaction(
|
|
|
255
255
|
throw new Error(
|
|
256
256
|
response.stopDetails?.type === "compaction"
|
|
257
257
|
? "Anthropic compaction returned no signed summary"
|
|
258
|
-
:
|
|
258
|
+
: `Anthropic compaction response carried no compaction block (stop reason: ${response.stopDetails?.type ?? response.stopReason})`,
|
|
259
259
|
);
|
|
260
260
|
}
|
|
261
261
|
return {
|
|
@@ -120,7 +120,7 @@ function createPrunedNotice(tokens: number): string {
|
|
|
120
120
|
* own rules: useless already drops no-savings candidates, superseded prunes for
|
|
121
121
|
* correctness regardless of size.
|
|
122
122
|
*/
|
|
123
|
-
const MIN_PRUNE_TOKENS = 50;
|
|
123
|
+
export const MIN_PRUNE_TOKENS = 50;
|
|
124
124
|
|
|
125
125
|
function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefined {
|
|
126
126
|
if (entry.type !== "message") return undefined;
|
|
@@ -47,6 +47,31 @@ export function isTranscriptUsageAnchor(message: AgentMessage): message is Assis
|
|
|
47
47
|
return assistant.usage !== undefined && hasContextTokenUsage(assistant.usage);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Newest `prunedAt` in `messages`, or `-Infinity` when nothing was pruned.
|
|
52
|
+
*
|
|
53
|
+
* A pruned tool result was rewritten in place, so every usage report made at
|
|
54
|
+
* or before this time still counts the removed bytes.
|
|
55
|
+
*/
|
|
56
|
+
function newestPrunedAt(messages: readonly AgentMessage[]): number {
|
|
57
|
+
let rewriteAt = Number.NEGATIVE_INFINITY;
|
|
58
|
+
for (const message of messages) {
|
|
59
|
+
if (message.role === "toolResult" && message.prunedAt !== undefined) {
|
|
60
|
+
rewriteAt = Math.max(rewriteAt, message.prunedAt);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return rewriteAt;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Options for {@link findTranscriptUsageAnchor}. */
|
|
67
|
+
export interface TranscriptUsageAnchorOptions {
|
|
68
|
+
/**
|
|
69
|
+
* Also treat usage reported at or before the newest `prunedAt` as stale,
|
|
70
|
+
* the same rule {@link findRequestUsageAnchor} applies to request contexts.
|
|
71
|
+
*/
|
|
72
|
+
skipPrunedAnchors?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
50
75
|
/**
|
|
51
76
|
* Newest assistant turn in `messages[fromIndex..]` whose usage can anchor the
|
|
52
77
|
* transcript, or `undefined` when none qualifies (fresh context, or every
|
|
@@ -58,10 +83,12 @@ export function isTranscriptUsageAnchor(message: AgentMessage): message is Assis
|
|
|
58
83
|
export function findTranscriptUsageAnchor(
|
|
59
84
|
messages: readonly AgentMessage[],
|
|
60
85
|
fromIndex = 0,
|
|
86
|
+
options?: TranscriptUsageAnchorOptions,
|
|
61
87
|
): TranscriptUsageAnchor | undefined {
|
|
88
|
+
const rewriteAt = options?.skipPrunedAnchors === true ? newestPrunedAt(messages) : Number.NEGATIVE_INFINITY;
|
|
62
89
|
for (let index = messages.length - 1; index >= fromIndex; index--) {
|
|
63
90
|
const message = messages[index];
|
|
64
|
-
if (!isTranscriptUsageAnchor(message)) continue;
|
|
91
|
+
if (!isTranscriptUsageAnchor(message) || message.timestamp <= rewriteAt) continue;
|
|
65
92
|
return { index, message, tokens: calculateContextTokens(message.usage) };
|
|
66
93
|
}
|
|
67
94
|
return undefined;
|
|
@@ -105,6 +132,8 @@ export interface TranscriptTokenOptions {
|
|
|
105
132
|
* accounting is governed separately by {@link countFromIndex}.
|
|
106
133
|
*/
|
|
107
134
|
anchorFromIndex?: number;
|
|
135
|
+
/** Forwarded to {@link findTranscriptUsageAnchor}. */
|
|
136
|
+
skipPrunedAnchors?: boolean;
|
|
108
137
|
/**
|
|
109
138
|
* First message whose content is counted locally when no anchor is found.
|
|
110
139
|
* Defaults to 0 (count the whole transcript), which is what a floor
|
|
@@ -132,7 +161,9 @@ export function estimateTranscriptTokens(
|
|
|
132
161
|
): number {
|
|
133
162
|
const estimateOptions: MessageCountOptions | undefined =
|
|
134
163
|
options?.excludeEncryptedReasoning === true ? { excludeEncryptedReasoning: true } : undefined;
|
|
135
|
-
const anchor = findTranscriptUsageAnchor(messages, options?.anchorFromIndex ?? 0
|
|
164
|
+
const anchor = findTranscriptUsageAnchor(messages, options?.anchorFromIndex ?? 0, {
|
|
165
|
+
skipPrunedAnchors: options?.skipPrunedAnchors,
|
|
166
|
+
});
|
|
136
167
|
let total = anchor?.tokens ?? 0;
|
|
137
168
|
for (let index = anchor ? anchor.index + 1 : (options?.countFromIndex ?? 0); index < messages.length; index++) {
|
|
138
169
|
total += tokenizer.countMessage(messages[index], estimateOptions);
|
package/src/types.ts
CHANGED
|
@@ -298,6 +298,14 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
298
298
|
*/
|
|
299
299
|
waitForSteeringMessages?: (signal?: AbortSignal) => Promise<void>;
|
|
300
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Called when live steering dequeues messages via {@link getSteeringMessages}
|
|
303
|
+
* for the response being streamed. The loop records them in the transcript
|
|
304
|
+
* after that response (or its tool batch); an abort before then leaves them
|
|
305
|
+
* unrecorded for the host to requeue.
|
|
306
|
+
*/
|
|
307
|
+
onLiveSteeringTaken?: (messages: AgentMessage[]) => void;
|
|
308
|
+
|
|
301
309
|
/**
|
|
302
310
|
* Peeks whether IRC messages should interrupt an interruptible waiting tool.
|
|
303
311
|
*
|