@cjhyy/code-shell-core 0.7.0 → 0.7.1
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/arena/arena.d.ts +2 -0
- package/dist/arena/arena.js +30 -1
- package/dist/arena/phases/adjudication.d.ts +2 -1
- package/dist/arena/phases/adjudication.js +2 -1
- package/dist/arena/phases/build-consensus.d.ts +2 -1
- package/dist/arena/phases/build-consensus.js +3 -1
- package/dist/arena/phases/cross-review.d.ts +3 -1
- package/dist/arena/phases/cross-review.js +8 -2
- package/dist/arena/phases/debate-rounds.d.ts +2 -1
- package/dist/arena/phases/debate-rounds.js +4 -2
- package/dist/arena/phases/participant-research.d.ts +2 -1
- package/dist/arena/phases/participant-research.js +3 -1
- package/dist/arena/phases/planning-detail-expansion.d.ts +2 -1
- package/dist/arena/phases/planning-detail-expansion.js +2 -1
- package/dist/arena/planner.d.ts +2 -1
- package/dist/arena/planner.js +2 -1
- package/dist/arena/types.d.ts +5 -1
- package/dist/cli/agent-server-tcp.js +13 -2
- package/dist/context/manager.d.ts +3 -3
- package/dist/context/manager.js +6 -6
- package/dist/context/token-counter.js +5 -3
- package/dist/context/tool-result-storage.d.ts +6 -0
- package/dist/context/tool-result-storage.js +25 -4
- package/dist/credentials/access.d.ts +10 -1
- package/dist/credentials/access.js +76 -2
- package/dist/credentials/index.d.ts +3 -2
- package/dist/credentials/index.js +2 -1
- package/dist/credentials/oauth.d.ts +6 -1
- package/dist/credentials/oauth.js +66 -1
- package/dist/credentials/store.d.ts +1 -1
- package/dist/credentials/store.js +16 -7
- package/dist/credentials/types.d.ts +38 -1
- package/dist/credentials/types.js +16 -1
- package/dist/engine/engine.d.ts +61 -32
- package/dist/engine/engine.js +289 -55
- package/dist/engine/goal.d.ts +5 -3
- package/dist/engine/session-title.d.ts +2 -1
- package/dist/engine/session-title.js +4 -1
- package/dist/engine/turn-loop.d.ts +18 -0
- package/dist/engine/turn-loop.js +202 -27
- package/dist/engine/types.d.ts +6 -2
- package/dist/git/worktree/crud.d.ts +3 -0
- package/dist/git/worktree/crud.js +32 -3
- package/dist/git/worktree/git-exec.d.ts +2 -2
- package/dist/git/worktree/git-exec.js +47 -11
- package/dist/git/worktree/query.d.ts +8 -7
- package/dist/git/worktree/query.js +27 -20
- package/dist/hooks/events.d.ts +3 -0
- package/dist/hooks/events.js +0 -3
- package/dist/hooks/goal-stop-hook.d.ts +14 -4
- package/dist/hooks/goal-stop-hook.js +626 -71
- package/dist/hooks/registry.js +3 -0
- package/dist/hooks/shell-runner.d.ts +12 -1
- package/dist/hooks/shell-runner.js +160 -9
- package/dist/index.d.ts +7 -6
- package/dist/index.js +6 -5
- package/dist/llm/client-base.js +12 -10
- package/dist/llm/types.d.ts +12 -5
- package/dist/plugins/pluginCommandHook.d.ts +4 -4
- package/dist/plugins/pluginCommandHook.js +111 -13
- package/dist/protocol/chat-session-manager.d.ts +13 -2
- package/dist/protocol/chat-session-manager.js +90 -18
- package/dist/protocol/chat-session.d.ts +12 -0
- package/dist/protocol/chat-session.js +30 -5
- package/dist/protocol/client.d.ts +5 -2
- package/dist/protocol/client.js +22 -1
- package/dist/protocol/server.d.ts +25 -11
- package/dist/protocol/server.js +290 -72
- package/dist/protocol/types.d.ts +34 -2
- package/dist/protocol/types.js +2 -0
- package/dist/services/dream-consolidation.d.ts +3 -0
- package/dist/services/dream-consolidation.js +4 -1
- package/dist/services/index.d.ts +1 -1
- package/dist/services/index.js +1 -1
- package/dist/services/oauth.d.ts +34 -10
- package/dist/services/oauth.js +233 -98
- package/dist/session/session-manager.d.ts +35 -6
- package/dist/session/session-manager.js +362 -26
- package/dist/session/transcript.d.ts +30 -1
- package/dist/session/transcript.js +119 -4
- package/dist/tool-system/builtin/agent.js +5 -1
- package/dist/tool-system/builtin/arena.js +1 -0
- package/dist/tool-system/builtin/background-jobs.d.ts +8 -5
- package/dist/tool-system/builtin/background-jobs.js +15 -11
- package/dist/tool-system/builtin/background-work.d.ts +6 -1
- package/dist/tool-system/builtin/background-work.js +5 -1
- package/dist/tool-system/builtin/bash.d.ts +3 -5
- package/dist/tool-system/builtin/bash.js +10 -5
- package/dist/tool-system/builtin/browser-tools.d.ts +2 -2
- package/dist/tool-system/builtin/edit.d.ts +2 -1
- package/dist/tool-system/builtin/edit.js +12 -4
- package/dist/tool-system/builtin/generate-video.d.ts +4 -0
- package/dist/tool-system/builtin/generate-video.js +138 -21
- package/dist/tool-system/builtin/glob.d.ts +2 -1
- package/dist/tool-system/builtin/glob.js +28 -3
- package/dist/tool-system/builtin/grep.d.ts +1 -0
- package/dist/tool-system/builtin/grep.js +82 -17
- package/dist/tool-system/builtin/index.d.ts +25 -11
- package/dist/tool-system/builtin/index.js +45 -2
- package/dist/tool-system/builtin/video-providers.d.ts +12 -15
- package/dist/tool-system/builtin/video-providers.js +1 -0
- package/dist/tool-system/builtin/view-image.d.ts +2 -2
- package/dist/tool-system/builtin/web-fetch.js +44 -3
- package/dist/tool-system/builtin/worktree.js +25 -7
- package/dist/tool-system/builtin/write.d.ts +2 -1
- package/dist/tool-system/builtin/write.js +14 -4
- package/dist/tool-system/context.d.ts +17 -5
- package/dist/tool-system/executor.js +24 -8
- package/dist/tool-system/mcp-manager.d.ts +6 -0
- package/dist/tool-system/mcp-manager.js +55 -5
- package/dist/tool-system/path-policy.d.ts +19 -0
- package/dist/tool-system/path-policy.js +62 -1
- package/dist/tool-system/permission.d.ts +43 -3
- package/dist/tool-system/permission.js +383 -30
- package/dist/tool-system/registry.d.ts +3 -2
- package/dist/tool-system/registry.js +52 -34
- package/dist/types.d.ts +15 -0
- package/package.json +1 -1
package/dist/engine/goal.d.ts
CHANGED
|
@@ -47,8 +47,10 @@ export interface GoalConfig {
|
|
|
47
47
|
*/
|
|
48
48
|
setAtMs?: number;
|
|
49
49
|
}
|
|
50
|
-
/** A forced
|
|
51
|
-
export type GoalTerminationReason = "stop_blocks_exhausted" | "token_budget_exhausted" | "time_budget_exhausted" | "max_turns_exhausted";
|
|
50
|
+
/** A forced stop outcome for a Goal run. Some outcomes intentionally preserve the Goal. */
|
|
51
|
+
export type GoalTerminationReason = "stop_blocks_exhausted" | "token_budget_exhausted" | "time_budget_exhausted" | "max_turns_exhausted" | "judge_prompt_too_large";
|
|
52
|
+
/** Outcomes that permanently close one concrete persisted Goal instance. */
|
|
53
|
+
export type PersistedGoalTerminationReason = Exclude<GoalTerminationReason, "judge_prompt_too_large">;
|
|
52
54
|
/**
|
|
53
55
|
* Persisted terminal marker used to prevent a stale whole-state writer from
|
|
54
56
|
* making an exhausted goal armable again. `objective + setAtMs` is the goal
|
|
@@ -57,7 +59,7 @@ export type GoalTerminationReason = "stop_blocks_exhausted" | "token_budget_exha
|
|
|
57
59
|
export interface GoalTerminal {
|
|
58
60
|
objective: string;
|
|
59
61
|
setAtMs?: number;
|
|
60
|
-
reason:
|
|
62
|
+
reason: PersistedGoalTerminationReason;
|
|
61
63
|
terminatedAtMs?: number;
|
|
62
64
|
}
|
|
63
65
|
/** True only when both values identify the same concrete goal instance. */
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* resolves the aux client and wiring; this only does the LLM call + cleanup.
|
|
6
6
|
*/
|
|
7
7
|
import type { LLMClientBase } from "../llm/client-base.js";
|
|
8
|
+
import type { TokenUsage } from "../types.js";
|
|
8
9
|
/**
|
|
9
10
|
* Ask the aux client for a one-line title from the first user message + first
|
|
10
11
|
* assistant reply. Returns the cleaned title, or null on any failure / empty
|
|
11
12
|
* output (caller treats null as "keep existing title").
|
|
12
13
|
*/
|
|
13
|
-
export declare function buildSessionTitle(client: LLMClientBase, firstUserText: string, firstAssistantText: string): Promise<string | null>;
|
|
14
|
+
export declare function buildSessionTitle(client: LLMClientBase, firstUserText: string, firstAssistantText: string, recordBilledUsage?: (usage: TokenUsage) => void): Promise<string | null>;
|
|
@@ -13,7 +13,7 @@ function clean(raw) {
|
|
|
13
13
|
* assistant reply. Returns the cleaned title, or null on any failure / empty
|
|
14
14
|
* output (caller treats null as "keep existing title").
|
|
15
15
|
*/
|
|
16
|
-
export async function buildSessionTitle(client, firstUserText, firstAssistantText) {
|
|
16
|
+
export async function buildSessionTitle(client, firstUserText, firstAssistantText, recordBilledUsage) {
|
|
17
17
|
try {
|
|
18
18
|
const prompt = `User: ${firstUserText.slice(0, 2000)}\n\n` +
|
|
19
19
|
`Assistant: ${firstAssistantText.slice(0, 2000)}\n\n` +
|
|
@@ -23,8 +23,11 @@ export async function buildSessionTitle(client, firstUserText, firstAssistantTex
|
|
|
23
23
|
messages: [{ role: "user", content: prompt }],
|
|
24
24
|
tools: [],
|
|
25
25
|
maxTokens: 64,
|
|
26
|
+
requestVisible: false,
|
|
26
27
|
reasoning: { mode: "off" },
|
|
27
28
|
});
|
|
29
|
+
if (resp.usage)
|
|
30
|
+
recordBilledUsage?.(resp.usage);
|
|
28
31
|
const title = clean(resp.text ?? "");
|
|
29
32
|
return title.length > 0 ? title : null;
|
|
30
33
|
}
|
|
@@ -172,6 +172,10 @@ export declare class TurnLoop {
|
|
|
172
172
|
private readonly sensitiveToolResultRedactions;
|
|
173
173
|
private readonly pendingImageMessages;
|
|
174
174
|
private readonly volatileContextMessages;
|
|
175
|
+
/** Last outer-loop turn for which the model returned a complete response. */
|
|
176
|
+
private lastCompletedModelTurn;
|
|
177
|
+
/** Last model turn whose transcript/persistence boundary was finalized. */
|
|
178
|
+
private lastFinalizedTurn;
|
|
175
179
|
/**
|
|
176
180
|
* Consecutive on_stop blocks (Goal mode kept the agent going). Reset to 0
|
|
177
181
|
* on any unblocked completion. When it reaches config.maxStopBlocks the
|
|
@@ -204,6 +208,13 @@ export declare class TurnLoop {
|
|
|
204
208
|
timeBudgetMs?: number;
|
|
205
209
|
maxStopBlocks: number;
|
|
206
210
|
};
|
|
211
|
+
/**
|
|
212
|
+
* Private Goal-judge accounting seam. Judge calls bypass ModelFacade, so the
|
|
213
|
+
* Engine forwards their provider usage here while the run-scoped tracker is
|
|
214
|
+
* live. The returned reason lets the hook suppress verdict side effects when
|
|
215
|
+
* this very request crossed a hard token/time budget.
|
|
216
|
+
*/
|
|
217
|
+
recordGoalJudgeUsage(usage: TokenUsage | undefined): Extract<GoalTerminationReason, "token_budget_exhausted" | "time_budget_exhausted"> | undefined;
|
|
207
218
|
/**
|
|
208
219
|
* Goal mode only: if the run is nearing EITHER stop ceiling (maxTurns or
|
|
209
220
|
* maxStopBlocks) and we haven't announced it yet, emit one approaching_limit
|
|
@@ -229,6 +240,13 @@ export declare class TurnLoop {
|
|
|
229
240
|
* for one Stop still writes a single marker.
|
|
230
241
|
*/
|
|
231
242
|
private markStopped;
|
|
243
|
+
/**
|
|
244
|
+
* Close one completed model turn exactly once. The transcript boundary must
|
|
245
|
+
* be durable before the Engine flushes usage, heartbeat, and turnCount via
|
|
246
|
+
* onTurnBoundary, so crash recovery never folds two assistant responses into
|
|
247
|
+
* the same transcript turn or observes state ahead of the transcript.
|
|
248
|
+
*/
|
|
249
|
+
private finalizeModelTurn;
|
|
232
250
|
private prepareMessagesForModel;
|
|
233
251
|
private stripVolatileContextMessages;
|
|
234
252
|
private appendVolatileContextMessages;
|
package/dist/engine/turn-loop.js
CHANGED
|
@@ -64,6 +64,10 @@ export class TurnLoop {
|
|
|
64
64
|
sensitiveToolResultRedactions = new Map();
|
|
65
65
|
pendingImageMessages = new Set();
|
|
66
66
|
volatileContextMessages = new Set();
|
|
67
|
+
/** Last outer-loop turn for which the model returned a complete response. */
|
|
68
|
+
lastCompletedModelTurn = 0;
|
|
69
|
+
/** Last model turn whose transcript/persistence boundary was finalized. */
|
|
70
|
+
lastFinalizedTurn = 0;
|
|
67
71
|
/**
|
|
68
72
|
* Consecutive on_stop blocks (Goal mode kept the agent going). Reset to 0
|
|
69
73
|
* on any unblocked completion. When it reaches config.maxStopBlocks the
|
|
@@ -128,6 +132,20 @@ export class TurnLoop {
|
|
|
128
132
|
}
|
|
129
133
|
return { ...next, maxStopBlocks: nextCap };
|
|
130
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Private Goal-judge accounting seam. Judge calls bypass ModelFacade, so the
|
|
137
|
+
* Engine forwards their provider usage here while the run-scoped tracker is
|
|
138
|
+
* live. The returned reason lets the hook suppress verdict side effects when
|
|
139
|
+
* this very request crossed a hard token/time budget.
|
|
140
|
+
*/
|
|
141
|
+
recordGoalJudgeUsage(usage) {
|
|
142
|
+
if (!this.goalTracker)
|
|
143
|
+
return undefined;
|
|
144
|
+
if (usage) {
|
|
145
|
+
recordGoalUsage(this.goalTracker, (usage.promptTokens ?? 0) + (usage.completionTokens ?? 0));
|
|
146
|
+
}
|
|
147
|
+
return goalBudgetTerminationReason(this.goalTracker, Date.now());
|
|
148
|
+
}
|
|
131
149
|
/**
|
|
132
150
|
* Goal mode only: if the run is nearing EITHER stop ceiling (maxTurns or
|
|
133
151
|
* maxStopBlocks) and we haven't announced it yet, emit one approaching_limit
|
|
@@ -217,6 +235,21 @@ export class TurnLoop {
|
|
|
217
235
|
return;
|
|
218
236
|
this.deps.transcript.appendTurnStopped();
|
|
219
237
|
}
|
|
238
|
+
/**
|
|
239
|
+
* Close one completed model turn exactly once. The transcript boundary must
|
|
240
|
+
* be durable before the Engine flushes usage, heartbeat, and turnCount via
|
|
241
|
+
* onTurnBoundary, so crash recovery never folds two assistant responses into
|
|
242
|
+
* the same transcript turn or observes state ahead of the transcript.
|
|
243
|
+
*/
|
|
244
|
+
finalizeModelTurn() {
|
|
245
|
+
if (this.lastCompletedModelTurn !== this.turnCount ||
|
|
246
|
+
this.lastFinalizedTurn === this.turnCount) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
this.lastFinalizedTurn = this.turnCount;
|
|
250
|
+
this.deps.transcript.appendTurnBoundary();
|
|
251
|
+
this.config.onTurnBoundary?.(this.turnCount);
|
|
252
|
+
}
|
|
220
253
|
prepareMessagesForModel(messages) {
|
|
221
254
|
const preserveMessages = this.pendingImageMessages.size > 0 ? this.pendingImageMessages : undefined;
|
|
222
255
|
const result = downgradeImagePayloadsInHistory(messages, { preserveMessages });
|
|
@@ -406,13 +439,16 @@ export class TurnLoop {
|
|
|
406
439
|
// window only after irreversible sensitive-data removal, non-text omission,
|
|
407
440
|
// and per-item truncation at the current-round ToolResult boundary.
|
|
408
441
|
const goalToolResults = [];
|
|
442
|
+
const sensitiveGoalResultTools = this.config.goal
|
|
443
|
+
? new Set(this.deps.tools.filter((tool) => tool.sensitiveResult).map((tool) => tool.name))
|
|
444
|
+
: undefined;
|
|
409
445
|
// Goal-mode run-scoped budget tracker (P0). Null when no goal. Stamps a
|
|
410
446
|
// wall-clock start now and accumulates prompt+completion tokens across
|
|
411
447
|
// every turn; the guardrail below force-stops the run once any configured
|
|
412
448
|
// budget is blown — the unattended-safety backstop.
|
|
413
|
-
this.goalTracker
|
|
414
|
-
|
|
415
|
-
|
|
449
|
+
if (!this.goalTracker && this.config.goal) {
|
|
450
|
+
this.goalTracker = createGoalBudgetTracker(this.config.goal, Date.now());
|
|
451
|
+
}
|
|
416
452
|
const goalTracker = this.goalTracker;
|
|
417
453
|
// Fresh run: re-arm the approaching-limit announcement.
|
|
418
454
|
this.approachAnnounced = false;
|
|
@@ -425,6 +461,25 @@ export class TurnLoop {
|
|
|
425
461
|
// hook emits, guards) and surfaces them as a model_error result.
|
|
426
462
|
try {
|
|
427
463
|
while (this.turnCount < this.config.maxTurns) {
|
|
464
|
+
const preTurnGoalTermination = goalTracker
|
|
465
|
+
? goalBudgetTerminationReason(goalTracker, Date.now())
|
|
466
|
+
: undefined;
|
|
467
|
+
if (preTurnGoalTermination) {
|
|
468
|
+
this.config.onStream?.({
|
|
469
|
+
type: "assistant_message",
|
|
470
|
+
message: {
|
|
471
|
+
role: "assistant",
|
|
472
|
+
content: "(Goal 预算已耗尽,强制停止。)",
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
messages = this.redactConsumedSensitiveToolResults(messages);
|
|
476
|
+
return {
|
|
477
|
+
text: finalText,
|
|
478
|
+
reason: "goal_budget_exhausted",
|
|
479
|
+
messages,
|
|
480
|
+
goalTermination: preTurnGoalTermination,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
428
483
|
this.turnCount++;
|
|
429
484
|
this.currentTurnUsage = {
|
|
430
485
|
promptTokens: 0,
|
|
@@ -523,7 +578,7 @@ export class TurnLoop {
|
|
|
523
578
|
}
|
|
524
579
|
else {
|
|
525
580
|
// Context management (async — may trigger LLM summarization)
|
|
526
|
-
messages = await this.deps.contextManager.manageAsync(messages);
|
|
581
|
+
messages = await this.deps.contextManager.manageAsync(messages, this.config.signal);
|
|
527
582
|
// manageAsync can itself issue an LLM summarization call lasting several
|
|
528
583
|
// seconds; if the signal aborted during it, stop here rather than
|
|
529
584
|
// proceeding into the (expensive) main model call. Belt to the loop-top
|
|
@@ -621,12 +676,22 @@ export class TurnLoop {
|
|
|
621
676
|
return { text: finalText, reason: "model_error", messages };
|
|
622
677
|
}
|
|
623
678
|
}
|
|
679
|
+
this.lastCompletedModelTurn = this.turnCount;
|
|
624
680
|
messages = this.redactConsumedSensitiveToolResults(messages);
|
|
625
681
|
// Record the response once into current-turn and whole-session counters.
|
|
626
682
|
if (response.usage?.promptTokens !== undefined) {
|
|
627
683
|
this.recordResponseUsage(response.usage);
|
|
628
684
|
this.emitCtxFromUsage(response.usage, messages);
|
|
629
685
|
}
|
|
686
|
+
// Charge every provider response to the run-scoped Goal budget before
|
|
687
|
+
// any truncation path can retry or continue the outer turn loop.
|
|
688
|
+
if (goalTracker && response.usage) {
|
|
689
|
+
const used = (response.usage.promptTokens ?? 0) + (response.usage.completionTokens ?? 0);
|
|
690
|
+
recordGoalUsage(goalTracker, used);
|
|
691
|
+
}
|
|
692
|
+
let budgetTermination = goalTracker
|
|
693
|
+
? goalBudgetTerminationReason(goalTracker, Date.now())
|
|
694
|
+
: undefined;
|
|
630
695
|
// Feed actual token usage back to the context manager so subsequent
|
|
631
696
|
// compaction decisions use hybrid (actual + delta) estimation rather than
|
|
632
697
|
// pure heuristics. Without this the manager falls back to char/4 estimates.
|
|
@@ -637,13 +702,24 @@ export class TurnLoop {
|
|
|
637
702
|
this.deps.recordContextUsageAnchor?.(anchor);
|
|
638
703
|
}
|
|
639
704
|
messages = this.markPendingImagesConsumed(messages);
|
|
705
|
+
// stopBlockCount guards only the no-tool final-answer loop evaluated by
|
|
706
|
+
// on_stop (text -> block -> text -> block). A response that structurally
|
|
707
|
+
// contains tool_use has left that streak, regardless of whether the call
|
|
708
|
+
// will execute successfully. Reset here, before truncated-tool and later
|
|
709
|
+
// queued-steer paths can continue the outer loop. Repeated ineffective
|
|
710
|
+
// tool_use turns still consume turns and are bounded by maxTurns.
|
|
711
|
+
if (response.toolCalls.length > 0) {
|
|
712
|
+
this.stopBlockCount = 0;
|
|
713
|
+
}
|
|
640
714
|
// Truncation that cut off a TOOL CALL: the model overflowed
|
|
641
715
|
// max_output_tokens mid tool-call, so the arg JSON is incomplete (e.g. a
|
|
642
716
|
// Write whose `content` was clipped, leaving file_path unset). Executing
|
|
643
717
|
// it raised a misleading "Missing required parameter: file_path". Instead,
|
|
644
718
|
// tell the model its output was truncated and let the next turn retry —
|
|
645
719
|
// bounded by the outer maxTurns loop.
|
|
646
|
-
if (
|
|
720
|
+
if (!budgetTermination &&
|
|
721
|
+
isTruncatedStop(response.stopReason) &&
|
|
722
|
+
response.toolCalls.length > 0) {
|
|
647
723
|
tlog.info("turn.truncated_tool_call", {
|
|
648
724
|
cat: "turn",
|
|
649
725
|
toolCount: response.toolCalls.length,
|
|
@@ -655,13 +731,15 @@ export class TurnLoop {
|
|
|
655
731
|
role: "user",
|
|
656
732
|
content: "<system-reminder>Your previous response was truncated by the max output token limit before the tool call finished, so its arguments are incomplete. Do not assume it ran. Either retry with a smaller/more focused tool call (e.g. write the file in sections via Edit), or raise this model's maxOutputTokens.</system-reminder>",
|
|
657
733
|
});
|
|
734
|
+
this.finalizeModelTurn();
|
|
658
735
|
continue;
|
|
659
736
|
}
|
|
660
737
|
// Handle max_output_tokens: if response was truncated, do continuation
|
|
661
738
|
// (up to 3 times). Truncation is reported as finish_reason "length"
|
|
662
739
|
// (OpenAI) or stop_reason "max_tokens" (Anthropic) — isTruncatedStop
|
|
663
740
|
// accepts both, so the OpenAI streaming path triggers continuation too.
|
|
664
|
-
if (
|
|
741
|
+
if (!budgetTermination &&
|
|
742
|
+
isTruncatedStop(response.stopReason) &&
|
|
665
743
|
response.toolCalls.length === 0 &&
|
|
666
744
|
response.text) {
|
|
667
745
|
let combinedText = response.text;
|
|
@@ -672,6 +750,11 @@ export class TurnLoop {
|
|
|
672
750
|
// more model calls, emitting text after Stop.
|
|
673
751
|
if (this.config.signal?.aborted)
|
|
674
752
|
break;
|
|
753
|
+
budgetTermination = goalTracker
|
|
754
|
+
? goalBudgetTerminationReason(goalTracker, Date.now())
|
|
755
|
+
: undefined;
|
|
756
|
+
if (budgetTermination)
|
|
757
|
+
break;
|
|
675
758
|
tlog.info("turn.max_tokens_continuation", { cat: "turn", retry: retry + 1 });
|
|
676
759
|
const contMessages = [
|
|
677
760
|
...messages,
|
|
@@ -683,18 +766,40 @@ export class TurnLoop {
|
|
|
683
766
|
];
|
|
684
767
|
try {
|
|
685
768
|
const contResponse = await this.deps.model.call(this.deps.systemPrompt, this.prepareMessagesForModel(contMessages), this.deps.tools, this.config.onStream, this.config.signal);
|
|
769
|
+
// Continuations are separate provider responses, so preserve the
|
|
770
|
+
// same structural tool_use invariant before processing this one.
|
|
771
|
+
if (contResponse.toolCalls.length > 0) {
|
|
772
|
+
this.stopBlockCount = 0;
|
|
773
|
+
}
|
|
686
774
|
if (contResponse.usage?.promptTokens !== undefined) {
|
|
687
775
|
this.recordResponseUsage(contResponse.usage);
|
|
688
776
|
continuedResponse = true;
|
|
689
777
|
}
|
|
778
|
+
if (goalTracker && contResponse.usage) {
|
|
779
|
+
const used = (contResponse.usage.promptTokens ?? 0) +
|
|
780
|
+
(contResponse.usage.completionTokens ?? 0);
|
|
781
|
+
recordGoalUsage(goalTracker, used);
|
|
782
|
+
}
|
|
783
|
+
budgetTermination = goalTracker
|
|
784
|
+
? goalBudgetTerminationReason(goalTracker, Date.now())
|
|
785
|
+
: undefined;
|
|
690
786
|
combinedText += contResponse.text;
|
|
691
|
-
if (
|
|
787
|
+
if (budgetTermination ||
|
|
788
|
+
!isTruncatedStop(contResponse.stopReason) ||
|
|
789
|
+
contResponse.toolCalls.length > 0) {
|
|
692
790
|
response = { ...contResponse, text: combinedText };
|
|
693
791
|
break;
|
|
694
792
|
}
|
|
695
793
|
}
|
|
696
|
-
catch {
|
|
697
|
-
|
|
794
|
+
catch (err) {
|
|
795
|
+
if (isAbortError(err) || this.config.signal?.aborted) {
|
|
796
|
+
this.markStopped();
|
|
797
|
+
this.finalizeModelTurn();
|
|
798
|
+
return { text: finalText, reason: "aborted_streaming", messages };
|
|
799
|
+
}
|
|
800
|
+
this.config.onStream?.({ type: "error", error: formatFriendlyError(err) });
|
|
801
|
+
this.finalizeModelTurn();
|
|
802
|
+
return { text: finalText, reason: "model_error", messages };
|
|
698
803
|
}
|
|
699
804
|
}
|
|
700
805
|
response = { ...response, text: combinedText };
|
|
@@ -702,16 +807,10 @@ export class TurnLoop {
|
|
|
702
807
|
this.emitCtxFromUsage(response.usage, messages);
|
|
703
808
|
}
|
|
704
809
|
}
|
|
705
|
-
// Goal-mode run-scoped accounting: add this turn's total token usage
|
|
706
|
-
// (prompt + completion) to the running total. Done after continuation so
|
|
707
|
-
// continued output is counted against the budget.
|
|
708
|
-
if (goalTracker && response.usage) {
|
|
709
|
-
const used = (response.usage.promptTokens ?? 0) + (response.usage.completionTokens ?? 0);
|
|
710
|
-
recordGoalUsage(goalTracker, used);
|
|
711
|
-
}
|
|
712
810
|
// Aborted?
|
|
713
811
|
if (this.config.signal?.aborted) {
|
|
714
812
|
this.markStopped();
|
|
813
|
+
this.finalizeModelTurn();
|
|
715
814
|
return { text: finalText, reason: "aborted_streaming", messages };
|
|
716
815
|
}
|
|
717
816
|
// Accumulate text
|
|
@@ -722,8 +821,8 @@ export class TurnLoop {
|
|
|
722
821
|
// is force-stopped regardless of what the model wants to do next (stop OR
|
|
723
822
|
// continue with tool calls). This is the unattended-safety backstop, so
|
|
724
823
|
// it sits BEFORE the "tool calls?" branch — both paths pass the gate.
|
|
725
|
-
|
|
726
|
-
? goalBudgetTerminationReason(goalTracker, Date.now())
|
|
824
|
+
budgetTermination = goalTracker
|
|
825
|
+
? (budgetTermination ?? goalBudgetTerminationReason(goalTracker, Date.now()))
|
|
727
826
|
: undefined;
|
|
728
827
|
if (goalTracker && budgetTermination) {
|
|
729
828
|
tlog.info("turn.goal_budget_exhausted", {
|
|
@@ -740,6 +839,7 @@ export class TurnLoop {
|
|
|
740
839
|
content: "(Goal 预算已耗尽,强制停止。)",
|
|
741
840
|
},
|
|
742
841
|
});
|
|
842
|
+
this.finalizeModelTurn();
|
|
743
843
|
return {
|
|
744
844
|
text: finalText,
|
|
745
845
|
reason: "goal_budget_exhausted",
|
|
@@ -760,6 +860,7 @@ export class TurnLoop {
|
|
|
760
860
|
hasToolUse: false,
|
|
761
861
|
});
|
|
762
862
|
messages.push({ role: "assistant", content: finalText });
|
|
863
|
+
this.finalizeModelTurn();
|
|
763
864
|
if (await this.consumeQueuedSteer(messages, "finalize_backfill")) {
|
|
764
865
|
continue;
|
|
765
866
|
}
|
|
@@ -791,6 +892,57 @@ export class TurnLoop {
|
|
|
791
892
|
finalText,
|
|
792
893
|
turnCount: this.turnCount,
|
|
793
894
|
});
|
|
895
|
+
// The primary-model judge is a real billed request. Its hook forwards
|
|
896
|
+
// usage through recordGoalJudgeUsage above; re-check immediately so an
|
|
897
|
+
// over-budget verdict cannot authorize another main-model turn.
|
|
898
|
+
const hookGoalTermination = stopHook.goalTermination;
|
|
899
|
+
const hookBudgetTermination = hookGoalTermination === "token_budget_exhausted" ||
|
|
900
|
+
hookGoalTermination === "time_budget_exhausted"
|
|
901
|
+
? hookGoalTermination
|
|
902
|
+
: undefined;
|
|
903
|
+
const postJudgeBudgetTermination = goalTracker
|
|
904
|
+
? (hookBudgetTermination ?? goalBudgetTerminationReason(goalTracker, Date.now()))
|
|
905
|
+
: undefined;
|
|
906
|
+
if (goalTracker && postJudgeBudgetTermination) {
|
|
907
|
+
tlog.info("turn.goal_budget_exhausted_after_judge", {
|
|
908
|
+
cat: "goal",
|
|
909
|
+
reason: postJudgeBudgetTermination,
|
|
910
|
+
tokensUsed: goalTracker.tokensUsed,
|
|
911
|
+
tokenBudget: goalTracker.goal.tokenBudget,
|
|
912
|
+
timeBudgetMs: goalTracker.goal.timeBudgetMs,
|
|
913
|
+
});
|
|
914
|
+
this.config.onStream?.({
|
|
915
|
+
type: "assistant_message",
|
|
916
|
+
messageId: assistantMessageId,
|
|
917
|
+
message: {
|
|
918
|
+
role: "assistant",
|
|
919
|
+
content: "(Goal 预算已耗尽,强制停止。)",
|
|
920
|
+
},
|
|
921
|
+
});
|
|
922
|
+
return {
|
|
923
|
+
text: finalText,
|
|
924
|
+
reason: "goal_budget_exhausted",
|
|
925
|
+
messages,
|
|
926
|
+
goalTermination: postJudgeBudgetTermination,
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
if (goalTracker && hookGoalTermination === "judge_prompt_too_large") {
|
|
930
|
+
tlog.warn("turn.goal_judge_prompt_too_large", { cat: "goal" });
|
|
931
|
+
this.config.onStream?.({
|
|
932
|
+
type: "assistant_message",
|
|
933
|
+
messageId: assistantMessageId,
|
|
934
|
+
message: {
|
|
935
|
+
role: "assistant",
|
|
936
|
+
content: "(Goal 裁判输入超过安全上限,无法判定;本次已停止,目标已保留。)",
|
|
937
|
+
},
|
|
938
|
+
});
|
|
939
|
+
return {
|
|
940
|
+
text: finalText,
|
|
941
|
+
reason: "completed",
|
|
942
|
+
messages,
|
|
943
|
+
goalTermination: hookGoalTermination,
|
|
944
|
+
};
|
|
945
|
+
}
|
|
794
946
|
// The judge's structured verdict (set by GoalStopHook in result.data)
|
|
795
947
|
// rides back here so we can show goal progress WITHOUT a second LLM
|
|
796
948
|
// call — `gaps` is whatever the judge already computed.
|
|
@@ -912,11 +1064,14 @@ export class TurnLoop {
|
|
|
912
1064
|
const resultBlocks = [];
|
|
913
1065
|
for (const result of results) {
|
|
914
1066
|
if (goalTracker) {
|
|
915
|
-
goalToolResults.push(projectGoalJudgeToolResult(result, this.turnCount));
|
|
916
|
-
//
|
|
917
|
-
//
|
|
918
|
-
|
|
919
|
-
|
|
1067
|
+
goalToolResults.push(projectGoalJudgeToolResult(result, this.turnCount, sensitiveGoalResultTools?.has(result.toolName) === true));
|
|
1068
|
+
// Never evict the front of one legal batch: the default executor may
|
|
1069
|
+
// return 25 results in a turn. Keep at least maxToolCallsPerTurn so
|
|
1070
|
+
// the judge renderer can preserve metadata for the entire newest batch.
|
|
1071
|
+
const residencyLimit = Math.max(20, this.config.maxToolCallsPerTurn);
|
|
1072
|
+
if (goalToolResults.length > residencyLimit) {
|
|
1073
|
+
goalToolResults.splice(0, goalToolResults.length - residencyLimit);
|
|
1074
|
+
}
|
|
920
1075
|
}
|
|
921
1076
|
resultBlocks.push(toolResultToBlock(result));
|
|
922
1077
|
const streamResult = toolResultForDisplay(result);
|
|
@@ -994,6 +1149,7 @@ export class TurnLoop {
|
|
|
994
1149
|
tlog.info("turn.goal_self_reported_complete", { cat: "goal" });
|
|
995
1150
|
this.stopBlockCount = 0;
|
|
996
1151
|
this.deps.clearPersistedGoal?.();
|
|
1152
|
+
this.finalizeModelTurn();
|
|
997
1153
|
if (await this.consumeQueuedSteer(messages, "finalize_backfill")) {
|
|
998
1154
|
continue;
|
|
999
1155
|
}
|
|
@@ -1010,6 +1166,7 @@ export class TurnLoop {
|
|
|
1010
1166
|
tlog.info("turn.goal_user_cancelled", { cat: "goal" });
|
|
1011
1167
|
this.stopBlockCount = 0;
|
|
1012
1168
|
this.deps.clearPersistedGoal?.();
|
|
1169
|
+
this.finalizeModelTurn();
|
|
1013
1170
|
if (await this.consumeQueuedSteer(messages, "finalize_backfill")) {
|
|
1014
1171
|
continue;
|
|
1015
1172
|
}
|
|
@@ -1030,6 +1187,7 @@ export class TurnLoop {
|
|
|
1030
1187
|
message: { role: "assistant", content: finalText },
|
|
1031
1188
|
});
|
|
1032
1189
|
messages.push({ role: "assistant", content: finalText });
|
|
1190
|
+
this.finalizeModelTurn();
|
|
1033
1191
|
if (await this.consumeQueuedSteer(messages, "finalize_backfill")) {
|
|
1034
1192
|
continue;
|
|
1035
1193
|
}
|
|
@@ -1072,9 +1230,7 @@ export class TurnLoop {
|
|
|
1072
1230
|
hasToolUse: true,
|
|
1073
1231
|
toolCallCount: toolCalls.length,
|
|
1074
1232
|
});
|
|
1075
|
-
|
|
1076
|
-
this.deps.transcript.appendTurnBoundary();
|
|
1077
|
-
this.config.onTurnBoundary?.(this.turnCount);
|
|
1233
|
+
this.finalizeModelTurn();
|
|
1078
1234
|
tlog.info("turn.end", {
|
|
1079
1235
|
cat: "turn",
|
|
1080
1236
|
duration_ms: Date.now() - turnStartedAt,
|
|
@@ -1089,6 +1245,9 @@ export class TurnLoop {
|
|
|
1089
1245
|
// engine's post-run saveState records model_error instead of leaving
|
|
1090
1246
|
// the session stuck at "active".
|
|
1091
1247
|
this.patchOrphanedToolUses(messages);
|
|
1248
|
+
// If the model already returned, this is still a completed model turn.
|
|
1249
|
+
// The idempotent helper is a no-op when its normal branch finalized first.
|
|
1250
|
+
this.finalizeModelTurn();
|
|
1092
1251
|
// A user-initiated Stop also bubbles here (an AbortError thrown from the
|
|
1093
1252
|
// per-turn scaffolding). Treat it as a clean abort, not a model_error —
|
|
1094
1253
|
// no error event, so the UI shows only the "你停止了本轮" line.
|
|
@@ -1133,12 +1292,28 @@ export class TurnLoop {
|
|
|
1133
1292
|
try {
|
|
1134
1293
|
const summaryResponse = await this.deps.model.call(this.deps.systemPrompt, this.prepareMessagesForModel(messages), [], // No tools available for summary turn
|
|
1135
1294
|
this.config.onStream, this.config.signal, this.modelCallRecordingOptions());
|
|
1295
|
+
if (summaryResponse.usage?.promptTokens !== undefined) {
|
|
1296
|
+
this.recordResponseUsage(summaryResponse.usage);
|
|
1297
|
+
}
|
|
1298
|
+
if (goalTracker && summaryResponse.usage) {
|
|
1299
|
+
recordGoalUsage(goalTracker, (summaryResponse.usage.promptTokens ?? 0) + (summaryResponse.usage.completionTokens ?? 0));
|
|
1300
|
+
}
|
|
1301
|
+
if (this.config.signal?.aborted) {
|
|
1302
|
+
this.markStopped();
|
|
1303
|
+
messages = this.redactConsumedSensitiveToolResults(messages);
|
|
1304
|
+
return { text: finalText, reason: "aborted_streaming", messages };
|
|
1305
|
+
}
|
|
1136
1306
|
messages = this.markPendingImagesConsumed(messages);
|
|
1137
1307
|
if (summaryResponse.text) {
|
|
1138
1308
|
finalText = summaryResponse.text;
|
|
1139
1309
|
}
|
|
1140
1310
|
}
|
|
1141
|
-
catch {
|
|
1311
|
+
catch (err) {
|
|
1312
|
+
if (isAbortError(err) || this.config.signal?.aborted) {
|
|
1313
|
+
this.markStopped();
|
|
1314
|
+
messages = this.redactConsumedSensitiveToolResults(messages);
|
|
1315
|
+
return { text: finalText, reason: "aborted_streaming", messages };
|
|
1316
|
+
}
|
|
1142
1317
|
// If even summary fails, just return what we have
|
|
1143
1318
|
this.currentTurnLog.warn("turn.summary_failed", { cat: "turn" });
|
|
1144
1319
|
}
|
package/dist/engine/types.d.ts
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import type { ClientDefaults, LLMConfig, SessionOrigin, TerminalReason, TokenUsage } from "../types.js";
|
|
14
14
|
import type { AgentPresetName } from "../preset/index.js";
|
|
15
|
-
import type { GoalConfig } from "./goal.js";
|
|
16
|
-
import type { ApprovalBackend } from "../tool-system/permission.js";
|
|
15
|
+
import type { GoalConfig, GoalTerminationReason } from "./goal.js";
|
|
16
|
+
import type { ApprovalBackend, ApprovalRouter } from "../tool-system/permission.js";
|
|
17
17
|
import type { SandboxConfig } from "../tool-system/sandbox/index.js";
|
|
18
18
|
import type { CostStateStore } from "./cost-store.js";
|
|
19
19
|
import type { AskUserFn } from "../tool-system/builtin/ask-user.js";
|
|
@@ -70,6 +70,8 @@ export interface EngineConfig {
|
|
|
70
70
|
sessionStorageDir?: string;
|
|
71
71
|
maxContextTokens?: number;
|
|
72
72
|
approvalBackend?: ApprovalBackend;
|
|
73
|
+
/** Connection-scoped permission approval router supplied by an interactive host. */
|
|
74
|
+
approvalRouter?: ApprovalRouter;
|
|
73
75
|
hooks?: EngineHookConfig[];
|
|
74
76
|
askUser?: AskUserFn;
|
|
75
77
|
/** Browser automation bridge (browser_* tools). Wired by the host (desktop)
|
|
@@ -177,6 +179,8 @@ export interface EngineHookConfig {
|
|
|
177
179
|
export interface EngineResult {
|
|
178
180
|
text: string;
|
|
179
181
|
reason: TerminalReason;
|
|
182
|
+
/** Goal-specific stop outcome; omitted for ordinary completion/met verdicts. */
|
|
183
|
+
goalTermination?: GoalTerminationReason;
|
|
180
184
|
sessionId: string;
|
|
181
185
|
turnCount: number;
|
|
182
186
|
usage: TokenUsage;
|
|
@@ -17,7 +17,10 @@ export interface PlatformScripts {
|
|
|
17
17
|
}
|
|
18
18
|
export interface CreateWorktreeOptions {
|
|
19
19
|
prefix?: string;
|
|
20
|
+
signal?: AbortSignal;
|
|
20
21
|
}
|
|
22
|
+
/** Best-effort rollback for an aborted `git worktree add`. */
|
|
23
|
+
export declare function cleanupAbortedWorktree(gitRoot: string, worktreePath: string, branchName: string): Promise<void>;
|
|
21
24
|
/**
|
|
22
25
|
* Pick the setup/cleanup script for the running platform, falling back to
|
|
23
26
|
* `default`. Empty/whitespace-only scripts are treated as absent so a project
|
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
import { existsSync, lstatSync, mkdirSync, readdirSync, symlinkSync } from "node:fs";
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
2
3
|
import { dirname, join, resolve } from "node:path";
|
|
3
4
|
import { safeSpawnShell } from "../../runtime/safe-spawn.js";
|
|
4
5
|
import { buildSandboxEnv, defaultShellBinary, mergeShellEnv } from "../../runtime/spawn-common.js";
|
|
5
6
|
import { execGit, execGitSync, gitErrorMessage } from "./git-exec.js";
|
|
6
7
|
import { assertBranchNotCheckedOut, currentBranch, findGitRoot } from "./query.js";
|
|
7
8
|
import { applyPrefix, isManagedWorktreeBranch, validateWorktreeSlug } from "./slug.js";
|
|
9
|
+
function throwIfAborted(signal) {
|
|
10
|
+
signal?.throwIfAborted();
|
|
11
|
+
}
|
|
12
|
+
/** Best-effort rollback for an aborted `git worktree add`. */
|
|
13
|
+
export async function cleanupAbortedWorktree(gitRoot, worktreePath, branchName) {
|
|
14
|
+
try {
|
|
15
|
+
await execGit(gitRoot, ["worktree", "remove", worktreePath, "--force"], 30_000);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
await rm(worktreePath, { recursive: true, force: true }).catch(() => { });
|
|
19
|
+
await execGit(gitRoot, ["worktree", "prune"], 10_000).catch(() => { });
|
|
20
|
+
}
|
|
21
|
+
await execGit(gitRoot, ["branch", "-D", branchName], 10_000).catch(() => { });
|
|
22
|
+
}
|
|
8
23
|
/**
|
|
9
24
|
* Pick the setup/cleanup script for the running platform, falling back to
|
|
10
25
|
* `default`. Empty/whitespace-only scripts are treated as absent so a project
|
|
@@ -27,16 +42,30 @@ export function selectPlatformScript(scripts, platform = process.platform) {
|
|
|
27
42
|
*/
|
|
28
43
|
export async function createWorktree(cwd, slug, sessionId, opts = {}) {
|
|
29
44
|
validateWorktreeSlug(slug);
|
|
45
|
+
throwIfAborted(opts.signal);
|
|
30
46
|
const gitRoot = await findGitRoot(cwd);
|
|
47
|
+
throwIfAborted(opts.signal);
|
|
31
48
|
const branchName = applyPrefix(opts.prefix, slug, sessionId);
|
|
32
49
|
const worktreePath = resolve(gitRoot, "..", `.worktrees/${slug}-${sessionId.slice(0, 8)}`);
|
|
33
50
|
await assertBranchNotCheckedOut(gitRoot, branchName);
|
|
51
|
+
throwIfAborted(opts.signal);
|
|
34
52
|
const originalBranch = await currentBranch(gitRoot);
|
|
53
|
+
throwIfAborted(opts.signal);
|
|
35
54
|
// The argv form keeps branchName/worktreePath as literal positional
|
|
36
55
|
// arguments even if a future caller bypasses validateWorktreeSlug.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
56
|
+
try {
|
|
57
|
+
await execGit(gitRoot, ["worktree", "add", "-b", branchName, worktreePath], 30_000, opts.signal);
|
|
58
|
+
throwIfAborted(opts.signal);
|
|
59
|
+
// Symlink large directories to avoid disk bloat.
|
|
60
|
+
symlinkLargeDirectories(gitRoot, worktreePath);
|
|
61
|
+
throwIfAborted(opts.signal);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (opts.signal?.aborted) {
|
|
65
|
+
await cleanupAbortedWorktree(gitRoot, worktreePath, branchName);
|
|
66
|
+
}
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
40
69
|
return {
|
|
41
70
|
originalCwd: cwd,
|
|
42
71
|
worktreePath,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export declare const GIT_BIN: string;
|
|
2
|
-
export declare function execGit(cwd: string, args: string[], timeout?: number): Promise<string>;
|
|
2
|
+
export declare function execGit(cwd: string, args: string[], timeout?: number, signal?: AbortSignal): Promise<string>;
|
|
3
3
|
export declare function execGitSync(cwd: string, args: string[], timeout?: number): string;
|
|
4
|
-
export declare function gitOutput(cwd: string, args: string[], timeout?: number): Promise<string | undefined>;
|
|
4
|
+
export declare function gitOutput(cwd: string, args: string[], timeout?: number, signal?: AbortSignal): Promise<string | undefined>;
|
|
5
5
|
export declare function gitOutputSync(cwd: string, args: string[], timeout?: number): string | undefined;
|
|
6
6
|
export declare function gitErrorMessage(err: unknown): string;
|
|
7
7
|
export declare function normalizeBranchName(branch: string): string;
|