@cjhyy/code-shell-core 0.6.0-rc.8 → 0.6.0-rc.9
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/dist/context/compaction.d.ts +30 -0
- package/dist/context/compaction.js +93 -0
- package/dist/context/manager.d.ts +18 -0
- package/dist/context/manager.js +156 -44
- package/dist/context/token-counter.js +13 -0
- package/dist/engine/engine.d.ts +21 -11
- package/dist/engine/engine.js +232 -76
- package/dist/engine/model-facade.js +2 -12
- package/dist/engine/query.js +2 -0
- package/dist/engine/session-usage.d.ts +12 -0
- package/dist/engine/session-usage.js +56 -0
- package/dist/engine/steer-queue.d.ts +2 -1
- package/dist/engine/steer-queue.js +2 -2
- package/dist/engine/turn-loop.d.ts +28 -2
- package/dist/engine/turn-loop.js +153 -26
- package/dist/protocol/chat-session.d.ts +2 -0
- package/dist/protocol/chat-session.js +1 -0
- package/dist/protocol/client.d.ts +4 -1
- package/dist/protocol/client.js +8 -2
- package/dist/protocol/server.d.ts +13 -12
- package/dist/protocol/server.js +83 -61
- package/dist/protocol/types.d.ts +4 -0
- package/dist/runtime/safe-spawn.js +74 -11
- package/dist/session/session-manager.js +7 -1
- package/dist/session/transcript.d.ts +4 -0
- package/dist/session/transcript.js +21 -0
- package/dist/tool-system/mcp-manager.js +17 -0
- package/dist/tool-system/mcp-stdio-diagnostics.d.ts +9 -0
- package/dist/tool-system/mcp-stdio-diagnostics.js +93 -0
- package/dist/types.d.ts +31 -1
- package/package.json +1 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/** Append a steer entry. Blank text is dropped (returns the list unchanged). */
|
|
2
|
-
export function enqueueSteerItem(list, id, text) {
|
|
2
|
+
export function enqueueSteerItem(list, id, text, clientMessageId) {
|
|
3
3
|
const t = text?.trim();
|
|
4
4
|
if (!id || !t)
|
|
5
5
|
return list;
|
|
6
|
-
return [...list, { id, text: t }];
|
|
6
|
+
return [...list, { id, text: t, ...(clientMessageId ? { clientMessageId } : {}) }];
|
|
7
7
|
}
|
|
8
8
|
/**
|
|
9
9
|
* Take everything currently queued and clear the list. Returns the drained
|
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
* Following Claude Code's po_() pattern:
|
|
5
5
|
* pre_check → model_call → post_check → tool_exec → context_mgmt → hook_notify → next turn
|
|
6
6
|
*/
|
|
7
|
-
import type { Message, StreamCallback, TerminalReason, ContentBlock, ToolResult } from "../types.js";
|
|
7
|
+
import type { Message, StreamCallback, TerminalReason, ContentBlock, ToolResult, TokenUsage } from "../types.js";
|
|
8
8
|
import type { SteerItem } from "./steer-queue.js";
|
|
9
9
|
import { ModelFacade } from "./model-facade.js";
|
|
10
10
|
import { ToolExecutor } from "../tool-system/executor.js";
|
|
11
11
|
import { ContextManager } from "../context/manager.js";
|
|
12
12
|
import { HookRegistry } from "../hooks/registry.js";
|
|
13
13
|
import { Transcript } from "../session/transcript.js";
|
|
14
|
+
import { type CumulativeUsageCounters } from "./session-usage.js";
|
|
14
15
|
import { type GoalConfig, type GoalExtension } from "./goal.js";
|
|
15
16
|
export interface TurnLoopConfig {
|
|
16
17
|
maxTurns: number;
|
|
@@ -18,6 +19,12 @@ export interface TurnLoopConfig {
|
|
|
18
19
|
tokenBudget?: number;
|
|
19
20
|
onStream?: StreamCallback;
|
|
20
21
|
signal?: AbortSignal;
|
|
22
|
+
/**
|
|
23
|
+
* Image-bearing messages added for the current request and not yet consumed
|
|
24
|
+
* by the model. TurnLoop preserves their base64 for one successful model
|
|
25
|
+
* response, then downgrades them to placeholders in the working history.
|
|
26
|
+
*/
|
|
27
|
+
freshImageMessages?: Iterable<Message>;
|
|
21
28
|
/**
|
|
22
29
|
* Fired after each turn boundary is recorded. Lets the engine flush an
|
|
23
30
|
* up-to-date snapshot to state.json mid-run, so a long run doesn't leave
|
|
@@ -76,6 +83,11 @@ export interface TurnLoopDeps {
|
|
|
76
83
|
before: number;
|
|
77
84
|
after: number;
|
|
78
85
|
} | null;
|
|
86
|
+
/**
|
|
87
|
+
* Records one LLM response into the session-monotonic cumulative counters.
|
|
88
|
+
* Returns the updated counters so usage_update can carry both metric scopes.
|
|
89
|
+
*/
|
|
90
|
+
recordCumulativeUsage?: (usage: TokenUsage) => CumulativeUsageCounters;
|
|
79
91
|
/**
|
|
80
92
|
* Reads/clears any user messages queued for THIS session via the steering
|
|
81
93
|
* channel (Engine.enqueueSteer) while a run is in flight. Consumed at the top
|
|
@@ -84,7 +96,13 @@ export interface TurnLoopDeps {
|
|
|
84
96
|
* Returns [] when nothing is queued. Wired by Engine; absent in standalone
|
|
85
97
|
* tests (turn loop tolerates undefined).
|
|
86
98
|
*/
|
|
87
|
-
consumeSteer?: () => SteerItem[];
|
|
99
|
+
consumeSteer?: (source?: "normal_step" | "finalize_backfill") => SteerItem[];
|
|
100
|
+
/**
|
|
101
|
+
* Execution-level idempotency guard for host-supplied user/steer intents.
|
|
102
|
+
* Returns false only when a present clientMessageId has already entered this
|
|
103
|
+
* run or the persisted transcript. Messages without an id bypass this guard.
|
|
104
|
+
*/
|
|
105
|
+
claimClientMessageId?: (clientMessageId: string, source: "steer") => boolean;
|
|
88
106
|
/**
|
|
89
107
|
* Clear this session's PERSISTED goal (state.activeGoal) and drop the
|
|
90
108
|
* in-flight goal-stop hook, so a user-initiated cancel_goal both stops the
|
|
@@ -121,6 +139,9 @@ export declare class TurnLoop {
|
|
|
121
139
|
private currentTurnLog;
|
|
122
140
|
/** Last emitted ctx token estimate; used to skip no-op usage_update events. */
|
|
123
141
|
private lastCtxEmit;
|
|
142
|
+
private currentTurnUsage;
|
|
143
|
+
private currentCumulativeUsage;
|
|
144
|
+
private readonly pendingImageMessages;
|
|
124
145
|
/**
|
|
125
146
|
* Consecutive on_stop blocks (Goal mode kept the agent going). Reset to 0
|
|
126
147
|
* on any unblocked completion. When it reaches config.maxStopBlocks the
|
|
@@ -178,6 +199,9 @@ export declare class TurnLoop {
|
|
|
178
199
|
* for one Stop still writes a single marker.
|
|
179
200
|
*/
|
|
180
201
|
private markStopped;
|
|
202
|
+
private prepareMessagesForModel;
|
|
203
|
+
private markPendingImagesConsumed;
|
|
204
|
+
private trackFreshImageMessage;
|
|
181
205
|
private emitHook;
|
|
182
206
|
/**
|
|
183
207
|
* Emit a usage_update so the UI ctx bar reflects current message-array
|
|
@@ -187,6 +211,7 @@ export declare class TurnLoop {
|
|
|
187
211
|
* promptTokens to override our estimate).
|
|
188
212
|
*/
|
|
189
213
|
private emitCtxFromMessages;
|
|
214
|
+
private recordResponseUsage;
|
|
190
215
|
private emitCtxFromUsage;
|
|
191
216
|
/**
|
|
192
217
|
* Run the multi-turn agent loop until completion.
|
|
@@ -198,6 +223,7 @@ export declare class TurnLoop {
|
|
|
198
223
|
*/
|
|
199
224
|
private callModelWithFallback;
|
|
200
225
|
get currentTurn(): number;
|
|
226
|
+
private consumeQueuedSteer;
|
|
201
227
|
/**
|
|
202
228
|
* Generate synthetic error tool_results for any dangling tool_use blocks
|
|
203
229
|
* that never received results (e.g. because the API call failed).
|
package/dist/engine/turn-loop.js
CHANGED
|
@@ -11,12 +11,13 @@ import { ContextLimitError } from "../exceptions.js";
|
|
|
11
11
|
import { logger } from "../logging/logger.js";
|
|
12
12
|
import { checkTokenBudget, createBudgetTracker } from "./token-budget.js";
|
|
13
13
|
import { StreamingToolQueue } from "./streaming-tool-queue.js";
|
|
14
|
-
import { estimateTokens } from "../context/compaction.js";
|
|
14
|
+
import { downgradeImagePayloadsInHistory, estimateTokens, messageHasBase64ImagePayload, } from "../context/compaction.js";
|
|
15
15
|
import { isTruncatedStop } from "../llm/stop-reason.js";
|
|
16
16
|
import { isAbortError } from "../llm/client-base.js";
|
|
17
17
|
import { crossedReactiveThreshold } from "./reactive-threshold.js";
|
|
18
18
|
import { COMPLETE_GOAL_TOOL_NAME } from "../tool-system/builtin/complete-goal.js";
|
|
19
19
|
import { CANCEL_GOAL_TOOL_NAME } from "../tool-system/builtin/cancel-goal.js";
|
|
20
|
+
import { addTokenUsage, cacheHitRateFromUsage, cumulativeCacheHitRate, } from "./session-usage.js";
|
|
20
21
|
import { createGoalBudgetTracker, recordGoalUsage, goalBudgetExceeded, applyGoalExtension, limitProximity, GOAL_DEFAULT_MAX_STOP_BLOCKS, } from "./goal.js";
|
|
21
22
|
/**
|
|
22
23
|
* 把一个 ToolResult 映射成发给 LLM 的 tool_result ContentBlock。
|
|
@@ -29,7 +30,7 @@ export function toolResultToBlock(result) {
|
|
|
29
30
|
tool_use_id: result.id,
|
|
30
31
|
content: result.error
|
|
31
32
|
? `Error: ${result.error}`
|
|
32
|
-
: result.contentBlocks ??
|
|
33
|
+
: (result.contentBlocks ?? result.result ?? "(no output)"),
|
|
33
34
|
};
|
|
34
35
|
if (result.isError || result.error)
|
|
35
36
|
block.is_error = true;
|
|
@@ -50,6 +51,15 @@ export class TurnLoop {
|
|
|
50
51
|
currentTurnLog = logger;
|
|
51
52
|
/** Last emitted ctx token estimate; used to skip no-op usage_update events. */
|
|
52
53
|
lastCtxEmit = -1;
|
|
54
|
+
currentTurnUsage = {
|
|
55
|
+
promptTokens: 0,
|
|
56
|
+
completionTokens: 0,
|
|
57
|
+
totalTokens: 0,
|
|
58
|
+
cacheReadTokens: 0,
|
|
59
|
+
cacheCreationTokens: 0,
|
|
60
|
+
};
|
|
61
|
+
currentCumulativeUsage;
|
|
62
|
+
pendingImageMessages = new Set();
|
|
53
63
|
/**
|
|
54
64
|
* Consecutive on_stop blocks (Goal mode kept the agent going). Reset to 0
|
|
55
65
|
* on any unblocked completion. When it reaches config.maxStopBlocks the
|
|
@@ -145,6 +155,9 @@ export class TurnLoop {
|
|
|
145
155
|
config) {
|
|
146
156
|
this.deps = deps;
|
|
147
157
|
this.config = config;
|
|
158
|
+
for (const msg of this.config.freshImageMessages ?? []) {
|
|
159
|
+
this.pendingImageMessages.add(msg);
|
|
160
|
+
}
|
|
148
161
|
// Wrap onStream so a single throwing handler can't silently break
|
|
149
162
|
// the channel for the rest of the run. A 2026-05-25 incident saw a
|
|
150
163
|
// sub-agent's events stop reaching the renderer ~23s into its run —
|
|
@@ -197,6 +210,38 @@ export class TurnLoop {
|
|
|
197
210
|
return;
|
|
198
211
|
this.deps.transcript.appendTurnStopped();
|
|
199
212
|
}
|
|
213
|
+
prepareMessagesForModel(messages) {
|
|
214
|
+
const preserveMessages = this.pendingImageMessages.size > 0 ? this.pendingImageMessages : undefined;
|
|
215
|
+
const result = downgradeImagePayloadsInHistory(messages, { preserveMessages });
|
|
216
|
+
if (result.replacedCount > 0) {
|
|
217
|
+
this.currentTurnLog.info("context.image_payload_downgrade", {
|
|
218
|
+
cat: "context",
|
|
219
|
+
images: result.replacedCount,
|
|
220
|
+
pendingFresh: this.pendingImageMessages.size,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return result.messages;
|
|
224
|
+
}
|
|
225
|
+
markPendingImagesConsumed(messages) {
|
|
226
|
+
if (this.pendingImageMessages.size === 0)
|
|
227
|
+
return messages;
|
|
228
|
+
const consumedMessages = this.pendingImageMessages.size;
|
|
229
|
+
this.pendingImageMessages.clear();
|
|
230
|
+
const result = downgradeImagePayloadsInHistory(messages);
|
|
231
|
+
if (result.replacedCount > 0) {
|
|
232
|
+
this.currentTurnLog.info("context.image_payload_consumed", {
|
|
233
|
+
cat: "context",
|
|
234
|
+
images: result.replacedCount,
|
|
235
|
+
messages: consumedMessages,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return result.messages;
|
|
239
|
+
}
|
|
240
|
+
trackFreshImageMessage(message) {
|
|
241
|
+
if (messageHasBase64ImagePayload(message)) {
|
|
242
|
+
this.pendingImageMessages.add(message);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
200
245
|
async emitHook(event, data = {}) {
|
|
201
246
|
return this.deps.hooks.emit(event, {
|
|
202
247
|
...data,
|
|
@@ -238,6 +283,10 @@ export class TurnLoop {
|
|
|
238
283
|
this.lastCtxEmit = ctx;
|
|
239
284
|
this.config.onStream({ type: "usage_update", promptTokens: ctx });
|
|
240
285
|
}
|
|
286
|
+
recordResponseUsage(usage) {
|
|
287
|
+
this.currentTurnUsage = addTokenUsage(this.currentTurnUsage, usage);
|
|
288
|
+
this.currentCumulativeUsage = this.deps.recordCumulativeUsage?.(usage);
|
|
289
|
+
}
|
|
241
290
|
emitCtxFromUsage(usage, messages) {
|
|
242
291
|
if (!this.config.onStream)
|
|
243
292
|
return;
|
|
@@ -254,23 +303,46 @@ export class TurnLoop {
|
|
|
254
303
|
derivedOverhead: overhead,
|
|
255
304
|
prev: this.lastCtxEmit,
|
|
256
305
|
});
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
306
|
+
const promptChanged = promptTokens !== this.lastCtxEmit;
|
|
307
|
+
if (promptChanged)
|
|
308
|
+
this.lastCtxEmit = promptTokens;
|
|
260
309
|
// Forward the provider's cache counts so the UI can show a hit rate. Only
|
|
261
310
|
// attach fields the provider actually reported — a spread keeps them off
|
|
262
311
|
// the event entirely when undefined, so the renderer can tell "no cache
|
|
263
312
|
// info this turn" from "0 cached". Estimate-path emits don't call this and
|
|
264
313
|
// so carry no cache fields (correct: an estimate has no cache reading).
|
|
314
|
+
const singleTurnCacheHitRate = cacheHitRateFromUsage(this.currentTurnUsage);
|
|
315
|
+
const cumulative = this.currentCumulativeUsage;
|
|
316
|
+
const cumulativeHitRate = cumulative ? cumulativeCacheHitRate(cumulative) : undefined;
|
|
317
|
+
if (!promptChanged && singleTurnCacheHitRate === undefined && cumulativeHitRate === undefined) {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
265
320
|
this.config.onStream({
|
|
266
321
|
type: "usage_update",
|
|
267
322
|
promptTokens,
|
|
268
|
-
...(usage.cacheReadTokens !== undefined
|
|
269
|
-
? { cacheReadTokens: usage.cacheReadTokens }
|
|
270
|
-
: {}),
|
|
323
|
+
...(usage.cacheReadTokens !== undefined ? { cacheReadTokens: usage.cacheReadTokens } : {}),
|
|
271
324
|
...(usage.cacheCreationTokens !== undefined
|
|
272
325
|
? { cacheCreationTokens: usage.cacheCreationTokens }
|
|
273
326
|
: {}),
|
|
327
|
+
singleTurnPromptTokens: this.currentTurnUsage.promptTokens,
|
|
328
|
+
singleTurnCacheReadTokens: this.currentTurnUsage.cacheReadTokens ?? 0,
|
|
329
|
+
singleTurnCacheCreationTokens: this.currentTurnUsage.cacheCreationTokens ?? 0,
|
|
330
|
+
...(singleTurnCacheHitRate !== undefined ? { singleTurnCacheHitRate } : {}),
|
|
331
|
+
...(cumulative
|
|
332
|
+
? {
|
|
333
|
+
cumulativePromptTokens: cumulative.cumulativePromptTokens,
|
|
334
|
+
cumulativeCacheReadTokens: cumulative.cumulativeCacheReadTokens,
|
|
335
|
+
cumulativeCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
|
|
336
|
+
...(cumulativeHitRate !== undefined
|
|
337
|
+
? { cumulativeCacheHitRate: cumulativeHitRate }
|
|
338
|
+
: {}),
|
|
339
|
+
// Legacy aliases for existing renderer builds. New UI code reads the
|
|
340
|
+
// cumulative* fields above.
|
|
341
|
+
sessionPromptTokens: cumulative.cumulativePromptTokens,
|
|
342
|
+
sessionCacheReadTokens: cumulative.cumulativeCacheReadTokens,
|
|
343
|
+
sessionCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
|
|
344
|
+
}
|
|
345
|
+
: {}),
|
|
274
346
|
});
|
|
275
347
|
}
|
|
276
348
|
/**
|
|
@@ -300,6 +372,13 @@ export class TurnLoop {
|
|
|
300
372
|
try {
|
|
301
373
|
while (this.turnCount < this.config.maxTurns) {
|
|
302
374
|
this.turnCount++;
|
|
375
|
+
this.currentTurnUsage = {
|
|
376
|
+
promptTokens: 0,
|
|
377
|
+
completionTokens: 0,
|
|
378
|
+
totalTokens: 0,
|
|
379
|
+
cacheReadTokens: 0,
|
|
380
|
+
cacheCreationTokens: 0,
|
|
381
|
+
};
|
|
303
382
|
// Abort fast-path: bail at the loop TOP before doing any per-turn work.
|
|
304
383
|
// Without this, an aborted child (parent abort, or the 30min per-call
|
|
305
384
|
// registry timeout) would run a full contextManager.manageAsync (itself
|
|
@@ -319,14 +398,7 @@ export class TurnLoop {
|
|
|
319
398
|
// they join THIS step's request — no abort, no lost in-flight work. Same
|
|
320
399
|
// loop-top user-push pattern as turnStartInjection / turn-limit warnings
|
|
321
400
|
// below. Push to transcript too so they persist + survive resume.
|
|
322
|
-
|
|
323
|
-
for (const { id, text } of steered) {
|
|
324
|
-
if (!text)
|
|
325
|
-
continue;
|
|
326
|
-
messages.push({ role: "user", content: text });
|
|
327
|
-
this.deps.transcript.appendMessage("user", text);
|
|
328
|
-
this.config.onStream?.({ type: "steer_injected", text, id });
|
|
329
|
-
}
|
|
401
|
+
this.consumeQueuedSteer(messages, "normal_step");
|
|
330
402
|
const state = initialTurnState(this.turnCount);
|
|
331
403
|
// Per-turn correlation ID. Every log written through `tlog` (or any
|
|
332
404
|
// child derived from it) is stamped with `turn` + `turnId`, so
|
|
@@ -378,7 +450,11 @@ export class TurnLoop {
|
|
|
378
450
|
// Goal mode: announce once when nearing EITHER stop ceiling (turns or
|
|
379
451
|
// stop-blocks) so the UI can offer a "再续" button while still live.
|
|
380
452
|
this.maybeAnnounceApproachingLimit();
|
|
381
|
-
// Pre-check:
|
|
453
|
+
// Pre-check: downgrade image payloads that have already had their one
|
|
454
|
+
// model-consumption turn, then run context management. Fresh images in
|
|
455
|
+
// pendingImageMessages are preserved through this next model request.
|
|
456
|
+
messages = this.prepareMessagesForModel(messages);
|
|
457
|
+
// Context management (async — may trigger LLM summarization)
|
|
382
458
|
messages = await this.deps.contextManager.manageAsync(messages);
|
|
383
459
|
// manageAsync can itself issue an LLM summarization call lasting several
|
|
384
460
|
// seconds; if the signal aborted during it, stop here rather than
|
|
@@ -468,8 +544,9 @@ export class TurnLoop {
|
|
|
468
544
|
return { text: finalText, reason: "model_error", messages };
|
|
469
545
|
}
|
|
470
546
|
}
|
|
471
|
-
//
|
|
547
|
+
// Record the response once into current-turn and whole-session counters.
|
|
472
548
|
if (response.usage?.promptTokens !== undefined) {
|
|
549
|
+
this.recordResponseUsage(response.usage);
|
|
473
550
|
this.emitCtxFromUsage(response.usage, messages);
|
|
474
551
|
}
|
|
475
552
|
// Feed actual token usage back to the context manager so subsequent
|
|
@@ -478,6 +555,7 @@ export class TurnLoop {
|
|
|
478
555
|
if (response.usage?.promptTokens !== undefined) {
|
|
479
556
|
this.deps.contextManager.recordActualUsage(response.usage.promptTokens, messages.length);
|
|
480
557
|
}
|
|
558
|
+
messages = this.markPendingImagesConsumed(messages);
|
|
481
559
|
// Truncation that cut off a TOOL CALL: the model overflowed
|
|
482
560
|
// max_output_tokens mid tool-call, so the arg JSON is incomplete (e.g. a
|
|
483
561
|
// Write whose `content` was clipped, leaving file_path unset). Executing
|
|
@@ -506,6 +584,7 @@ export class TurnLoop {
|
|
|
506
584
|
response.toolCalls.length === 0 &&
|
|
507
585
|
response.text) {
|
|
508
586
|
let combinedText = response.text;
|
|
587
|
+
let continuedResponse = false;
|
|
509
588
|
for (let retry = 0; retry < 3; retry++) {
|
|
510
589
|
// Don't fire another continuation call if the user cancelled — without
|
|
511
590
|
// this an abort during a truncated response could still issue up to 3
|
|
@@ -522,7 +601,11 @@ export class TurnLoop {
|
|
|
522
601
|
},
|
|
523
602
|
];
|
|
524
603
|
try {
|
|
525
|
-
const contResponse = await this.deps.model.call(this.deps.systemPrompt, contMessages, this.deps.tools, this.config.onStream, this.config.signal);
|
|
604
|
+
const contResponse = await this.deps.model.call(this.deps.systemPrompt, this.prepareMessagesForModel(contMessages), this.deps.tools, this.config.onStream, this.config.signal);
|
|
605
|
+
if (contResponse.usage?.promptTokens !== undefined) {
|
|
606
|
+
this.recordResponseUsage(contResponse.usage);
|
|
607
|
+
continuedResponse = true;
|
|
608
|
+
}
|
|
526
609
|
combinedText += contResponse.text;
|
|
527
610
|
if (!isTruncatedStop(contResponse.stopReason) || contResponse.toolCalls.length > 0) {
|
|
528
611
|
response = { ...contResponse, text: combinedText };
|
|
@@ -534,10 +617,9 @@ export class TurnLoop {
|
|
|
534
617
|
}
|
|
535
618
|
}
|
|
536
619
|
response = { ...response, text: combinedText };
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
this.emitCtxFromUsage(response.usage, messages);
|
|
620
|
+
if (continuedResponse && response.usage?.promptTokens !== undefined) {
|
|
621
|
+
this.emitCtxFromUsage(response.usage, messages);
|
|
622
|
+
}
|
|
541
623
|
}
|
|
542
624
|
// Goal-mode run-scoped accounting: add this turn's total token usage
|
|
543
625
|
// (prompt + completion) to the running total. Done after continuation so
|
|
@@ -587,6 +669,9 @@ export class TurnLoop {
|
|
|
587
669
|
hasToolUse: false,
|
|
588
670
|
});
|
|
589
671
|
messages.push({ role: "assistant", content: finalText });
|
|
672
|
+
if (this.consumeQueuedSteer(messages, "finalize_backfill")) {
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
590
675
|
// on_stop seam: the model wants to stop. Give handlers (Goal mode)
|
|
591
676
|
// a chance to BLOCK termination and keep the agent working. A
|
|
592
677
|
// handler returning continueSession=true injects its messages and
|
|
@@ -668,10 +753,16 @@ export class TurnLoop {
|
|
|
668
753
|
});
|
|
669
754
|
}
|
|
670
755
|
this.stopBlockCount = 0;
|
|
756
|
+
if (this.consumeQueuedSteer(messages, "finalize_backfill")) {
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
671
759
|
return { text: finalText, reason: "completed", messages };
|
|
672
760
|
}
|
|
673
761
|
// Tool execution phase
|
|
674
|
-
tlog.info("turn.tool_use", {
|
|
762
|
+
tlog.info("turn.tool_use", {
|
|
763
|
+
cat: "turn",
|
|
764
|
+
tools: response.toolCalls.map((t) => t.toolName),
|
|
765
|
+
});
|
|
675
766
|
const toolCalls = response.toolCalls.slice(0, this.config.maxToolCallsPerTurn);
|
|
676
767
|
// Per-turn cap: any calls beyond maxToolCallsPerTurn are NOT executed and
|
|
677
768
|
// NOT added to the assistant message below, so the model never sees a
|
|
@@ -734,7 +825,9 @@ export class TurnLoop {
|
|
|
734
825
|
});
|
|
735
826
|
});
|
|
736
827
|
}
|
|
737
|
-
|
|
828
|
+
const toolResultMessage = { role: "user", content: resultBlocks };
|
|
829
|
+
messages.push(toolResultMessage);
|
|
830
|
+
this.trackFreshImageMessage(toolResultMessage);
|
|
738
831
|
// B-3: tell the model which of its requested tool calls were dropped by
|
|
739
832
|
// the per-turn cap so it can re-issue them, instead of silently assuming
|
|
740
833
|
// they ran. Appended to the same user message that carries the results.
|
|
@@ -773,6 +866,9 @@ export class TurnLoop {
|
|
|
773
866
|
tlog.info("turn.goal_self_reported_complete", { cat: "goal" });
|
|
774
867
|
this.stopBlockCount = 0;
|
|
775
868
|
this.deps.clearPersistedGoal?.();
|
|
869
|
+
if (this.consumeQueuedSteer(messages, "finalize_backfill")) {
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
776
872
|
return { text: finalText, reason: "completed", messages };
|
|
777
873
|
}
|
|
778
874
|
// Goal mode: user-initiated cancellation. cancel_goal is the "strong
|
|
@@ -785,6 +881,9 @@ export class TurnLoop {
|
|
|
785
881
|
tlog.info("turn.goal_user_cancelled", { cat: "goal" });
|
|
786
882
|
this.stopBlockCount = 0;
|
|
787
883
|
this.deps.clearPersistedGoal?.();
|
|
884
|
+
if (this.consumeQueuedSteer(messages, "finalize_backfill")) {
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
788
887
|
return { text: finalText, reason: "completed", messages };
|
|
789
888
|
}
|
|
790
889
|
// Token budget check
|
|
@@ -801,6 +900,9 @@ export class TurnLoop {
|
|
|
801
900
|
message: { role: "assistant", content: finalText },
|
|
802
901
|
});
|
|
803
902
|
messages.push({ role: "assistant", content: finalText });
|
|
903
|
+
if (this.consumeQueuedSteer(messages, "finalize_backfill")) {
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
804
906
|
return { text: finalText, reason: "completed", messages };
|
|
805
907
|
}
|
|
806
908
|
if (budgetDecision === "nudge") {
|
|
@@ -877,6 +979,8 @@ export class TurnLoop {
|
|
|
877
979
|
maxTurns: this.config.maxTurns,
|
|
878
980
|
turnCount: this.turnCount,
|
|
879
981
|
});
|
|
982
|
+
this.consumeQueuedSteer(messages, "finalize_backfill");
|
|
983
|
+
messages = this.prepareMessagesForModel(messages);
|
|
880
984
|
messages = this.deps.contextManager.manage(messages);
|
|
881
985
|
messages.push({
|
|
882
986
|
role: "user",
|
|
@@ -884,8 +988,9 @@ export class TurnLoop {
|
|
|
884
988
|
});
|
|
885
989
|
this.emitCtxFromMessages(messages);
|
|
886
990
|
try {
|
|
887
|
-
const summaryResponse = await this.deps.model.call(this.deps.systemPrompt, messages, [], // No tools available for summary turn
|
|
991
|
+
const summaryResponse = await this.deps.model.call(this.deps.systemPrompt, this.prepareMessagesForModel(messages), [], // No tools available for summary turn
|
|
888
992
|
this.config.onStream, this.config.signal);
|
|
993
|
+
messages = this.markPendingImagesConsumed(messages);
|
|
889
994
|
if (summaryResponse.text) {
|
|
890
995
|
finalText = summaryResponse.text;
|
|
891
996
|
}
|
|
@@ -973,6 +1078,28 @@ export class TurnLoop {
|
|
|
973
1078
|
get currentTurn() {
|
|
974
1079
|
return this.turnCount;
|
|
975
1080
|
}
|
|
1081
|
+
consumeQueuedSteer(messages, source) {
|
|
1082
|
+
const steered = this.deps.consumeSteer?.(source) ?? [];
|
|
1083
|
+
let consumed = false;
|
|
1084
|
+
for (const { id, text, clientMessageId } of steered) {
|
|
1085
|
+
if (!text)
|
|
1086
|
+
continue;
|
|
1087
|
+
if (clientMessageId && this.deps.claimClientMessageId?.(clientMessageId, "steer") === false) {
|
|
1088
|
+
logger.info("steer.submit.duplicate_ignored", {
|
|
1089
|
+
clientMessageId,
|
|
1090
|
+
steerId: id,
|
|
1091
|
+
sessionId: this.deps.sessionId,
|
|
1092
|
+
source,
|
|
1093
|
+
});
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
consumed = true;
|
|
1097
|
+
messages.push({ role: "user", content: text });
|
|
1098
|
+
this.deps.transcript.appendMessage("user", text, { steerId: id, clientMessageId });
|
|
1099
|
+
this.config.onStream?.({ type: "steer_injected", text, id });
|
|
1100
|
+
}
|
|
1101
|
+
return consumed;
|
|
1102
|
+
}
|
|
976
1103
|
/**
|
|
977
1104
|
* Generate synthetic error tool_results for any dangling tool_use blocks
|
|
978
1105
|
* that never received results (e.g. because the API call failed).
|
|
@@ -18,6 +18,8 @@ export interface TurnOpts {
|
|
|
18
18
|
* completion notification) rather than the user's own input — persisted so
|
|
19
19
|
* the disk reader skips it as a user bubble on replay. See Engine.run. */
|
|
20
20
|
injected?: boolean;
|
|
21
|
+
/** Stable id for this user-intent; forwarded to Engine.run for idempotency. */
|
|
22
|
+
clientMessageId?: string;
|
|
21
23
|
}
|
|
22
24
|
/**
|
|
23
25
|
* One ChatSession per UI chat tab. Owns a single Engine, an AbortController
|
|
@@ -85,7 +85,10 @@ export declare class AgentClient {
|
|
|
85
85
|
* the desktop preload's steer(); without it the SDK can't reach the method
|
|
86
86
|
* (request() is private) — the protocol was asymmetric after the steer merge.
|
|
87
87
|
*/
|
|
88
|
-
steer(sessionId: string, text: string, id?: string): Promise<
|
|
88
|
+
steer(sessionId: string, text: string, id?: string, clientMessageId?: string): Promise<{
|
|
89
|
+
accepted: boolean;
|
|
90
|
+
id?: string;
|
|
91
|
+
}>;
|
|
89
92
|
/**
|
|
90
93
|
* Revoke a still-pending steer entry by id (before the loop consumes it).
|
|
91
94
|
* Returns false when the loop already consumed it (not an error — it will
|
package/dist/protocol/client.js
CHANGED
|
@@ -125,8 +125,14 @@ export class AgentClient {
|
|
|
125
125
|
* the desktop preload's steer(); without it the SDK can't reach the method
|
|
126
126
|
* (request() is private) — the protocol was asymmetric after the steer merge.
|
|
127
127
|
*/
|
|
128
|
-
async steer(sessionId, text, id) {
|
|
129
|
-
await this.request(Methods.Steer, {
|
|
128
|
+
async steer(sessionId, text, id, clientMessageId) {
|
|
129
|
+
const res = (await this.request(Methods.Steer, {
|
|
130
|
+
sessionId,
|
|
131
|
+
text,
|
|
132
|
+
id,
|
|
133
|
+
clientMessageId,
|
|
134
|
+
}));
|
|
135
|
+
return { accepted: res?.accepted === true, id: res?.id };
|
|
130
136
|
}
|
|
131
137
|
/**
|
|
132
138
|
* Revoke a still-pending steer entry by id (before the loop consumes it).
|
|
@@ -180,14 +180,11 @@ export declare class AgentServer {
|
|
|
180
180
|
*/
|
|
181
181
|
private requestApprovalFromClient;
|
|
182
182
|
/**
|
|
183
|
-
*
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
* (the chatManager approve handler looks there, keyed by sessionId+requestId)
|
|
189
|
-
* and tags the notify with sessionId so the renderer routes the question to
|
|
190
|
-
* the right chat tab.
|
|
183
|
+
* Per-session AskUserQuestion for the chatManager path. Resolves via the
|
|
184
|
+
* SESSION's pendingApprovals (the chatManager approve handler looks there,
|
|
185
|
+
* keyed by sessionId+requestId) and tags the notify with sessionId so the
|
|
186
|
+
* renderer routes the question to the right chat tab. This intentionally has
|
|
187
|
+
* no wall-clock timeout; Stop/cancel drains the pending ask.
|
|
191
188
|
*/
|
|
192
189
|
private requestAskUserForSession;
|
|
193
190
|
/**
|
|
@@ -206,6 +203,11 @@ export declare class AgentServer {
|
|
|
206
203
|
* parse into { ok, count?, error? }. Degrades to ok:false on timeout/malformed.
|
|
207
204
|
*/
|
|
208
205
|
private requestCredentialInjectForSession;
|
|
206
|
+
/**
|
|
207
|
+
* Ask the client to answer a question from the agent (legacy single-engine
|
|
208
|
+
* path). This intentionally has no wall-clock timeout; Stop/cancel drains
|
|
209
|
+
* the pending ask.
|
|
210
|
+
*/
|
|
209
211
|
private requestAskUserFromClient;
|
|
210
212
|
private notify;
|
|
211
213
|
/**
|
|
@@ -220,11 +222,10 @@ export declare class AgentServer {
|
|
|
220
222
|
private clearApprovalTimer;
|
|
221
223
|
/**
|
|
222
224
|
* Resolve all of a chat session's pending approvals as cancelled and clear
|
|
223
|
-
*
|
|
225
|
+
* any matching server-side approval timers. Used by handleCancel's
|
|
224
226
|
* per-session path so a Stop while a tool is awaiting approval doesn't leave
|
|
225
|
-
* the tool hanging
|
|
226
|
-
*
|
|
227
|
-
* (see requestAskUserForSession / makeBrowserBridge).
|
|
227
|
+
* the tool hanging. Bounded request types have same-keyed timer entries;
|
|
228
|
+
* AskUserQuestion does not.
|
|
228
229
|
*/
|
|
229
230
|
private cancelSessionApprovals;
|
|
230
231
|
private clearAllApprovalTimers;
|