@cjhyy/code-shell-core 0.6.0-rc.1 → 0.6.0-rc.10
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 +22 -12
- package/dist/engine/engine.js +263 -81
- package/dist/engine/model-connections-pool.js +1 -0
- package/dist/engine/model-facade.js +2 -12
- package/dist/engine/query.js +2 -0
- package/dist/engine/runtime.d.ts +2 -0
- package/dist/engine/runtime.js +25 -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/git/utils.d.ts +12 -0
- package/dist/git/utils.js +33 -6
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/llm/capabilities/rules.js +1 -1
- package/dist/llm/model-pool.d.ts +7 -0
- package/dist/llm/model-pool.js +8 -1
- package/dist/model-catalog/builtin.js +6 -1
- package/dist/preset/index.d.ts +5 -1
- package/dist/preset/index.js +21 -2
- package/dist/prompt/composer.d.ts +5 -0
- package/dist/prompt/composer.js +10 -2
- package/dist/prompt/sections/base.md +1 -0
- package/dist/protocol/chat-session-manager.d.ts +1 -0
- package/dist/protocol/chat-session-manager.js +2 -0
- package/dist/protocol/chat-session.d.ts +4 -1
- package/dist/protocol/chat-session.js +9 -3
- package/dist/protocol/client.d.ts +5 -1
- package/dist/protocol/client.js +8 -2
- package/dist/protocol/server.d.ts +13 -12
- package/dist/protocol/server.js +199 -67
- package/dist/protocol/types.d.ts +14 -0
- package/dist/runtime/background-shell.js +14 -0
- package/dist/runtime/safe-spawn.js +89 -11
- package/dist/runtime/spawn-common.d.ts +15 -4
- package/dist/runtime/spawn-common.js +113 -12
- 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/builtin/bash.js +3 -2
- package/dist/tool-system/builtin/cron.js +10 -2
- package/dist/tool-system/builtin/edit-model-catalog.js +15 -5
- package/dist/tool-system/builtin/generate-video.js +3 -0
- package/dist/tool-system/builtin/grep.d.ts +9 -0
- package/dist/tool-system/builtin/grep.js +100 -3
- package/dist/tool-system/builtin/index.d.ts +3 -1
- package/dist/tool-system/builtin/index.js +5 -5
- package/dist/tool-system/builtin/powershell.js +4 -1
- package/dist/tool-system/builtin/sleep.js +5 -0
- package/dist/tool-system/context.d.ts +10 -0
- package/dist/tool-system/executor.js +25 -2
- 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/tool-system/permission.d.ts +3 -1
- package/dist/tool-system/permission.js +2 -1
- package/dist/tool-system/sandbox/off.js +7 -1
- package/dist/types.d.ts +35 -1
- package/dist/utils/exec.d.ts +8 -0
- package/dist/utils/exec.js +10 -0
- package/package.json +1 -1
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import type { TokenUsage } from "../types.js";
|
|
2
2
|
import type { LLMUsageTracker } from "../llm/types.js";
|
|
3
|
+
export interface CumulativeUsageCounters {
|
|
4
|
+
cumulativePromptTokens: number;
|
|
5
|
+
cumulativeCacheReadTokens: number;
|
|
6
|
+
cumulativeCacheCreationTokens: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function emptyCumulativeUsageCounters(): CumulativeUsageCounters;
|
|
9
|
+
export declare function normalizeCumulativeUsageCounters(counters: Partial<CumulativeUsageCounters> | undefined, legacyUsage?: TokenUsage): CumulativeUsageCounters;
|
|
10
|
+
export declare function addCumulativeUsage(counters: Partial<CumulativeUsageCounters> | undefined, usage: TokenUsage): CumulativeUsageCounters;
|
|
11
|
+
export declare function addTokenUsage(left: TokenUsage, right: TokenUsage): TokenUsage;
|
|
12
|
+
export declare function cacheHitRateFromTokens(promptTokens: number, cacheReadTokens: number | undefined, cacheCreationTokens: number | undefined): number | undefined;
|
|
13
|
+
export declare function cacheHitRateFromUsage(usage: TokenUsage | undefined): number | undefined;
|
|
14
|
+
export declare function cumulativeCacheHitRate(counters: CumulativeUsageCounters): number | undefined;
|
|
3
15
|
/**
|
|
4
16
|
* Fold one run's cumulative usage onto a session baseline, producing the new
|
|
5
17
|
* session-cumulative TokenUsage.
|
|
@@ -1,3 +1,59 @@
|
|
|
1
|
+
export function emptyCumulativeUsageCounters() {
|
|
2
|
+
return {
|
|
3
|
+
cumulativePromptTokens: 0,
|
|
4
|
+
cumulativeCacheReadTokens: 0,
|
|
5
|
+
cumulativeCacheCreationTokens: 0,
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
export function normalizeCumulativeUsageCounters(counters, legacyUsage) {
|
|
9
|
+
return {
|
|
10
|
+
cumulativePromptTokens: typeof counters?.cumulativePromptTokens === "number"
|
|
11
|
+
? counters.cumulativePromptTokens
|
|
12
|
+
: (legacyUsage?.promptTokens ?? 0),
|
|
13
|
+
cumulativeCacheReadTokens: typeof counters?.cumulativeCacheReadTokens === "number"
|
|
14
|
+
? counters.cumulativeCacheReadTokens
|
|
15
|
+
: (legacyUsage?.cacheReadTokens ?? 0),
|
|
16
|
+
cumulativeCacheCreationTokens: typeof counters?.cumulativeCacheCreationTokens === "number"
|
|
17
|
+
? counters.cumulativeCacheCreationTokens
|
|
18
|
+
: (legacyUsage?.cacheCreationTokens ?? 0),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function addCumulativeUsage(counters, usage) {
|
|
22
|
+
const current = normalizeCumulativeUsageCounters(counters);
|
|
23
|
+
return {
|
|
24
|
+
cumulativePromptTokens: current.cumulativePromptTokens + (usage.promptTokens ?? 0),
|
|
25
|
+
cumulativeCacheReadTokens: current.cumulativeCacheReadTokens + (usage.cacheReadTokens ?? 0),
|
|
26
|
+
cumulativeCacheCreationTokens: current.cumulativeCacheCreationTokens + (usage.cacheCreationTokens ?? 0),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function addTokenUsage(left, right) {
|
|
30
|
+
return {
|
|
31
|
+
promptTokens: left.promptTokens + (right.promptTokens ?? 0),
|
|
32
|
+
completionTokens: left.completionTokens + (right.completionTokens ?? 0),
|
|
33
|
+
totalTokens: left.totalTokens + (right.totalTokens ?? 0),
|
|
34
|
+
cacheReadTokens: (left.cacheReadTokens ?? 0) + (right.cacheReadTokens ?? 0),
|
|
35
|
+
cacheCreationTokens: (left.cacheCreationTokens ?? 0) + (right.cacheCreationTokens ?? 0),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function cacheHitRateFromTokens(promptTokens, cacheReadTokens, cacheCreationTokens) {
|
|
39
|
+
const read = cacheReadTokens ?? 0;
|
|
40
|
+
const creation = cacheCreationTokens ?? 0;
|
|
41
|
+
if (read === 0 && creation === 0)
|
|
42
|
+
return undefined;
|
|
43
|
+
const uncached = Math.max(0, promptTokens - read - creation);
|
|
44
|
+
const denom = read + creation + uncached;
|
|
45
|
+
if (denom === 0)
|
|
46
|
+
return undefined;
|
|
47
|
+
return read / denom;
|
|
48
|
+
}
|
|
49
|
+
export function cacheHitRateFromUsage(usage) {
|
|
50
|
+
if (!usage)
|
|
51
|
+
return undefined;
|
|
52
|
+
return cacheHitRateFromTokens(usage.promptTokens ?? 0, usage.cacheReadTokens, usage.cacheCreationTokens);
|
|
53
|
+
}
|
|
54
|
+
export function cumulativeCacheHitRate(counters) {
|
|
55
|
+
return cacheHitRateFromTokens(counters.cumulativePromptTokens, counters.cumulativeCacheReadTokens, counters.cumulativeCacheCreationTokens);
|
|
56
|
+
}
|
|
1
57
|
/**
|
|
2
58
|
* Fold one run's cumulative usage onto a session baseline, producing the new
|
|
3
59
|
* session-cumulative TokenUsage.
|
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
export interface SteerItem {
|
|
10
10
|
id: string;
|
|
11
11
|
text: string;
|
|
12
|
+
clientMessageId?: string;
|
|
12
13
|
}
|
|
13
14
|
/** Append a steer entry. Blank text is dropped (returns the list unchanged). */
|
|
14
|
-
export declare function enqueueSteerItem(list: SteerItem[], id: string, text: string): SteerItem[];
|
|
15
|
+
export declare function enqueueSteerItem(list: SteerItem[], id: string, text: string, clientMessageId?: string): SteerItem[];
|
|
15
16
|
/**
|
|
16
17
|
* Take everything currently queued and clear the list. Returns the drained
|
|
17
18
|
* entries (in order) and the now-empty remainder. The turn loop calls this at
|
|
@@ -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).
|
package/dist/git/utils.d.ts
CHANGED
|
@@ -14,6 +14,18 @@ export interface GitStatusEntry {
|
|
|
14
14
|
}
|
|
15
15
|
export type { GitLogEntry };
|
|
16
16
|
export declare function isGitRepo(cwd: string): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a directory to its PROJECT ROOT: the enclosing git repository's
|
|
19
|
+
* top-level dir if `cwd` is inside a git repo, otherwise `cwd` unchanged.
|
|
20
|
+
*
|
|
21
|
+
* This is the project-boundary rule the desktop uses when adding/identifying a
|
|
22
|
+
* project: picking a SUBDIRECTORY of a git repo should belong to that one repo
|
|
23
|
+
* (its root), not spawn a separate project per subdir — mirrors how editors
|
|
24
|
+
* (and Claude Code) treat a repo as one workspace. A non-git folder is its own
|
|
25
|
+
* project (returned as-is). Never throws; on any git failure falls back to cwd.
|
|
26
|
+
* Returns the git-reported toplevel (already absolute, forward-slashed on win).
|
|
27
|
+
*/
|
|
28
|
+
export declare function resolveProjectRoot(cwd: string): string;
|
|
17
29
|
export declare function getCurrentBranch(cwd: string): string;
|
|
18
30
|
export declare function getGitStatus(cwd: string): GitStatusEntry[];
|
|
19
31
|
export declare function getGitDiff(cwd: string, opts?: {
|
package/dist/git/utils.js
CHANGED
|
@@ -8,15 +8,15 @@
|
|
|
8
8
|
* into the string form.
|
|
9
9
|
*/
|
|
10
10
|
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { realpathSync } from "node:fs";
|
|
11
12
|
import { parseGitLog } from "./parse-log.js";
|
|
12
|
-
import { resolveExecutable } from "../utils/exec.js";
|
|
13
|
+
import { resolveExecutable, resolveGit } from "../utils/exec.js";
|
|
13
14
|
// Resolve git/gh through PATH×PATHEXT on Windows so a .cmd/.exe shim is found
|
|
14
15
|
// (bare execFile doesn't walk PATHEXT). No-op on POSIX. See utils/exec.ts.
|
|
15
|
-
const GIT_BIN = resolveExecutable("git");
|
|
16
16
|
const GH_BIN = resolveExecutable("gh");
|
|
17
17
|
/** Run git with an argv array and return its trimmed stdout. */
|
|
18
18
|
function git(cwd, args, timeoutMs = 10000) {
|
|
19
|
-
return execFileSync(
|
|
19
|
+
return execFileSync(resolveGit(), args, { cwd, encoding: "utf-8", timeout: timeoutMs }).trim();
|
|
20
20
|
}
|
|
21
21
|
/** Run gh with an argv array and return its trimmed stdout. */
|
|
22
22
|
function gh(cwd, args, timeoutMs = 10000) {
|
|
@@ -31,6 +31,33 @@ export function isGitRepo(cwd) {
|
|
|
31
31
|
return false;
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolve a directory to its PROJECT ROOT: the enclosing git repository's
|
|
36
|
+
* top-level dir if `cwd` is inside a git repo, otherwise `cwd` unchanged.
|
|
37
|
+
*
|
|
38
|
+
* This is the project-boundary rule the desktop uses when adding/identifying a
|
|
39
|
+
* project: picking a SUBDIRECTORY of a git repo should belong to that one repo
|
|
40
|
+
* (its root), not spawn a separate project per subdir — mirrors how editors
|
|
41
|
+
* (and Claude Code) treat a repo as one workspace. A non-git folder is its own
|
|
42
|
+
* project (returned as-is). Never throws; on any git failure falls back to cwd.
|
|
43
|
+
* Returns the git-reported toplevel (already absolute, forward-slashed on win).
|
|
44
|
+
*/
|
|
45
|
+
export function resolveProjectRoot(cwd) {
|
|
46
|
+
let realCwd = cwd;
|
|
47
|
+
try {
|
|
48
|
+
realCwd = realpathSync(cwd);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Non-existent paths are allowed to fall back unchanged below.
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const top = git(realCwd, ["rev-parse", "--show-toplevel"], 5000);
|
|
55
|
+
return top ? realpathSync(top) : realCwd;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return realCwd;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
34
61
|
export function getCurrentBranch(cwd) {
|
|
35
62
|
return git(cwd, ["branch", "--show-current"], 5000);
|
|
36
63
|
}
|
|
@@ -82,13 +109,13 @@ export function gitAdd(cwd, files = ["."]) {
|
|
|
82
109
|
// `--` ensures a path starting with `-` cannot be parsed as a flag.
|
|
83
110
|
// Each file is its own argv token, so spaces / quotes / non-ASCII pass
|
|
84
111
|
// through verbatim with no shell parsing.
|
|
85
|
-
execFileSync(
|
|
112
|
+
execFileSync(resolveGit(), ["add", "--", ...files], { cwd, timeout: 10000 });
|
|
86
113
|
}
|
|
87
114
|
export function gitCommit(cwd, message) {
|
|
88
115
|
// Pre-fix this used `JSON.stringify(message)` which only happened to be
|
|
89
116
|
// safe because JSON.stringify covers most shell metacharacters — but it's
|
|
90
117
|
// not real escaping. The argv form is.
|
|
91
|
-
return execFileSync(
|
|
118
|
+
return execFileSync(resolveGit(), ["commit", "-m", message], {
|
|
92
119
|
cwd,
|
|
93
120
|
encoding: "utf-8",
|
|
94
121
|
timeout: 30000,
|
|
@@ -115,7 +142,7 @@ export function gitCheckout(cwd, branch, create = false) {
|
|
|
115
142
|
throw new Error(`refusing branch name that starts with '-': ${branch}`);
|
|
116
143
|
}
|
|
117
144
|
const args = create ? ["checkout", "-b", branch] : ["checkout", branch];
|
|
118
|
-
execFileSync(
|
|
145
|
+
execFileSync(resolveGit(), args, { cwd, timeout: 10000 });
|
|
119
146
|
}
|
|
120
147
|
export function ghAvailable() {
|
|
121
148
|
try {
|