@cjhyy/code-shell-core 0.8.1 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1501,7 +1501,9 @@ export class Engine {
1501
1501
  this.lastMessages = messages;
1502
1502
  // Wire up LLM summarization for context compaction
1503
1503
  // Uses a lightweight call without tools
1504
- contextManager.setTranscriptPath(session.transcript.getFilePath());
1504
+ if (session.transcript.isPersistent()) {
1505
+ contextManager.setTranscriptPath(session.transcript.getFilePath());
1506
+ }
1505
1507
  // Re-derive frozen persistence decisions from the messages we just
1506
1508
  // loaded. Skipped on cold start (messages == [userContextMsg] only).
1507
1509
  // Critical for resume — otherwise a result that was persisted last
@@ -1704,14 +1706,17 @@ export class Engine {
1704
1706
  // capture the persisted total at run start and fold this run's usage onto
1705
1707
  // it (see foldRunUsage). Snapshot now, before any turn boundary fires.
1706
1708
  const usageBaseline = { ...session.state.tokenUsage };
1707
- const sessionDir = join(this.config.sessionStorageDir ?? sessionsRoot(), session.state.sessionId);
1708
- const fileHistoryHook = registerFileHistoryHook({
1709
- hooks: this.hooks,
1710
- sessionDir,
1711
- cwd,
1712
- getTurnSeq: () => session.state.turnSeq,
1713
- contributions: this.capabilities.flatMap((capability) => [...(capability.fileHistory ?? [])]),
1714
- });
1709
+ const fileHistoryHook = isEphemeralSessionState(session.state)
1710
+ ? { dispose() { } }
1711
+ : registerFileHistoryHook({
1712
+ hooks: this.hooks,
1713
+ sessionDir: join(this.config.sessionStorageDir ?? sessionsRoot(), session.state.sessionId),
1714
+ cwd,
1715
+ getTurnSeq: () => session.state.turnSeq,
1716
+ contributions: this.capabilities.flatMap((capability) => [
1717
+ ...(capability.fileHistory ?? []),
1718
+ ]),
1719
+ });
1715
1720
  // Hook: agent start
1716
1721
  await this.emitHook("on_agent_start", {
1717
1722
  sessionId: session.state.sessionId,
@@ -2800,7 +2805,9 @@ export class Engine {
2800
2805
  maxTokens: this.resolveMaxContextTokens(),
2801
2806
  ...Object.fromEntries(Object.entries(this.resolveContextRatios()).filter(([, v]) => v !== undefined)),
2802
2807
  });
2803
- contextManager.setTranscriptPath(session.transcript.getFilePath());
2808
+ if (session.transcript.isPersistent()) {
2809
+ contextManager.setTranscriptPath(session.transcript.getFilePath());
2810
+ }
2804
2811
  contextManager.initReplacementStateFromMessages(sourceMessages);
2805
2812
  this.lastContextManager = contextManager;
2806
2813
  }
@@ -279,5 +279,9 @@ export function assembleRunToolDefs(args) {
279
279
  const toolDefs = args.runPlanMode
280
280
  ? profileToolDefs.filter((t) => PLAN_MODE_ALLOWED_TOOLS.has(t.name))
281
281
  : profileToolDefs;
282
+ // ToolSearch shares the worker registry across Sessions, but discovery must
283
+ // reflect this exact run's filtered and rewritten surface. Keep the context
284
+ // snapshot in lockstep with the definitions sent to the model.
285
+ toolCtx.searchableToolDefinitions = toolDefs;
282
286
  return toolDefs;
283
287
  }
@@ -202,6 +202,9 @@ export declare class TurnLoop {
202
202
  * loop forces a stop so a stuck goal can't loop forever.
203
203
  */
204
204
  private stopBlockCount;
205
+ /** Consecutive identical tool-call/result batches, ignoring provider call ids. */
206
+ private repeatedToolBatchFingerprint;
207
+ private repeatedToolBatchCount;
205
208
  /**
206
209
  * Run-scoped goal budget tracker (Goal mode). Hoisted to an instance field
207
210
  * (not a run() local) so extend() can bump its budgets mid-run. Null when no
@@ -4,7 +4,7 @@
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 { randomUUID } from "node:crypto";
7
+ import { createHash, randomUUID } from "node:crypto";
8
8
  import { buildAgentDirectionMessage } from "../tool-system/builtin/agent-notifications.js";
9
9
  import { newTurnId } from "./turn-state.js";
10
10
  import { formatFriendlyError } from "./friendly-error.js";
@@ -40,6 +40,39 @@ export function toolResultToBlock(result) {
40
40
  block.is_error = true;
41
41
  return block;
42
42
  }
43
+ const REPEATED_TOOL_BATCH_LIMIT = 3;
44
+ function canonicalToolValue(value) {
45
+ if (value === null)
46
+ return "null";
47
+ if (value === undefined)
48
+ return "undefined";
49
+ if (Array.isArray(value))
50
+ return `[${value.map(canonicalToolValue).join(",")}]`;
51
+ if (typeof value === "object") {
52
+ return `{${Object.entries(value)
53
+ .sort(([left], [right]) => left.localeCompare(right))
54
+ .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalToolValue(entry)}`)
55
+ .join(",")}}`;
56
+ }
57
+ return JSON.stringify(value);
58
+ }
59
+ function repeatedToolBatchFingerprint(toolCalls, results) {
60
+ // Hash immediately and never log the canonical source: tool results may
61
+ // contain credentials or large media payloads. Call ids are deliberately
62
+ // omitted because providers generate a fresh id for every identical retry.
63
+ return createHash("sha256")
64
+ .update(canonicalToolValue({
65
+ calls: toolCalls.map((call) => ({ toolName: call.toolName, args: call.args })),
66
+ results: results.map((result) => ({
67
+ toolName: result.toolName,
68
+ isError: result.isError === true || Boolean(result.error),
69
+ error: result.error,
70
+ result: result.result,
71
+ contentBlocks: result.contentBlocks,
72
+ })),
73
+ }))
74
+ .digest("hex");
75
+ }
43
76
  export class TurnLoop {
44
77
  deps;
45
78
  config;
@@ -76,6 +109,9 @@ export class TurnLoop {
76
109
  * loop forces a stop so a stuck goal can't loop forever.
77
110
  */
78
111
  stopBlockCount = 0;
112
+ /** Consecutive identical tool-call/result batches, ignoring provider call ids. */
113
+ repeatedToolBatchFingerprint;
114
+ repeatedToolBatchCount = 0;
79
115
  /**
80
116
  * Run-scoped goal budget tracker (Goal mode). Hoisted to an instance field
81
117
  * (not a run() local) so extend() can bump its budgets mid-run. Null when no
@@ -1409,6 +1445,49 @@ export class TurnLoop {
1409
1445
  tlog.info("guard.stale_task", { cat: "guard", turn: this.turnCount });
1410
1446
  }
1411
1447
  }
1448
+ const toolBatchFingerprint = repeatedToolBatchFingerprint(toolCalls, results);
1449
+ if (toolBatchFingerprint === this.repeatedToolBatchFingerprint) {
1450
+ this.repeatedToolBatchCount++;
1451
+ }
1452
+ else {
1453
+ this.repeatedToolBatchFingerprint = toolBatchFingerprint;
1454
+ this.repeatedToolBatchCount = 1;
1455
+ }
1456
+ if (this.repeatedToolBatchCount >= REPEATED_TOOL_BATCH_LIMIT) {
1457
+ tlog.warn("turn.repeated_tool_batch_stopped", {
1458
+ cat: "turn",
1459
+ repeatedCount: this.repeatedToolBatchCount,
1460
+ tools: toolCalls.map((call) => call.toolName),
1461
+ });
1462
+ await this.emitHook("on_turn_end", {
1463
+ turnNumber: this.turnCount,
1464
+ hasToolUse: true,
1465
+ toolCallCount: toolCalls.length,
1466
+ });
1467
+ finalText =
1468
+ `检测到同一组工具调用及其结果连续重复 ${REPEATED_TOOL_BATCH_LIMIT} 次,` +
1469
+ "已自动停止,避免继续空转。请调整请求或让 Session 获取新的上下文后再试。";
1470
+ this.deps.transcript.appendMessage("assistant", finalText);
1471
+ messages.push({ role: "assistant", content: finalText });
1472
+ this.config.onStream?.({
1473
+ type: "assistant_message",
1474
+ messageId: assistantMessageId,
1475
+ message: { role: "assistant", content: finalText },
1476
+ });
1477
+ this.finalizeModelTurn();
1478
+ if (await this.consumeQueuedSteer(messages, "finalize_backfill")) {
1479
+ this.repeatedToolBatchFingerprint = undefined;
1480
+ this.repeatedToolBatchCount = 0;
1481
+ continue;
1482
+ }
1483
+ messages = this.redactConsumedSensitiveToolResults(messages);
1484
+ return {
1485
+ text: finalText,
1486
+ reason: "completed",
1487
+ messages,
1488
+ completionKind: "limit_stop",
1489
+ };
1490
+ }
1412
1491
  // Hook: turn end
1413
1492
  await this.emitHook("on_turn_end", {
1414
1493
  turnNumber: this.turnCount,
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Public API exports.
5
5
  */
6
- export declare const VERSION = "0.8.1";
6
+ export declare const VERSION = "0.8.3";
7
7
  export type { Message, ContentBlock, ToolDefinition, ToolCall, ToolResult, RegisteredTool, TranscriptEvent, TranscriptEventType, SessionState, SessionKind, SessionWorkspace, SessionForkLineage, ContextUsageAnchor, SessionStatus, TokenUsage, CompiledInput, PermissionDecision, PermissionMode, PermissionRule, TurnPhase, TurnResult, TerminalReason, TurnCompletionKind, StreamEvent, StreamCallback, LLMConfig, ClientDefaults, LLMResponse, Settings, MCPServerConfig, } from "./types.js";
8
8
  export type { GoalConfig, GoalLifecycleConfig, GoalLifecyclePhase, GoalLifecycleTerminalReason, GoalLifecycleV1, } from "./goal/lifecycle.js";
9
9
  export { FrameworkError, LLMError, LLMRateLimitError, ContextLimitError, ToolError, ToolNotFoundError, ToolExecutionError, ToolTimeoutError, PermissionDeniedError, SessionError, TranscriptError, ConfigError, SandboxUnavailableError, } from "./exceptions.js";
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Public API exports.
5
5
  */
6
- export const VERSION = "0.8.1";
6
+ export const VERSION = "0.8.3";
7
7
  // ─── Exceptions ──────────────────────────────────────────────────
8
8
  export { FrameworkError, LLMError, LLMRateLimitError, ContextLimitError, ToolError, ToolNotFoundError, ToolExecutionError, ToolTimeoutError, PermissionDeniedError, SessionError, TranscriptError, ConfigError, SandboxUnavailableError, } from "./exceptions.js";
9
9
  // ─── Engine (primary API) ────────────────────────────────────────
@@ -145,6 +145,52 @@ export async function runStreamWithWatchdog(stream, opts = {}) {
145
145
  }
146
146
  return text;
147
147
  }
148
+ const MISSING_TOOL_RESULT_WIRE_TEXT = "Error: Tool execution did not complete before the conversation resumed.";
149
+ /**
150
+ * OpenAI requires each assistant tool_calls batch to be followed immediately
151
+ * by exactly one role:tool message per id. Normalize at the provider boundary
152
+ * as a final guard against legacy/corrupt transcripts: keep the latest result
153
+ * for a duplicate id, synthesize a request-local result for a missing id, and
154
+ * discard tool messages that were never declared by the preceding assistant.
155
+ */
156
+ function normalizeOpenAIToolMessagePairs(messages) {
157
+ const normalized = [];
158
+ for (let index = 0; index < messages.length; index++) {
159
+ const message = messages[index];
160
+ if (message.role === "tool")
161
+ continue;
162
+ normalized.push(message);
163
+ if (message.role !== "assistant")
164
+ continue;
165
+ const toolCalls = message.tool_calls;
166
+ const expectedIds = Array.isArray(toolCalls)
167
+ ? toolCalls
168
+ .map((toolCall) => toolCall.id)
169
+ .filter((id) => typeof id === "string" && id.length > 0)
170
+ : [];
171
+ if (expectedIds.length === 0)
172
+ continue;
173
+ const expected = new Set(expectedIds);
174
+ const latestResultById = new Map();
175
+ let cursor = index + 1;
176
+ while (cursor < messages.length && messages[cursor]?.role === "tool") {
177
+ const toolMessage = messages[cursor];
178
+ if (expected.has(toolMessage.tool_call_id)) {
179
+ latestResultById.set(toolMessage.tool_call_id, toolMessage);
180
+ }
181
+ cursor++;
182
+ }
183
+ for (const id of expectedIds) {
184
+ normalized.push(latestResultById.get(id) ?? {
185
+ role: "tool",
186
+ tool_call_id: id,
187
+ content: MISSING_TOOL_RESULT_WIRE_TEXT,
188
+ });
189
+ }
190
+ index = cursor - 1;
191
+ }
192
+ return normalized;
193
+ }
148
194
  export class OpenAIClient extends LLMClientBase {
149
195
  _client = null;
150
196
  // Sticky override: once the endpoint tells us `max_tokens` is rejected for
@@ -839,10 +885,11 @@ export class OpenAIClient extends LLMClientBase {
839
885
  }
840
886
  }
841
887
  }
888
+ const normalized = normalizeOpenAIToolMessagePairs(result);
842
889
  if (this.isOpenRouterAnthropic) {
843
- this.applyAnthropicCacheBreakpoints(result);
890
+ this.applyAnthropicCacheBreakpoints(normalized);
844
891
  }
845
- return result;
892
+ return normalized;
846
893
  }
847
894
  /**
848
895
  * In-place: add prompt-cache breakpoints for Anthropic-over-OpenRouter.
@@ -190,6 +190,14 @@ export class ChatSessionManager {
190
190
  return alreadyClosing;
191
191
  const s = this.sessions.get(sessionId);
192
192
  if (!s) {
193
+ if (sessionId.startsWith("qchat-")) {
194
+ try {
195
+ this.engineSessionManager(this.factory({}))?.forgetEphemeralSession?.(sessionId);
196
+ }
197
+ catch {
198
+ // Closing an already-expired process-local chat is idempotent.
199
+ }
200
+ }
193
201
  if (markClosed)
194
202
  this.rememberClosedSession(sessionId);
195
203
  return Promise.resolve();
@@ -204,6 +212,7 @@ export class ChatSessionManager {
204
212
  clearCredentialSessionAllow(sessionId);
205
213
  clearInjectCredentialSessionAllow(sessionId);
206
214
  const finishClose = () => {
215
+ sessionManager?.forgetEphemeralSession?.(sessionId);
207
216
  this.unregisterMcpOwner(s);
208
217
  if (this.sessions.get(sessionId) === s)
209
218
  this.sessions.delete(sessionId);
@@ -27,10 +27,11 @@ export const AUTOMATION_RUN_SOURCE = "automation";
27
27
  export const AUTOMATION_PROMPT_NOTE = "This is an unattended, scheduled automation run. No human is watching, and " +
28
28
  "AskUserQuestion will not reach anyone. You ARE the automation — do not ask " +
29
29
  "the user questions and do not offer to set up or schedule automation. " +
30
- "Produce the requested output directly; when uncertain, state your assumption " +
31
- "and proceed." +
32
- " When finished, call UpdateAutomationMemory exactly once with a concise " +
33
- "summary of this run's key findings/state for the next run.";
30
+ "When uncertain, state your assumption and proceed. When the work is ready, " +
31
+ "first call UpdateAutomationMemory exactly once with a concise summary of this " +
32
+ "run's key findings/state for the next run. After that call succeeds, return the " +
33
+ "complete requested output as the final assistant message. Do not replace the " +
34
+ "requested output with a completion acknowledgement or put it in a formatting tool.";
34
35
  /** Compose the run's appendSystemPrompt: prepend the automation note when the
35
36
  * run is tagged source "automation", preserving any host-provided append. */
36
37
  export function buildAppendSystemPrompt(hostAppend, metadata) {
@@ -18,7 +18,7 @@ export interface ForkSessionOptions {
18
18
  throughEventId?: string;
19
19
  /** `completed` is the interrupted snapshot used by ephemeral side chats. */
20
20
  snapshotMode?: "tail" | "completed";
21
- /** Hide this temporary fork from ordinary session lists and resume pickers. */
21
+ /** Keep this temporary fork in process memory only. */
22
22
  ephemeral?: boolean;
23
23
  }
24
24
  export interface ForkSessionResult {
@@ -94,24 +94,22 @@ export declare class SessionManager {
94
94
  private readonly registeredCloseEpochs;
95
95
  private readonly workspaceCapability?;
96
96
  constructor(storageDir?: string, workspaceCapability?: SessionWorkspaceCapability);
97
+ private processLocalKey;
98
+ private processLocalBundle;
99
+ private storeProcessLocalBundle;
100
+ /** Forget a Quick Chat/side-chat bundle immediately; nothing remains on disk. */
101
+ forgetEphemeralSession(sessionId: string): boolean;
97
102
  private cleanupStaleForkStaging;
98
103
  /** Bind one Engine/session pair to the current close epoch without advancing it. */
99
104
  registerSessionGeneration(sessionId: string): number;
100
105
  /** Advance the close epoch once before close waits for the old run to settle. */
101
106
  incrementSessionGeneration(sessionId: string): number;
102
107
  /**
103
- * Create a new on-disk session. If `explicitSessionId` is passed, use
104
- * it verbatim (ChatSessionManager-driven hosts choose a logical sid like
105
- * "tui-main" and expect us to honor it). Otherwise generate one with
106
- * nanoid. Either way the on-disk directory is materialized and the
107
- * state.json + transcript.jsonl files are written before return.
108
+ * Create a session. `qchat-` sessions stay process-local; ordinary sessions
109
+ * materialize state.json + transcript.jsonl before return.
108
110
  */
109
111
  create(cwd: string, model: string, provider: string, explicitSessionId?: string, parentSessionId?: string | null, origin?: import("../types.js").SessionOrigin, kind?: SessionKind): SessionBundle;
110
- /**
111
- * Whether a session directory exists on disk. Used by ChatSession-driven
112
- * cold starts to decide between resume vs create-with-explicit-sid
113
- * without catching SessionError.
114
- */
112
+ /** Whether a persisted or process-local session exists. */
115
113
  exists(sessionId: string): boolean;
116
114
  /**
117
115
  * Cheap persisted-main-root probe — reads only state.json, NOT the transcript
@@ -142,10 +140,10 @@ export declare class SessionManager {
142
140
  }): string[];
143
141
  /** @deprecated Use readSessionMainRoot; retained for public API compatibility. */
144
142
  readCwd(sessionId: string): string | undefined;
145
- /** Disk-only direct-parent ACL metadata. Undefined means unprovable/corrupt. */
143
+ /** Direct-parent ACL metadata. Undefined means unprovable/corrupt. */
146
144
  readParentSessionId(sessionId: string): string | null | undefined;
147
145
  /**
148
- * Disk-only workspace pointer reader. Legacy sessions written before
146
+ * Workspace pointer reader. Legacy sessions written before
149
147
  * `workspace` existed are treated as main-workspace sessions rooted at
150
148
  * `state.cwd`; the read is intentionally non-mutating.
151
149
  */
@@ -306,6 +304,7 @@ export declare class SessionManager {
306
304
  /** Publish a summary-only top-level fork after summarization has succeeded. */
307
305
  createSummaryFork(sourceSessionId: string, options: SummaryForkOptions): ForkSessionResult;
308
306
  private readForkSnapshot;
307
+ private freezeForkSnapshot;
309
308
  private publishSessionAtomically;
310
309
  list(limit?: number, opts?: {
311
310
  excludeKinds?: readonly string[];