@cjhyy/code-shell-core 0.9.3 → 0.9.5

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.
@@ -317,6 +317,14 @@ export declare class Engine {
317
317
  * in-process AgentServer to decide whether to wire an interactive askUser.
318
318
  */
319
319
  isHeadless(): boolean;
320
+ /**
321
+ * A background_notification park only makes sense where the Session can be
322
+ * woken by the completion later (server refuses headless; sub-agent sessions
323
+ * are not in chatManager) — everywhere else honouring it would end the run
324
+ * early and orphan the background result. A reply_committed boundary is
325
+ * synchronous and never suppressed.
326
+ */
327
+ private suppressesRunYield;
320
328
  get permissionMode(): NonNullable<EngineConfig["permissionMode"]>;
321
329
  get planMode(): boolean;
322
330
  /**
@@ -804,6 +804,16 @@ export class Engine {
804
804
  isHeadless() {
805
805
  return this.config.headless === true;
806
806
  }
807
+ /**
808
+ * A background_notification park only makes sense where the Session can be
809
+ * woken by the completion later (server refuses headless; sub-agent sessions
810
+ * are not in chatManager) — everywhere else honouring it would end the run
811
+ * early and orphan the background result. A reply_committed boundary is
812
+ * synchronous and never suppressed.
813
+ */
814
+ suppressesRunYield(reason) {
815
+ return (reason === "background_notification" && (this.isHeadless() || this.config.isSubAgent === true));
816
+ }
807
817
  get permissionMode() {
808
818
  return this.permissionController.permissionMode;
809
819
  }
@@ -2172,18 +2182,12 @@ export class Engine {
2172
2182
  publishGoalJudgeContext: (context) => {
2173
2183
  publishGoalJudgeContext(context);
2174
2184
  },
2175
- // A background_notification yield parks the run until the Session is
2176
- // woken by the completion notification. Only a top-level interactive
2177
- // session can be woken (server refuses headless; sub-agent sessions
2178
- // are not in chatManager) everywhere else honouring the yield would
2179
- // end the run early and orphan the background result, so the loop
2180
- // never sees the request and the model keeps its full turn.
2181
- ...(this.isHeadless() || this.config.isSubAgent === true
2182
- ? {}
2183
- : {
2184
- peekToolRunYield: () => toolCtx.runYield?.peek?.(),
2185
- consumeToolRunYield: () => toolCtx.runYield?.consume(),
2186
- }),
2185
+ // A background_notification yield is visible only where the Session
2186
+ // can be woken later. A committed host reply is a synchronous terminal
2187
+ // boundary, so it must remain visible in headless and sub-agent runs.
2188
+ // One predicate serves peek and consume so they can never disagree.
2189
+ peekToolRunYield: (reason) => !this.suppressesRunYield(reason) && toolCtx.runYield?.peek(reason) === true,
2190
+ consumeToolRunYield: (reason) => !this.suppressesRunYield(reason) && toolCtx.runYield?.consume(reason) === true,
2187
2191
  ctxOverheadStore: {
2188
2192
  get: (s) => this.ctxOverheadBySid.get(s) ?? 0,
2189
2193
  set: (s, n) => {
@@ -2,11 +2,14 @@
2
2
  * Model call facade — wraps LLM client with transcript integration.
3
3
  */
4
4
  import type { LLMClientBase } from "../llm/client-base.js";
5
+ import type { PromptCacheRequestContext } from "../llm/prompt-cache.js";
5
6
  import type { Message, ToolDefinition, LLMResponse, StreamCallback } from "../types.js";
6
7
  import { Transcript } from "../session/transcript.js";
7
8
  import { type PromptPrefixFingerprint } from "./prompt-cache-diagnostics.js";
8
9
  export interface ModelCallRecordingOptions {
9
10
  sensitiveToolResultRedactions?: ReadonlyMap<string, string>;
11
+ /** Cache boundary metadata; scopeId is filled from the active session. */
12
+ promptCache?: Omit<PromptCacheRequestContext, "scopeId">;
10
13
  }
11
14
  /**
12
15
  * Prompt-cache hit rate for one request, as CC computes it:
@@ -89,6 +89,7 @@ export class ModelFacade {
89
89
  }
90
90
  },
91
91
  signal,
92
+ promptCache: { scopeId: sid, ...recordingOptions?.promptCache },
92
93
  });
93
94
  }
94
95
  catch (err) {
@@ -144,6 +145,7 @@ export class ModelFacade {
144
145
  tools,
145
146
  stream: false,
146
147
  signal,
148
+ promptCache: { scopeId: sid, ...recordingOptions?.promptCache },
147
149
  });
148
150
  }
149
151
  catch (err) {
@@ -10,7 +10,7 @@ import { applyDynamicToolDef } from "./dynamic-tool-defs.js";
10
10
  /** engine.ts L1485-1522 —— ToolContext 组装(spawner、agentDefinitions、base 由调用方传入)。 */
11
11
  export function buildRunToolContext(args) {
12
12
  const { options, profile, profileParams } = args;
13
- let pendingRunYield;
13
+ const pendingRunYield = new Set();
14
14
  // sessionId is filled in after the session bundle is resolved below
15
15
  // (the session may be cold-started or resumed). Until then this is
16
16
  // intentionally shaped as a mutable local; we treat it as immutable
@@ -40,16 +40,16 @@ export function buildRunToolContext(args) {
40
40
  toolCtx.cwd = nextCwd;
41
41
  },
42
42
  runYield: {
43
+ // Reasons accumulate independently — a batch may both commit a host
44
+ // reply and launch background work; the turn loop decides precedence.
43
45
  request(reason) {
44
- pendingRunYield ??= reason;
46
+ pendingRunYield.add(reason);
45
47
  },
46
- peek() {
47
- return pendingRunYield;
48
+ peek(reason) {
49
+ return pendingRunYield.has(reason);
48
50
  },
49
- consume() {
50
- const reason = pendingRunYield;
51
- pendingRunYield = undefined;
52
- return reason;
51
+ consume(reason) {
52
+ return pendingRunYield.delete(reason);
53
53
  },
54
54
  },
55
55
  skillAllowlist: options?.skillAllowlist !== undefined
@@ -17,12 +17,15 @@ import type { ToolCall, ToolResult } from "../types.js";
17
17
  import type { ToolExecutor } from "../tool-system/executor.js";
18
18
  export declare class StreamingToolQueue {
19
19
  private readonly executor;
20
+ private readonly pendingUnsafeSkipReason?;
20
21
  private readonly pending;
21
22
  private readonly unsafeQueue;
22
23
  private readonly callOrder;
23
24
  private readonly toolNameById;
24
25
  private draining;
25
- constructor(executor: ToolExecutor);
26
+ constructor(executor: ToolExecutor, options?: {
27
+ pendingUnsafeSkipReason?: () => string | undefined;
28
+ });
26
29
  /**
27
30
  * Enqueue a tool from the completed response. Concurrency-safe tools start
28
31
  * immediately within this post-response phase; unsafe tools are queued for
@@ -15,13 +15,15 @@
15
15
  */
16
16
  export class StreamingToolQueue {
17
17
  executor;
18
+ pendingUnsafeSkipReason;
18
19
  pending = new Map();
19
20
  unsafeQueue = [];
20
21
  callOrder = [];
21
22
  toolNameById = new Map();
22
23
  draining = false;
23
- constructor(executor) {
24
+ constructor(executor, options) {
24
25
  this.executor = executor;
26
+ this.pendingUnsafeSkipReason = options?.pendingUnsafeSkipReason;
25
27
  }
26
28
  /**
27
29
  * Enqueue a tool from the completed response. Concurrency-safe tools start
@@ -62,8 +64,21 @@ export class StreamingToolQueue {
62
64
  this.draining = true;
63
65
  const resultMap = new Map();
64
66
  // Execute unsafe tools sequentially. A rejection here must not stop the
65
- // remaining unsafe tools from running.
67
+ // remaining unsafe tools from running. A trusted terminal boundary may,
68
+ // however, skip calls that have not started yet. We still synthesize one
69
+ // result per call so tool_use/tool_result history remains structurally
70
+ // complete even though the run will stop after this batch.
66
71
  for (const call of this.unsafeQueue) {
72
+ const skipReason = this.pendingUnsafeSkipReason?.();
73
+ if (skipReason) {
74
+ resultMap.set(call.id, {
75
+ id: call.id,
76
+ toolName: call.toolName,
77
+ error: `Tool execution skipped: ${skipReason}`,
78
+ isError: true,
79
+ });
80
+ continue;
81
+ }
67
82
  const p = this.executor.executeSingle(call);
68
83
  this.pending.set(call.id, p);
69
84
  resultMap.set(call.id, await this.toResult(call.id, call.toolName, p));
@@ -150,10 +150,10 @@ export interface TurnLoopDeps {
150
150
  * built-in judge closure; it is never added to the public on_stop context.
151
151
  */
152
152
  publishGoalJudgeContext?: (context: GoalJudgeRuntimeContext) => void;
153
- /** Inspect a trusted tool's pending run yield without clearing it. */
154
- peekToolRunYield?: () => import("../tool-system/context.js").ToolRunYieldReason | undefined;
155
- /** Consume a trusted tool's request to yield until an async notification. */
156
- consumeToolRunYield?: () => import("../tool-system/context.js").ToolRunYieldReason | undefined;
153
+ /** Whether a trusted tool has a specific run boundary pending (not cleared). */
154
+ peekToolRunYield?: (reason: import("../tool-system/context.js").ToolRunYieldReason) => boolean;
155
+ /** Consume a specific pending run boundary; true if it was pending. */
156
+ consumeToolRunYield?: (reason: import("../tool-system/context.js").ToolRunYieldReason) => boolean;
157
157
  }
158
158
  export interface TurnLoopResult {
159
159
  text: string;
@@ -287,7 +287,16 @@ export declare class TurnLoop {
287
287
  private finalizeModelTurn;
288
288
  private prepareMessagesForModel;
289
289
  private stripVolatileContextMessages;
290
- private appendVolatileContextMessages;
290
+ /**
291
+ * Keep volatile context out of compaction/summarization without moving it on
292
+ * every model round. If context management is a no-op, return the original
293
+ * array so the provider sees a strictly append-only prompt. A real rewrite
294
+ * (dedupe/compaction/truncation) already invalidates the old prefix, so start
295
+ * a fresh append-only segment with the volatile snapshot at the new tail.
296
+ */
297
+ private restoreVolatileAfterContextManagement;
298
+ private manageContextMessages;
299
+ private manageContextMessagesSync;
291
300
  private markPendingImagesConsumed;
292
301
  private redactConsumedSensitiveToolResults;
293
302
  private modelCallRecordingOptions;
@@ -340,10 +340,36 @@ export class TurnLoop {
340
340
  });
341
341
  return changed ? stripped : messages;
342
342
  }
343
- appendVolatileContextMessages(messages) {
344
- if (this.volatileContextMessages.size === 0)
345
- return messages;
346
- return [...this.stripVolatileContextMessages(messages), ...this.volatileContextMessages];
343
+ /**
344
+ * Keep volatile context out of compaction/summarization without moving it on
345
+ * every model round. If context management is a no-op, return the original
346
+ * array so the provider sees a strictly append-only prompt. A real rewrite
347
+ * (dedupe/compaction/truncation) already invalidates the old prefix, so start
348
+ * a fresh append-only segment with the volatile snapshot at the new tail.
349
+ */
350
+ restoreVolatileAfterContextManagement(original, stableInput, managedStable) {
351
+ const unchanged = stableInput.length === managedStable.length &&
352
+ stableInput.every((message, index) => managedStable[index] === message);
353
+ if (unchanged)
354
+ return original;
355
+ const volatile = original.filter((message) => this.volatileContextMessages.has(message));
356
+ return [...managedStable, ...volatile];
357
+ }
358
+ async manageContextMessages(messages) {
359
+ if (this.volatileContextMessages.size === 0) {
360
+ return this.deps.contextManager.manageAsync(messages, this.config.signal);
361
+ }
362
+ const stable = this.stripVolatileContextMessages(messages);
363
+ const managed = await this.deps.contextManager.manageAsync(stable, this.config.signal);
364
+ return this.restoreVolatileAfterContextManagement(messages, stable, managed);
365
+ }
366
+ manageContextMessagesSync(messages) {
367
+ if (this.volatileContextMessages.size === 0) {
368
+ return this.deps.contextManager.manage(messages);
369
+ }
370
+ const stable = this.stripVolatileContextMessages(messages);
371
+ const managed = this.deps.contextManager.manage(stable);
372
+ return this.restoreVolatileAfterContextManagement(messages, stable, managed);
347
373
  }
348
374
  markPendingImagesConsumed(messages) {
349
375
  if (this.pendingImageMessages.size === 0)
@@ -367,11 +393,15 @@ export class TurnLoop {
367
393
  this.sensitiveToolResultRedactions.clear();
368
394
  return redacted;
369
395
  }
370
- modelCallRecordingOptions() {
371
- if (this.sensitiveToolResultRedactions.size === 0)
372
- return undefined;
396
+ modelCallRecordingOptions(messages) {
397
+ const volatileIndex = messages.findIndex((message) => this.volatileContextMessages.has(message));
373
398
  return {
374
- sensitiveToolResultRedactions: new Map(this.sensitiveToolResultRedactions),
399
+ ...(this.sensitiveToolResultRedactions.size > 0
400
+ ? { sensitiveToolResultRedactions: new Map(this.sensitiveToolResultRedactions) }
401
+ : {}),
402
+ promptCache: {
403
+ stablePrefixMessageCount: volatileIndex >= 0 ? volatileIndex : messages.length,
404
+ },
375
405
  };
376
406
  }
377
407
  trackFreshImageMessage(message) {
@@ -657,7 +687,6 @@ export class TurnLoop {
657
687
  // pendingImageMessages are preserved through this next model request.
658
688
  const hasPendingSensitiveToolResults = this.sensitiveToolResultRedactions.size > 0;
659
689
  messages = this.prepareMessagesForModel(messages);
660
- messages = this.stripVolatileContextMessages(messages);
661
690
  if (hasPendingSensitiveToolResults) {
662
691
  tlog.info("turn.sensitive_tool_result_context_management_skipped", {
663
692
  cat: "turn",
@@ -666,7 +695,7 @@ export class TurnLoop {
666
695
  }
667
696
  else {
668
697
  // Context management (async — may trigger LLM summarization)
669
- messages = await this.deps.contextManager.manageAsync(messages, this.config.signal);
698
+ messages = await this.manageContextMessages(messages);
670
699
  // manageAsync can itself issue an LLM summarization call lasting several
671
700
  // seconds; if the signal aborted during it, stop here rather than
672
701
  // proceeding into the (expensive) main model call. Belt to the loop-top
@@ -710,7 +739,6 @@ export class TurnLoop {
710
739
  messages = this.redactConsumedSensitiveToolResults(messages);
711
740
  return { text: finalText, reason: "completed", messages };
712
741
  }
713
- messages = this.appendVolatileContextMessages(messages);
714
742
  // Model call (with streaming fallback and max_output_tokens continuation)
715
743
  this.config.onStream?.({
716
744
  type: "stream_request_start",
@@ -721,7 +749,15 @@ export class TurnLoop {
721
749
  this.streamedToolIds.clear();
722
750
  // Tool queue is created before the call, but enqueue happens only after
723
751
  // the complete LLMResponse is available below.
724
- const streamingQueue = new StreamingToolQueue(this.deps.toolExecutor);
752
+ const streamingQueue = new StreamingToolQueue(this.deps.toolExecutor, {
753
+ // Once a trusted reply tool commits the authoritative host response,
754
+ // later sequential calls from the same model batch must not execute.
755
+ // Concurrency-safe calls may already be running; drain still awaits
756
+ // those so the transcript remains complete.
757
+ pendingUnsafeSkipReason: () => this.deps.peekToolRunYield?.("reply_committed")
758
+ ? "an authoritative host reply was already committed"
759
+ : undefined,
760
+ });
725
761
  let response;
726
762
  try {
727
763
  response = await this.callModelWithFallback(messages, assistantMessageId);
@@ -891,7 +927,8 @@ export class TurnLoop {
891
927
  },
892
928
  ];
893
929
  try {
894
- const contResponse = await this.deps.model.call(this.deps.systemPrompt, this.prepareMessagesForModel(contMessages), this.deps.tools, this.config.onStream, this.config.signal);
930
+ const preparedContinuationMessages = this.prepareMessagesForModel(contMessages);
931
+ const contResponse = await this.deps.model.call(this.deps.systemPrompt, preparedContinuationMessages, this.deps.tools, this.config.onStream, this.config.signal, this.modelCallRecordingOptions(preparedContinuationMessages));
895
932
  // Continuations are separate provider responses, so preserve the
896
933
  // same structural tool_use invariant before processing this one.
897
934
  if (contResponse.toolCalls.length > 0) {
@@ -995,7 +1032,7 @@ export class TurnLoop {
995
1032
  // answered before parking the run. Keep the tool's yield request
996
1033
  // pending across that extra model round, then park once the model
997
1034
  // has replied and there is still no background result to consume.
998
- if (this.deps.consumeToolRunYield?.() === "background_notification") {
1035
+ if (this.deps.consumeToolRunYield?.("background_notification")) {
999
1036
  tlog.info("turn.background_notification_wait_after_steer", { cat: "turn" });
1000
1037
  messages = this.redactConsumedSensitiveToolResults(messages);
1001
1038
  return {
@@ -1272,30 +1309,47 @@ export class TurnLoop {
1272
1309
  // Tool results just pushed; recompute ctx so the bar updates *before*
1273
1310
  // the next model round-trip — large tool outputs can move it sharply.
1274
1311
  this.emitCtxFromMessages(messages);
1275
- // A trusted tool launched asynchronous work whose completion is routed
1276
- // back into this Session. End this run at the tool boundary instead of
1277
- // asking the model for another step with no new evidence; the queued
1278
- // completion notification will wake the Session and continue normally.
1279
- // This precedes complete_goal so a single batch cannot launch unfinished
1280
- // background work and simultaneously claim the enclosing Goal is done.
1281
- if (this.deps.peekToolRunYield?.() === "background_notification") {
1312
+ // A trusted tool marked a run boundary. reply_committed: the
1313
+ // authoritative user-facing reply is recorded another model request
1314
+ // could only produce duplicate tools or stray assistant text.
1315
+ // background_notification: async work was launched whose completion
1316
+ // notification will wake this Session, so the run parks instead of
1317
+ // asking the model for another step with no new evidence. One batch
1318
+ // may pend both; the park wins so the completion finds a parked run.
1319
+ const replyCommitted = this.deps.peekToolRunYield?.("reply_committed") === true;
1320
+ const backgroundWait = this.deps.peekToolRunYield?.("background_notification") === true;
1321
+ if (replyCommitted || backgroundWait) {
1282
1322
  // Close the current model turn before a queued steer re-drives it;
1283
1323
  // interrupt-and-redrive swaps the turn signal at this boundary.
1284
1324
  this.finalizeModelTurn();
1285
1325
  if (await this.consumeQueuedSteer(messages, "finalize_backfill")) {
1326
+ // A user message arrived while the batch was executing. The
1327
+ // committed reply answered the previous content only — lift the
1328
+ // reply barrier so the re-driven round can answer the new message
1329
+ // (the host itself rejects duplicate authoritative replies), and
1330
+ // keep any background park pending across the extra round.
1331
+ if (replyCommitted)
1332
+ this.deps.consumeToolRunYield?.("reply_committed");
1286
1333
  continue;
1287
1334
  }
1288
- }
1289
- if (this.deps.peekToolRunYield?.() === "background_notification" &&
1290
- this.deps.consumeToolRunYield?.() === "background_notification") {
1291
- tlog.info("turn.background_notification_wait", { cat: "turn" });
1292
- messages = this.redactConsumedSensitiveToolResults(messages);
1293
- return {
1294
- text: finalText,
1295
- reason: "completed",
1296
- messages,
1297
- completionKind: "background_wait",
1298
- };
1335
+ // Park before complete_goal so a single batch cannot launch
1336
+ // unfinished background work and simultaneously claim the enclosing
1337
+ // Goal is done.
1338
+ if (backgroundWait && this.deps.consumeToolRunYield?.("background_notification")) {
1339
+ tlog.info("turn.background_notification_wait", { cat: "turn" });
1340
+ messages = this.redactConsumedSensitiveToolResults(messages);
1341
+ return {
1342
+ text: finalText,
1343
+ reason: "completed",
1344
+ messages,
1345
+ completionKind: "background_wait",
1346
+ };
1347
+ }
1348
+ if (replyCommitted && this.deps.consumeToolRunYield?.("reply_committed")) {
1349
+ tlog.info("turn.reply_committed_stop", { cat: "turn" });
1350
+ messages = this.redactConsumedSensitiveToolResults(messages);
1351
+ return { text: finalText, reason: "completed", messages };
1352
+ }
1299
1353
  }
1300
1354
  // Goal mode P0: explicit completion. If the model called complete_goal,
1301
1355
  // it has DECLARED the goal done — short-circuit to "completed" WITHOUT
@@ -1496,7 +1550,7 @@ export class TurnLoop {
1496
1550
  });
1497
1551
  }
1498
1552
  else {
1499
- messages = this.deps.contextManager.manage(messages);
1553
+ messages = this.manageContextMessagesSync(messages);
1500
1554
  }
1501
1555
  if (this.goalControlStopRequested) {
1502
1556
  messages = this.redactConsumedSensitiveToolResults(messages);
@@ -1512,8 +1566,9 @@ export class TurnLoop {
1512
1566
  messages = this.redactConsumedSensitiveToolResults(messages);
1513
1567
  return { text: finalText, reason: "completed", messages };
1514
1568
  }
1515
- const summaryResponse = await this.deps.model.call(this.deps.systemPrompt, this.prepareMessagesForModel(messages), [], // No tools available for summary turn
1516
- this.config.onStream, this.config.signal, this.modelCallRecordingOptions());
1569
+ const summaryMessages = this.prepareMessagesForModel(messages);
1570
+ const summaryResponse = await this.deps.model.call(this.deps.systemPrompt, summaryMessages, [], // No tools available for summary turn
1571
+ this.config.onStream, this.config.signal, this.modelCallRecordingOptions(summaryMessages));
1517
1572
  if (summaryResponse.usage?.promptTokens !== undefined) {
1518
1573
  this.recordResponseUsage(summaryResponse.usage, "primary", false);
1519
1574
  }
@@ -1591,7 +1646,7 @@ export class TurnLoop {
1591
1646
  }
1592
1647
  : undefined;
1593
1648
  try {
1594
- return await this.deps.model.call(this.deps.systemPrompt, messages, this.deps.tools, wrappedStream, this.config.signal, this.modelCallRecordingOptions());
1649
+ return await this.deps.model.call(this.deps.systemPrompt, messages, this.deps.tools, wrappedStream, this.config.signal, this.modelCallRecordingOptions(messages));
1595
1650
  }
1596
1651
  catch (err) {
1597
1652
  // If it's a context or rate limit error, don't fallback — propagate
@@ -1637,7 +1692,7 @@ export class TurnLoop {
1637
1692
  error: err.message,
1638
1693
  });
1639
1694
  // Retry without streaming
1640
- return await this.deps.model.callWithoutStreaming(this.deps.systemPrompt, messages, this.deps.tools, this.config.signal, this.modelCallRecordingOptions());
1695
+ return await this.deps.model.callWithoutStreaming(this.deps.systemPrompt, messages, this.deps.tools, this.config.signal, this.modelCallRecordingOptions(messages));
1641
1696
  }
1642
1697
  }
1643
1698
  get currentTurn() {
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.9.3";
6
+ export declare const VERSION = "0.9.5";
7
7
  export type { Message, ContentBlock, ToolDefinition, ToolCall, ToolResult, RegisteredTool, TranscriptEvent, TranscriptEventType, SessionState, SessionKind, SessionWorkspace, SessionProjectBinding, 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, CompositionError, 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.9.3";
6
+ export const VERSION = "0.9.5";
7
7
  // ─── Exceptions ──────────────────────────────────────────────────
8
8
  export { FrameworkError, LLMError, LLMRateLimitError, ContextLimitError, ToolError, ToolNotFoundError, ToolExecutionError, ToolTimeoutError, PermissionDeniedError, SessionError, TranscriptError, ConfigError, CompositionError, SandboxUnavailableError, } from "./exceptions.js";
9
9
  // ─── Composition (AgentModule / ResolvedComposition) ─────────────
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Provider-neutral prompt-cache planning.
3
+ *
4
+ * This module owns the semantic cache policy (session affinity and reusable
5
+ * prefix boundaries). Provider clients only translate the plan to their wire
6
+ * format: OpenAI `prompt_cache_*` fields or Anthropic `cache_control` blocks.
7
+ */
8
+ export type PromptCacheStrategy = "openai-explicit" | "openai-implicit" | "anthropic-explicit" | "provider-managed";
9
+ export type PromptCacheBreakpoint = "system" | "tools" | "stable-history" | "rolling-history";
10
+ export interface PromptCacheRequestContext {
11
+ /** Stable run/session namespace used for provider cache affinity. */
12
+ scopeId?: string;
13
+ /**
14
+ * Number of source messages before the first volatile context message.
15
+ * Providers use this to retain a reusable durable-history breakpoint while
16
+ * also advancing a rolling breakpoint over the append-only in-run tail.
17
+ */
18
+ stablePrefixMessageCount?: number;
19
+ }
20
+ export interface PromptCachePolicy {
21
+ strategy: PromptCacheStrategy;
22
+ layoutVersion: string;
23
+ breakpoints: readonly PromptCacheBreakpoint[];
24
+ /** Opaque and <=64 chars, as required by OpenAI's prompt_cache_key. */
25
+ cacheKey?: string;
26
+ /** GPT-5.6+ explicit-cache request mode. */
27
+ promptCacheOptions?: {
28
+ mode: "explicit";
29
+ ttl: "30m";
30
+ };
31
+ }
32
+ export interface ResolvePromptCachePolicyInput {
33
+ provider: string;
34
+ providerKind?: string;
35
+ model: string;
36
+ request?: PromptCacheRequestContext;
37
+ /** Sticky compatibility fallback after an endpoint rejects explicit fields. */
38
+ explicitDisabled?: boolean;
39
+ }
40
+ /**
41
+ * Produce a privacy-preserving stable affinity key without leaking a raw
42
+ * session id to the provider. The prefix plus 48 hex chars is 51 characters.
43
+ */
44
+ export declare function createPromptCacheKey(scopeId: string, namespace: string): string;
45
+ /** Resolve one cache policy from the actual provider route and model family. */
46
+ export declare function resolvePromptCachePolicy(input: ResolvePromptCachePolicyInput): PromptCachePolicy;
47
+ /** Deduplicate semantic boundaries while preserving their left-to-right order. */
48
+ export declare function uniquePromptCacheBreakpointIndexes(indexes: readonly (number | undefined)[]): number[];
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Provider-neutral prompt-cache planning.
3
+ *
4
+ * This module owns the semantic cache policy (session affinity and reusable
5
+ * prefix boundaries). Provider clients only translate the plan to their wire
6
+ * format: OpenAI `prompt_cache_*` fields or Anthropic `cache_control` blocks.
7
+ */
8
+ import { createHash } from "node:crypto";
9
+ const OPENAI_EXPLICIT_BREAKPOINTS = ["system", "stable-history", "rolling-history"];
10
+ const ANTHROPIC_BREAKPOINTS = ["system", "tools", "stable-history", "rolling-history"];
11
+ const OPENROUTER_ANTHROPIC_BREAKPOINTS = ["system", "stable-history", "rolling-history"];
12
+ function normalizedModel(model) {
13
+ return model.replace(/^~/, "");
14
+ }
15
+ function isAnthropicModel(model) {
16
+ return /^anthropic\/claude-/i.test(normalizedModel(model));
17
+ }
18
+ function isOpenAIModel(model) {
19
+ const normalized = normalizedModel(model);
20
+ return /^openai\//i.test(normalized) || /^(?:gpt-|o\d)/i.test(normalized);
21
+ }
22
+ /** GPT-5.6 and later 5.x releases support explicit prompt-cache breakpoints. */
23
+ function supportsOpenAIExplicitCaching(model) {
24
+ const normalized = normalizedModel(model).replace(/^openai\//i, "");
25
+ const match = /^gpt-5\.(\d+)(?:[-.]|$)/i.exec(normalized);
26
+ return match !== null && Number(match[1]) >= 6;
27
+ }
28
+ /**
29
+ * Produce a privacy-preserving stable affinity key without leaking a raw
30
+ * session id to the provider. The prefix plus 48 hex chars is 51 characters.
31
+ */
32
+ export function createPromptCacheKey(scopeId, namespace) {
33
+ const digest = createHash("sha256")
34
+ .update("codeshell-prompt-cache-v1\0")
35
+ .update(namespace)
36
+ .update("\0")
37
+ .update(scopeId)
38
+ .digest("hex")
39
+ .slice(0, 48);
40
+ return `cs:${digest}`;
41
+ }
42
+ /** Resolve one cache policy from the actual provider route and model family. */
43
+ export function resolvePromptCachePolicy(input) {
44
+ const kind = (input.providerKind ?? input.provider).toLowerCase();
45
+ const model = normalizedModel(input.model);
46
+ const key = input.request?.scopeId
47
+ ? createPromptCacheKey(input.request.scopeId, `${kind}:${model}`)
48
+ : undefined;
49
+ if (input.provider === "anthropic" || kind === "anthropic") {
50
+ return {
51
+ strategy: "anthropic-explicit",
52
+ layoutVersion: "system-tools-stable-rolling-v2",
53
+ breakpoints: ANTHROPIC_BREAKPOINTS,
54
+ };
55
+ }
56
+ if (kind === "openrouter" && isAnthropicModel(model)) {
57
+ return {
58
+ strategy: "anthropic-explicit",
59
+ // OpenRouter/Anthropic includes tools in the system-prefix cache entry,
60
+ // so a separate tool marker is unnecessary and preserves one slot.
61
+ layoutVersion: "system-stable-rolling-v2",
62
+ breakpoints: OPENROUTER_ANTHROPIC_BREAKPOINTS,
63
+ };
64
+ }
65
+ const openAIRoute = kind === "openai" || (kind === "openrouter" && isOpenAIModel(model));
66
+ if (openAIRoute && supportsOpenAIExplicitCaching(model) && input.explicitDisabled !== true) {
67
+ return {
68
+ strategy: "openai-explicit",
69
+ layoutVersion: "system-stable-rolling-v1",
70
+ breakpoints: OPENAI_EXPLICIT_BREAKPOINTS,
71
+ ...(key ? { cacheKey: key } : {}),
72
+ promptCacheOptions: { mode: "explicit", ttl: "30m" },
73
+ };
74
+ }
75
+ if (openAIRoute) {
76
+ return {
77
+ strategy: "openai-implicit",
78
+ layoutVersion: "implicit-affinity-v1",
79
+ breakpoints: [],
80
+ ...(key ? { cacheKey: key } : {}),
81
+ };
82
+ }
83
+ return {
84
+ strategy: "provider-managed",
85
+ layoutVersion: "append-only-v1",
86
+ breakpoints: [],
87
+ };
88
+ }
89
+ /** Deduplicate semantic boundaries while preserving their left-to-right order. */
90
+ export function uniquePromptCacheBreakpointIndexes(indexes) {
91
+ const seen = new Set();
92
+ const result = [];
93
+ for (const index of indexes) {
94
+ if (index === undefined || index < 0 || seen.has(index))
95
+ continue;
96
+ seen.add(index);
97
+ result.push(index);
98
+ }
99
+ return result;
100
+ }
@@ -16,6 +16,7 @@ export declare class AnthropicClient extends LLMClientBase {
16
16
  */
17
17
  private _capability;
18
18
  private get capability();
19
+ private promptCachePolicy;
19
20
  getPromptCacheConfigIdentity(): Readonly<Record<string, unknown>>;
20
21
  /**
21
22
  * Translate the resolved ReasoningSetting into Anthropic's `thinking` field,
@@ -44,7 +45,9 @@ export declare class AnthropicClient extends LLMClientBase {
44
45
  private nonStreamMessage;
45
46
  private streamMessage;
46
47
  private processResponse;
48
+ private buildSystem;
47
49
  private buildMessages;
50
+ private markAnthropicCacheBreakpoint;
48
51
  private convertTools;
49
52
  private handleApiError;
50
53
  }