@salesforce/sfdx-agent-sdk 0.80.0 → 0.82.0

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/CHANGELOG.md CHANGED
@@ -3,6 +3,19 @@
3
3
  All notable changes to `@salesforce/sfdx-agent-sdk` are documented in this file.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [0.82.0] - 2026-09-15
7
+
8
+ _No changes — released alongside dependent packages._
9
+
10
+ ## [0.81.0] - 2026-09-15
11
+
12
+ ### Features
13
+ - **agent-sdk,harness-claude**: enable auto-compaction by default @W-24153849@ ([#813](https://github.com/forcedotcom/agentic-dx/pull/813))
14
+
15
+ ### Chores
16
+ - **deps-dev**: bump eslint from 10.9.1 to 10.10.0 in the eslint group across 1 directory ([#815](https://github.com/forcedotcom/agentic-dx/pull/815))
17
+ - **deps-dev**: bump the dev-dependencies group with 2 updates ([#814](https://github.com/forcedotcom/agentic-dx/pull/814))
18
+
6
19
  ## [0.80.0] - 2026-09-14
7
20
 
8
21
  ### Fixes
package/README.md CHANGED
@@ -154,7 +154,7 @@ keeps unparameterized call sites working.
154
154
  | `getChatSessionIds` | `() => string[]` | List active session IDs. |
155
155
  | `destroyChatSession` | `(sessionId: string) => Promise<void>` | Destroy a session and its history. |
156
156
  | `cloneChatSession` | `(sourceSessionId: string) => Promise<ChatSession>` | Clone a session with its message history. |
157
- | `compactChatSession` | `(sessionId: string) => Promise<ChatSession>` | Compact a session into a summarized new session. |
157
+ | `compactChatSession` | `(sessionId: string) => Promise<ChatSession>` | Compact a session into a summarized **new** session (`session-destroyed` / `session-created` fire; the old `ChatSession` object is detached). Contrast with automatic auto-compaction (see "Auto-Compaction" below), which rotates the **existing** `ChatSession` object's id in place with no lifecycle events. |
158
158
  | `destroy` | `() => Promise<void>` | Destroy the agent and all its sessions. |
159
159
  | `onTelemetry` | `(callback: TelemetryEventCallback) => Unsubscribe` | Subscribe to telemetry scoped to this agent (and its sessions). |
160
160
  | `onLog` | `(callback: (record: LogRecord) => void) => Unsubscribe` | Subscribe to logs scoped to this agent (and its sessions). |
@@ -165,7 +165,7 @@ A single conversation thread.
165
165
 
166
166
  | Method | Signature | Description |
167
167
  | ------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
168
- | `getId` | `() => string` | Session/thread identifier. |
168
+ | `getId` | `() => string` | Session/thread identifier. **Not immutable across the session's lifetime:** a successful automatic auto-compaction (see "Auto-Compaction" below) rotates this value in place — same `ChatSession` object, new id. Don't cache the return value as a stable key; call `getId()` again after any `chat()` if you persist it externally. |
169
169
  | `chat` | `(message: string, options?: ChatOptions) => Promise<ChatStreamResult>` | Send a message and stream the response. The returned `eventStream` is the single iterator for the entire chat turn. |
170
170
  | `submitToolResult` | `(toolResult: ToolResultInfo) => Promise<void>` | Return a consumer-executed tool result. Control message on the existing turn — post-resume events flow on the same stream. |
171
171
  | `approveToolCall` | `(toolCallId: string, options?: { remember?: boolean }) => Promise<void>` | Approve a pending tool call. `{ remember: true }` ("Allow always") appends an `allow` rule to `AgentConfig.toolPolicies` and persists it before settling. Control message on the existing turn. |
@@ -271,19 +271,20 @@ telemetry for a service of your own.
271
271
 
272
272
  #### `AgentConfig`
273
273
 
274
- | Field | Type | Description |
275
- | ---------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
276
- | `orgAlias?` | `string` | Salesforce org alias or username. Falls back to project/default org. |
277
- | `modelId?` | `ModelName \| Model` | LLM model selector. Pass a `ModelName` enum value for an in-tree model (e.g. `'sfdc_ai__DefaultGPT5'`), or a pre-built `Model` instance to opt into a Bedrock-Anthropic Claude variant the SDK has not yet released — see `createClaudeModel(gatewayId, overrides)` exported from this package. Legacy pinned `llmgateway__*` ids still resolve via a back-compat alias map. |
278
- | `name?` | `string` | Human-readable agent name. |
279
- | `description?` | `string` | Agent purpose description. |
280
- | `instructions?` | `string` | System instructions for the agent. |
281
- | `tools?` | `ToolDefinition[]` | Consumer-executed tool schemas. |
282
- | `mcpServers?` | `MCPConfiguration` | MCP server connections. |
283
- | `skills?` | `string[]` | Each entry is either an individual skill folder (containing `SKILL.md`) or a parent folder containing skill subfolders. Relative and absolute paths supported; forms can be mixed in the same array. |
284
- | `rules?` | `string[]` | Each entry is either an individual `.md` rule file or a directory of `.md` rule files (scanned one level deep, alphabetical, non-`.md` skipped). Bodies are composed verbatim into the agent's effective system prompt; YAML frontmatter is optional and stripped if present. Matches Claude Code's `.claude/rules/*.md` convention. |
285
- | `toolPolicies?` | `ToolPolicyRule[]` | Ordered per-tool approval rules resolved by `resolveToolApprovalPolicy` (cross-tier deny-wins / within-tier last-wins). Author directly or via `definePolicy(...)`. See "Tool Approval Policy" below. |
286
- | `defaultToolDecision?` | `Decision` | Fallback decision when no rule matches. Defaults to `'allow'` (no policy ⇒ no gating). Set to `'require-approval'` for a fail-closed posture (recommended for catalogs with un-annotated MCP servers). |
274
+ | Field | Type | Description |
275
+ | -------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
276
+ | `orgAlias?` | `string` | Salesforce org alias or username. Falls back to project/default org. |
277
+ | `modelId?` | `ModelName \| Model` | LLM model selector. Pass a `ModelName` enum value for an in-tree model (e.g. `'sfdc_ai__DefaultGPT5'`), or a pre-built `Model` instance to opt into a Bedrock-Anthropic Claude variant the SDK has not yet released — see `createClaudeModel(gatewayId, overrides)` exported from this package. Legacy pinned `llmgateway__*` ids still resolve via a back-compat alias map. |
278
+ | `name?` | `string` | Human-readable agent name. |
279
+ | `description?` | `string` | Agent purpose description. |
280
+ | `instructions?` | `string` | System instructions for the agent. |
281
+ | `tools?` | `ToolDefinition[]` | Consumer-executed tool schemas. |
282
+ | `mcpServers?` | `MCPConfiguration` | MCP server connections. |
283
+ | `skills?` | `string[]` | Each entry is either an individual skill folder (containing `SKILL.md`) or a parent folder containing skill subfolders. Relative and absolute paths supported; forms can be mixed in the same array. |
284
+ | `rules?` | `string[]` | Each entry is either an individual `.md` rule file or a directory of `.md` rule files (scanned one level deep, alphabetical, non-`.md` skipped). Bodies are composed verbatim into the agent's effective system prompt; YAML frontmatter is optional and stripped if present. Matches Claude Code's `.claude/rules/*.md` convention. |
285
+ | `toolPolicies?` | `ToolPolicyRule[]` | Ordered per-tool approval rules resolved by `resolveToolApprovalPolicy` (cross-tier deny-wins / within-tier last-wins). Author directly or via `definePolicy(...)`. See "Tool Approval Policy" below. |
286
+ | `defaultToolDecision?` | `Decision` | Fallback decision when no rule matches. Defaults to `'allow'` (no policy ⇒ no gating). Set to `'require-approval'` for a fail-closed posture (recommended for catalogs with un-annotated MCP servers). |
287
+ | `autoCompactionThreshold?` | `number` | Occupancy ratio (0-1) that triggers a thread compaction before the next turn's model call. Defaults to `DEFAULT_AUTO_COMPACTION_THRESHOLD` (0.9). Auto-compaction is always active — there is no field to disable it. See "Auto-Compaction" below. |
287
288
 
288
289
  #### `AgentStateUpdate` and `ConsumerMetadataMutation`
289
290
 
@@ -387,6 +388,34 @@ invariant alone. The exact resolved message reaches both the model and the singl
387
388
  It is never written to telemetry/log fields and never creates a remembered deny rule; untyped JavaScript that also
388
389
  supplies `remember: true` still follows those safety rules.
389
390
 
391
+ #### Auto-Compaction
392
+
393
+ Every agent auto-compacts its threads once context usage crosses `AgentConfig.autoCompactionThreshold` (default 0.9),
394
+ checked once per turn before the model call. **Auto-compaction is always active — there is no field to disable it.**
395
+ `resolveAutoCompactionThreshold` is the pure resolver harness authors call to apply the default; it's exported for
396
+ consumers who want to inspect the effective threshold for an agent.
397
+
398
+ ```typescript
399
+ import { resolveAutoCompactionThreshold, DEFAULT_AUTO_COMPACTION_THRESHOLD } from '@salesforce/sfdx-agent-sdk';
400
+
401
+ resolveAutoCompactionThreshold(config.autoCompactionThreshold);
402
+ // → number
403
+ // defaults to DEFAULT_AUTO_COMPACTION_THRESHOLD (0.9) when omitted
404
+ ```
405
+
406
+ Raise or lower `autoCompactionThreshold` (0–1) to change how full the context window must be before a compaction is
407
+ triggered. On a harness whose runtime already runs its own native auto-compaction mechanism
408
+ (`AgentHarness.hasNativeAutoCompaction === true`), the SDK's own turn-boundary trigger does not run for that harness's
409
+ sessions — the threshold is forwarded to the harness's native mechanism instead, so double-compaction never happens.
410
+
411
+ **This is a different lifecycle contract than `Agent.compactChatSession()`.** A successful automatic compaction rotates
412
+ the same `ChatSession` object's id **in place** — `session.getId()` returns a new value after the triggering `chat()`
413
+ call, with no `session-destroyed` / `session-created` telemetry. The manual `compactChatSession()` verb instead detaches
414
+ the old session and hands back a brand-new `ChatSession`, firing those lifecycle events. If you key persistence, UI
415
+ state, or logs off `getId()`, re-read it after every `chat()` call rather than caching it — and don't call
416
+ `compactChatSession()` on a thread that already has automatic auto-compaction enabled unless you intend two independent
417
+ compaction paths to potentially run back-to-back.
418
+
390
419
  #### `MCPConfiguration`
391
420
 
392
421
  ```typescript
package/dist/agent.d.ts CHANGED
@@ -383,7 +383,21 @@ export declare class DefaultAgent implements Agent {
383
383
  * the concrete `DefaultAgent` type rather than `Agent`.
384
384
  */
385
385
  restoreSessions(threadIds: string[]): void;
386
+ /** Registers a thread's telemetry-router slice and records its unregister callback. */
387
+ private registerSlice;
388
+ /** Runs and clears a thread's telemetry-router slice unregister callback, if any. */
389
+ private unregisterSlice;
386
390
  private attachSession;
387
391
  private detachSession;
392
+ /**
393
+ * Re-keys the `sessions` map and the telemetry-router session slice after an in-place
394
+ * auto-compaction rotation (see `DefaultChatSession.maybeAutoCompact`). The session object
395
+ * itself is unchanged — only its backing thread id — so this does NOT emit
396
+ * `session-destroyed` / `session-created`; from the consumer's perspective the same
397
+ * `ChatSession` they already hold just now reports a different `getId()`. Without this,
398
+ * `getChatSession(newThreadId)` would miss and `destroyChatSession(oldThreadId)` would try
399
+ * to destroy a thread the harness already destroyed as part of compaction.
400
+ */
401
+ private handleAutoCompactionRotation;
388
402
  private assertNotDisposed;
389
403
  }
package/dist/agent.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * See LICENSE.txt for license terms.
4
4
  */
5
5
  import { LogBus, RealClock, UUIDGenerator, } from '@salesforce/agentic-common';
6
- import { toHarnessConfig, } from './harness/harness-config.js';
6
+ import { resolveAutoCompactionThreshold, toHarnessConfig, } from './harness/harness-config.js';
7
7
  import { normalizeMcpAuthProviders } from './mcp-auth.js';
8
8
  import { DefaultChatSession } from './chat-session.js';
9
9
  import { AgentSDKError, AgentSDKErrorType } from './errors.js';
@@ -457,8 +457,22 @@ export class DefaultAgent {
457
457
  }
458
458
  }
459
459
  }
460
- attachSession(threadId) {
460
+ /** Registers a thread's telemetry-router slice and records its unregister callback. */
461
+ registerSlice(threadId) {
461
462
  const slice = this.router.registerSession(threadId);
463
+ this.sessionSliceUnregisters.set(threadId, () => this.router.unregisterSession(threadId));
464
+ return slice;
465
+ }
466
+ /** Runs and clears a thread's telemetry-router slice unregister callback, if any. */
467
+ unregisterSlice(threadId) {
468
+ const unregister = this.sessionSliceUnregisters.get(threadId);
469
+ if (unregister) {
470
+ unregister();
471
+ this.sessionSliceUnregisters.delete(threadId);
472
+ }
473
+ }
474
+ attachSession(threadId) {
475
+ const slice = this.registerSlice(threadId);
462
476
  // Live getter — read at call time so getContextUsage() reflects the
463
477
  // model bound to the agent right now, not the model that was bound
464
478
  // when this session was created. updateAgentConfig() can swap the
@@ -476,12 +490,20 @@ export class DefaultAgent {
476
490
  const persistRememberedRule = async (rule) => {
477
491
  await this.updateAgentConfig({ toolPolicies: [...(this.config.toolPolicies ?? []), rule] });
478
492
  };
493
+ // Live getter, same pattern as `getContextWindow` — reads `this.config.autoCompactionThreshold`
494
+ // at call time so an `updateAgentConfig()` change takes effect on the session's next turn.
495
+ const getAutoCompactionThreshold = () => resolveAutoCompactionThreshold(this.config.autoCompactionThreshold);
479
496
  const session = new DefaultChatSession(this.harness, this.agentId, threadId, slice, {
480
497
  telemetry: this.telemetryBus,
481
498
  log: this.logBus,
482
- }, getContextWindow, { clock: this.clock, idGenerator: this.idGenerator, persistRememberedRule });
499
+ }, getContextWindow, {
500
+ clock: this.clock,
501
+ idGenerator: this.idGenerator,
502
+ persistRememberedRule,
503
+ getAutoCompactionThreshold,
504
+ onThreadIdRotated: (oldThreadId, newThreadId) => this.handleAutoCompactionRotation(oldThreadId, newThreadId),
505
+ });
483
506
  this.sessions.set(threadId, session);
484
- this.sessionSliceUnregisters.set(threadId, () => this.router.unregisterSession(threadId));
485
507
  const sessionCreatedAt = this.clock.now();
486
508
  // Tier-1 pair: share one tick across the telemetry event and its colocated log sibling.
487
509
  this.telemetryBus.emitTelemetry({ type: 'session-created', agentId: this.agentId, threadId }, sessionCreatedAt);
@@ -503,13 +525,28 @@ export class DefaultAgent {
503
525
  else {
504
526
  session.releaseWithoutEvent();
505
527
  }
506
- const unregister = this.sessionSliceUnregisters.get(threadId);
507
- if (unregister) {
508
- unregister();
509
- this.sessionSliceUnregisters.delete(threadId);
510
- }
528
+ this.unregisterSlice(threadId);
511
529
  this.sessions.delete(threadId);
512
530
  }
531
+ /**
532
+ * Re-keys the `sessions` map and the telemetry-router session slice after an in-place
533
+ * auto-compaction rotation (see `DefaultChatSession.maybeAutoCompact`). The session object
534
+ * itself is unchanged — only its backing thread id — so this does NOT emit
535
+ * `session-destroyed` / `session-created`; from the consumer's perspective the same
536
+ * `ChatSession` they already hold just now reports a different `getId()`. Without this,
537
+ * `getChatSession(newThreadId)` would miss and `destroyChatSession(oldThreadId)` would try
538
+ * to destroy a thread the harness already destroyed as part of compaction.
539
+ */
540
+ handleAutoCompactionRotation(oldThreadId, newThreadId) {
541
+ const session = this.sessions.get(oldThreadId);
542
+ if (!session)
543
+ return;
544
+ this.sessions.delete(oldThreadId);
545
+ this.sessions.set(newThreadId, session);
546
+ this.unregisterSlice(oldThreadId);
547
+ const newSlice = this.registerSlice(newThreadId);
548
+ session.rebindInboundSlice(newSlice);
549
+ }
513
550
  assertNotDisposed() {
514
551
  if (this.disposed) {
515
552
  throw new AgentSDKError('Agent has been disposed.', AgentSDKErrorType.DISPOSED);
@@ -1,6 +1,6 @@
1
1
  import { type Clock, LogBus, type LogRecord, type UniqueIDGenerator, type Unsubscribe } from '@salesforce/agentic-common';
2
2
  import type { AgentHarness } from './harness/agent-harness.js';
3
- import type { StreamOptions } from './harness/harness-config.js';
3
+ import { type StreamOptions } from './harness/harness-config.js';
4
4
  import type { TelemetrySlice } from './internal/telemetry-router.js';
5
5
  import type { ChatEvent, ChatStreamResult } from './types/events.js';
6
6
  import type { Message, MessagePart } from './types/messages.js';
@@ -21,6 +21,16 @@ export type ChatOptions = StreamOptions;
21
21
  * `toolPolicies`-only partial so no connectivity re-resolution is triggered.
22
22
  */
23
23
  export type RememberedRulePersister = (rule: ToolPolicyRule) => Promise<void>;
24
+ /**
25
+ * Notifies the owning `Agent` that a session's backing thread was rotated by an
26
+ * in-place auto-compaction (the session kept the same object identity, but
27
+ * {@link ChatSession.getId} now returns a new value). Supplied by `DefaultAgent`
28
+ * so it can re-key its `sessions` map and the telemetry-router session slice from
29
+ * the old id to the new one — otherwise `Agent.getChatSession` /
30
+ * `destroyChatSession` would keep looking up a thread the harness already
31
+ * destroyed. Omitted in unit tests that don't need this bookkeeping.
32
+ */
33
+ export type ThreadIdRotatedNotifier = (oldThreadId: string, newThreadId: string) => void;
24
34
  /**
25
35
  * Optional injected dependencies for a {@link DefaultChatSession}. Bundled into
26
36
  * one trailing bag (rather than a growing tail of positional params) so a new
@@ -41,6 +51,16 @@ export type ChatSessionDeps = {
41
51
  * then behaves as a one-shot with no write. See {@link RememberedRulePersister}.
42
52
  */
43
53
  persistRememberedRule?: RememberedRulePersister;
54
+ /**
55
+ * Live getter for the agent's resolved auto-compaction threshold. Called at the
56
+ * start of every {@link ChatSession.chat} so a live `Agent.updateAgentConfig()` change
57
+ * to `AgentConfig.autoCompactionThreshold` takes effect on the next turn — same pattern
58
+ * as `getContextWindow`. Defaults to `DEFAULT_AUTO_COMPACTION_THRESHOLD` (0.9) when
59
+ * omitted.
60
+ */
61
+ getAutoCompactionThreshold?: () => number;
62
+ /** See {@link ThreadIdRotatedNotifier}. */
63
+ onThreadIdRotated?: ThreadIdRotatedNotifier;
44
64
  };
45
65
  /**
46
66
  * Parent bus pair used to wire upward forwarding at construction time.
@@ -321,7 +341,14 @@ export interface ChatSession {
321
341
  export declare class DefaultChatSession implements ChatSession {
322
342
  private readonly harness;
323
343
  private readonly agentId;
324
- private readonly threadId;
344
+ /**
345
+ * Mutable (not `readonly`): auto-compaction rotates a session's backing thread in
346
+ * place — see {@link maybeAutoCompact} — so the session's public id
347
+ * ({@link getId}) can change over its lifetime while the object identity stays
348
+ * the same. {@link onThreadIdRotated} notifies `DefaultAgent` so its `sessions`
349
+ * map and telemetry-router slice stay keyed correctly.
350
+ */
351
+ private threadId;
325
352
  private readonly chatEventBus;
326
353
  private readonly telemetryBus;
327
354
  private readonly logBus;
@@ -390,6 +417,18 @@ export declare class DefaultChatSession implements ChatSession {
390
417
  */
391
418
  private latestTurnUsage;
392
419
  private latestStepCount;
420
+ /**
421
+ * Whether {@link latestUsage} reflects the last COMPLETED normal turn (`true`) or is stale —
422
+ * unset (fresh session), or left over from an errored/abandoned turn (`false`). Read by
423
+ * {@link currentOccupancy} to decide between the two occupancy signals: a trustworthy live
424
+ * reading is preferred; otherwise occupancy falls back to a transcript-size estimate. Set
425
+ * `true` in {@link wrapEventStream}'s natural-completion branch, `false` in its error branch
426
+ * and by {@link clearHistory}.
427
+ */
428
+ private lastTurnUsageTrustworthy;
429
+ /** Live getter for the agent's resolved auto-compaction threshold. See {@link ChatSessionDeps.getAutoCompactionThreshold}. */
430
+ private readonly getAutoCompactionThreshold;
431
+ private readonly onThreadIdRotated;
393
432
  private disposed;
394
433
  /**
395
434
  * True while a turn started by {@link chat} is in flight — from the `chat()`
@@ -443,6 +482,9 @@ export declare class DefaultChatSession implements ChatSession {
443
482
  * - MUST return an object containing the original `textStream` and the wrapped `eventStream`.
444
483
  * - MUST notify listeners with `ErrorEvent` + `FinishEvent` and re-throw if the harness throws
445
484
  * before returning a stream result.
485
+ * - MUST check the auto-compaction policy before the harness call and, if the current
486
+ * occupancy is at/above its threshold, compact the thread first (see
487
+ * {@link maybeAutoCompact}) so the turn proceeds against the (now smaller) thread.
446
488
  */
447
489
  chat(message: string | MessagePart[], options?: ChatOptions): Promise<ChatStreamResult>;
448
490
  /**
@@ -492,7 +534,10 @@ export declare class DefaultChatSession implements ChatSession {
492
534
  * `chat-stream-started` is emitted by the entry-point method (chat / submitToolResult /
493
535
  * approveToolCall / declineToolCall) before the harness call so that pre-stream rejections
494
536
  * still produce a started+error pair. `startedAt` is captured there and threaded down so
495
- * `durationMs` measures real elapsed time on both terminal events.
537
+ * `durationMs` measures real elapsed time on both terminal events. `threadId` is likewise
538
+ * captured before the harness call (pre-auto-compaction-rotation) and threaded down so the
539
+ * `chat-stream-started`/`chat-stream-completed`/`chat-stream-error` triple for one turn stays
540
+ * paired on the same threadId even when `maybeAutoCompact()` rotates `this.threadId` mid-turn.
496
541
  */
497
542
  private wrapEventStream;
498
543
  /**
@@ -547,6 +592,8 @@ export declare class DefaultChatSession implements ChatSession {
547
592
  * absent) until the next turn produces one.
548
593
  */
549
594
  clearHistory(): Promise<void>;
595
+ /** Drops all retained per-turn usage state — shared by {@link clearHistory} and a successful {@link maybeAutoCompact} rotation, both of which retire the thread the retained state described. */
596
+ private resetUsageState;
550
597
  /**
551
598
  * @requirements
552
599
  * - MUST always return a populated `ContextUsage`. Pre-first-turn and post-`clearHistory()`,
@@ -596,6 +643,77 @@ export declare class DefaultChatSession implements ChatSession {
596
643
  * read from the same `latestUsage` snapshot `wrapEventStream` captures.
597
644
  */
598
645
  private contextUsageLogFields;
646
+ /**
647
+ * Pre-turn occupancy hook, called from {@link chat} before the harness call. Auto-compaction
648
+ * is always active — there is no "disabled" state to check. This method short-circuits
649
+ * immediately when `this.harness.hasNativeAutoCompaction` is `true`: a harness that runs its
650
+ * own native auto-compaction mechanism must not ALSO have the SDK's turn-boundary trigger run
651
+ * against the same conversation — that would double-compact via two uncoordinated mechanisms.
652
+ * A harness making that claim is expected to honor `AgentConfig.autoCompactionThreshold`
653
+ * itself, by forwarding it to its native mechanism; the SDK never verifies this.
654
+ *
655
+ * Otherwise, when {@link currentOccupancy} is at/above the resolved
656
+ * {@link ChatSessionDeps.getAutoCompactionThreshold} threshold, compacts the thread in place —
657
+ * the session keeps its object identity but {@link getId} returns a new value afterward, and
658
+ * {@link onThreadIdRotated} notifies `DefaultAgent` to re-key its bookkeeping. Both outcomes
659
+ * (`'compacted'` and `'failed'`) emit a structured `logBus` record tagged
660
+ * `event_type: 'auto-compaction'` — this log has no colocated telemetry sibling, unlike the
661
+ * Tier-1 sites documented in `ARCHITECTURE.md`, so a subscriber must read it off `logBus`
662
+ * directly.
663
+ *
664
+ * A `COMPACTION_FAILED` error is swallowed: the session is left intact (still pointed at
665
+ * the pre-compaction thread, per the harness contract's "leave it intact on failure"
666
+ * clause) and the turn proceeds normally against it rather than failing the whole `chat()`
667
+ * call over a best-effort maintenance operation. Any other error from {@link currentOccupancy}
668
+ * (e.g. a transient `getMessages()` failure while estimating transcript occupancy) is
669
+ * likewise swallowed (after a `logBus.warn`) — it is a best-effort heuristic read, not the
670
+ * actual harness operation, and a hiccup there must not fail the user's turn. Any other error
671
+ * from `harness.compactThread` propagates to `chat()`'s existing pre-stream-failure handling.
672
+ *
673
+ * `abortSignal` is `chat()`'s composed signal — `cancelTurn()`'s own per-turn controller
674
+ * folded together with any caller-supplied `options.abortSignal` — checked before starting
675
+ * the occupancy read and again before calling `compactThread`, so an abort from EITHER source
676
+ * that lands while this is running skips (rather than starts) compaction. This narrows, but
677
+ * does not close, the window an abort can block on: `AgentHarness.compactThread` takes no
678
+ * `AbortSignal`, so a `compactThread` call already in flight when the signal aborts cannot be
679
+ * interrupted — the caller blocks for the remaining compaction latency, and if
680
+ * `harness.stream()` subsequently runs against an already-aborted signal, the turn can end via
681
+ * `notifyPreStreamError` with `finishReason: 'error'` rather than `'cancelled'`. See
682
+ * `cancelTurn()`'s Critical Invariant below for the full contract and why this residual gap is
683
+ * accepted for now.
684
+ */
685
+ private maybeAutoCompact;
686
+ /**
687
+ * The occupancy ratio (0-1) `maybeAutoCompact` checks against the configured threshold,
688
+ * from whichever of two signals is available, in priority order:
689
+ *
690
+ * 1. **Live usage** — when {@link lastTurnUsageTrustworthy}, the same `effectiveInputTokens()
691
+ * / contextWindow` computation {@link getContextUsage} exposes as `usedFraction`.
692
+ * 2. **Transcript-size estimate** — otherwise (cold session, or the prior turn ended
693
+ * abnormally), a rough estimate from the persisted transcript's character count via
694
+ * {@link estimateTranscriptOccupancy}.
695
+ *
696
+ * Returns `undefined` when neither signal is available (a brand-new session with no
697
+ * completed turn and no persisted messages), so `maybeAutoCompact` never compacts a
698
+ * session with nothing to compact.
699
+ */
700
+ private currentOccupancy;
701
+ /**
702
+ * Estimates occupancy from the thread's persisted transcript size (chars ÷
703
+ * {@link CHARS_PER_TOKEN} as a token-count proxy) when no trustworthy live usage reading is
704
+ * available. Returns `undefined` for an empty transcript so a brand-new thread never
705
+ * triggers compaction.
706
+ */
707
+ private estimateTranscriptOccupancy;
708
+ /**
709
+ * Re-points this session's inbound router-slice forwarding at a new slice, unsubscribing
710
+ * the old one first. Called by `DefaultAgent` after an auto-compaction rotation re-registers
711
+ * the telemetry-router slice under the new thread id, so any future thread-scoped
712
+ * harness-emitted event routes to this session under its new id rather than falling through
713
+ * to "unrouted". Not part of the public {@link ChatSession} interface — `DefaultAgent` holds
714
+ * the concrete `DefaultChatSession` type, same as {@link releaseWithoutEvent}.
715
+ */
716
+ rebindInboundSlice(slice: TelemetrySlice): void;
599
717
  /**
600
718
  * @requirements
601
719
  * - IF `message` is a `string`, it MUST be formatted into a standard `Message` object array containing exactly one message.
@@ -699,7 +817,10 @@ export declare class DefaultChatSession implements ChatSession {
699
817
  * not throw.
700
818
  *
701
819
  * `startedAt` is the timestamp captured by {@link emitChatStreamStarted} so `durationMs`
702
- * measures real elapsed time even for pre-stream rejections.
820
+ * measures real elapsed time even for pre-stream rejections. `threadId` is the same
821
+ * pre-auto-compaction-rotation id captured alongside `startedAt`, so this event stays paired
822
+ * with its `chat-stream-started` even when the rejection surfaces after a mid-turn rotation
823
+ * (e.g. `harness.stream()` itself fails on the post-rotation thread).
703
824
  */
704
825
  private notifyPreStreamError;
705
826
  /**
@@ -3,8 +3,10 @@
3
3
  * See LICENSE.txt for license terms.
4
4
  */
5
5
  import { backfillCreatedAt, EventBus, LogBus, RealClock, UUIDGenerator, } from '@salesforce/agentic-common';
6
+ import { resolveAutoCompactionThreshold } from './harness/harness-config.js';
6
7
  import { resolveToolDeclineModelMessage } from './harness/tool-decline.js';
7
8
  import { AgentSDKError, AgentSDKErrorType } from './errors.js';
9
+ import { CHARS_PER_TOKEN, estimateTranscriptChars } from './internal/transcript-occupancy.js';
8
10
  import { createTelemetryBus, } from './types/telemetry-events.js';
9
11
  /**
10
12
  * Composes the SDK-owned per-turn abort signal with the caller's (if any) into
@@ -16,6 +18,10 @@ import { createTelemetryBus, } from './types/telemetry-events.js';
16
18
  function composeAbortSignals(own, caller) {
17
19
  return caller === undefined ? own : AbortSignal.any([own, caller]);
18
20
  }
21
+ /** Clamps an occupancy ratio to `[0, 1]` — a denominator/numerator pair can fall outside that range (e.g. a `contextWindow` change mid-session, or an estimate overshoot). */
22
+ function clampFraction(numerator, denominator) {
23
+ return Math.min(1, Math.max(0, numerator / denominator));
24
+ }
19
25
  /**
20
26
  * Default implementation of {@link ChatSession} that delegates all operations
21
27
  * to an {@link AgentHarness}. The session holds its agent ID and thread ID
@@ -24,6 +30,13 @@ function composeAbortSignals(own, caller) {
24
30
  export class DefaultChatSession {
25
31
  harness;
26
32
  agentId;
33
+ /**
34
+ * Mutable (not `readonly`): auto-compaction rotates a session's backing thread in
35
+ * place — see {@link maybeAutoCompact} — so the session's public id
36
+ * ({@link getId}) can change over its lifetime while the object identity stays
37
+ * the same. {@link onThreadIdRotated} notifies `DefaultAgent` so its `sessions`
38
+ * map and telemetry-router slice stay keyed correctly.
39
+ */
27
40
  threadId;
28
41
  chatEventBus = new EventBus();
29
42
  // Constructed in the constructor body so they use the injected `clock` (tests inject a StubClock for
@@ -95,6 +108,18 @@ export class DefaultChatSession {
95
108
  */
96
109
  latestTurnUsage = undefined;
97
110
  latestStepCount = 0;
111
+ /**
112
+ * Whether {@link latestUsage} reflects the last COMPLETED normal turn (`true`) or is stale —
113
+ * unset (fresh session), or left over from an errored/abandoned turn (`false`). Read by
114
+ * {@link currentOccupancy} to decide between the two occupancy signals: a trustworthy live
115
+ * reading is preferred; otherwise occupancy falls back to a transcript-size estimate. Set
116
+ * `true` in {@link wrapEventStream}'s natural-completion branch, `false` in its error branch
117
+ * and by {@link clearHistory}.
118
+ */
119
+ lastTurnUsageTrustworthy = false;
120
+ /** Live getter for the agent's resolved auto-compaction threshold. See {@link ChatSessionDeps.getAutoCompactionThreshold}. */
121
+ getAutoCompactionThreshold;
122
+ onThreadIdRotated;
98
123
  disposed = false;
99
124
  /**
100
125
  * True while a turn started by {@link chat} is in flight — from the `chat()`
@@ -146,6 +171,9 @@ export class DefaultChatSession {
146
171
  this.clock = deps.clock ?? new RealClock();
147
172
  this.idGenerator = deps.idGenerator ?? new UUIDGenerator();
148
173
  this.persistRememberedRule = deps.persistRememberedRule;
174
+ this.getAutoCompactionThreshold =
175
+ deps.getAutoCompactionThreshold ?? (() => resolveAutoCompactionThreshold(undefined));
176
+ this.onThreadIdRotated = deps.onThreadIdRotated;
149
177
  this.telemetryBus = createTelemetryBus(this.clock);
150
178
  this.logBus = new LogBus(this.clock);
151
179
  this.inboundUnsubs = [inbound.telemetry.forwardTo(this.telemetryBus), inbound.log.forwardTo(this.logBus)];
@@ -163,6 +191,9 @@ export class DefaultChatSession {
163
191
  * - MUST return an object containing the original `textStream` and the wrapped `eventStream`.
164
192
  * - MUST notify listeners with `ErrorEvent` + `FinishEvent` and re-throw if the harness throws
165
193
  * before returning a stream result.
194
+ * - MUST check the auto-compaction policy before the harness call and, if the current
195
+ * occupancy is at/above its threshold, compact the thread first (see
196
+ * {@link maybeAutoCompact}) so the turn proceeds against the (now smaller) thread.
166
197
  */
167
198
  async chat(message, options) {
168
199
  this.assertNotDisposed();
@@ -181,7 +212,17 @@ export class DefaultChatSession {
181
212
  this.turnAbortController = turnAbort;
182
213
  const abortSignal = composeAbortSignals(turnAbort.signal, options?.abortSignal);
183
214
  const startedAt = this.emitChatStreamStarted('chat');
184
- const streamPromise = this.harness.stream(this.agentId, this.threadId, message, { ...options, abortSignal });
215
+ // Captured before `maybeAutoCompact()` runs so the whole `chat-stream-started`
216
+ // / `chat-stream-completed` / `chat-stream-error` triple for THIS turn carries
217
+ // the same threadId, even though auto-compaction may rotate `this.threadId` in
218
+ // place mid-turn (the telemetry pairing contract keys on the pre-rotation id —
219
+ // the thread the turn was issued against).
220
+ const turnThreadId = this.threadId;
221
+ // Chained (not `await`ed here) so the auto-compaction check runs to completion
222
+ // before `harness.stream()` is invoked — it may rotate `this.threadId` in place,
223
+ // and the stream call must see the post-rotation id — while `streamPromise` is
224
+ // still derived synchronously, before any `await` in this function body.
225
+ const streamPromise = this.maybeAutoCompact(abortSignal).then(() => this.harness.stream(this.agentId, this.threadId, message, { ...options, abortSignal }));
185
226
  // Derive the settle barrier synchronously from the stream promise — before
186
227
  // the `await` — so a `cancelTurn()` that races this `chat()` still awaits the
187
228
  // real teardown + persistence flush rather than an `undefined` (an immediate
@@ -193,14 +234,14 @@ export class DefaultChatSession {
193
234
  const result = await streamPromise;
194
235
  return {
195
236
  textStream: result.textStream,
196
- eventStream: this.wrapEventStream(result.eventStream, startedAt),
237
+ eventStream: this.wrapEventStream(result.eventStream, startedAt, turnThreadId),
197
238
  };
198
239
  }
199
240
  catch (err) {
200
241
  // Pre-stream failure ends the turn before a stream exists — clear the
201
242
  // flag here since `wrapEventStream` (which normally clears it) never runs.
202
243
  this.turnActive = false;
203
- this.notifyPreStreamError(err, startedAt);
244
+ this.notifyPreStreamError(err, startedAt, turnThreadId);
204
245
  throw err;
205
246
  }
206
247
  }
@@ -277,9 +318,12 @@ export class DefaultChatSession {
277
318
  * `chat-stream-started` is emitted by the entry-point method (chat / submitToolResult /
278
319
  * approveToolCall / declineToolCall) before the harness call so that pre-stream rejections
279
320
  * still produce a started+error pair. `startedAt` is captured there and threaded down so
280
- * `durationMs` measures real elapsed time on both terminal events.
321
+ * `durationMs` measures real elapsed time on both terminal events. `threadId` is likewise
322
+ * captured before the harness call (pre-auto-compaction-rotation) and threaded down so the
323
+ * `chat-stream-started`/`chat-stream-completed`/`chat-stream-error` triple for one turn stays
324
+ * paired on the same threadId even when `maybeAutoCompact()` rotates `this.threadId` mid-turn.
281
325
  */
282
- async *wrapEventStream(stream, startedAt) {
326
+ async *wrapEventStream(stream, startedAt, threadId) {
283
327
  let sawFinish = false;
284
328
  let lastError;
285
329
  let finishUsage;
@@ -371,11 +415,15 @@ export class DefaultChatSession {
371
415
  const finishedAt = this.clock.now();
372
416
  const durationMs = finishedAt.getTime() - startedAt.getTime();
373
417
  if (lastError !== undefined) {
418
+ // The retained `latestUsage` is stale for occupancy purposes — the errored/abandoned
419
+ // turn may have jumped occupancy without a completed step-finish reflecting it. The
420
+ // next `maybeAutoCompact()` falls back to the transcript-size estimate instead.
421
+ this.lastTurnUsageTrustworthy = false;
374
422
  // Tier-1 pair: share one tick across the telemetry event and its colocated log sibling.
375
423
  this.telemetryBus.emitTelemetry({
376
424
  type: 'chat-stream-error',
377
425
  agentId: this.agentId,
378
- threadId: this.threadId,
426
+ threadId,
379
427
  durationMs,
380
428
  error: lastError,
381
429
  }, finishedAt);
@@ -385,7 +433,7 @@ export class DefaultChatSession {
385
433
  context: {
386
434
  event_type: 'chat-stream-error',
387
435
  agentId: this.agentId,
388
- threadId: this.threadId,
436
+ threadId,
389
437
  durationMs,
390
438
  },
391
439
  error: lastError,
@@ -404,10 +452,13 @@ export class DefaultChatSession {
404
452
  // `getContextUsage()` (which spreads on read, carrying the mutation).
405
453
  this.latestTurnUsage = finishUsage === undefined ? undefined : { ...finishUsage };
406
454
  this.latestStepCount = stepCount;
455
+ // A completed turn's `latestUsage` is a trustworthy live occupancy reading —
456
+ // `maybeAutoCompact()` on the next `chat()` can use it directly.
457
+ this.lastTurnUsageTrustworthy = true;
407
458
  this.telemetryBus.emitTelemetry({
408
459
  type: 'chat-stream-completed',
409
460
  agentId: this.agentId,
410
- threadId: this.threadId,
461
+ threadId,
411
462
  durationMs,
412
463
  // Self-describing usage on telemetry (W-24125085, Phase 2): `usage`
413
464
  // stays as the legacy alias for the whole-turn aggregate, now also
@@ -451,7 +502,7 @@ export class DefaultChatSession {
451
502
  context: {
452
503
  event_type: 'chat-stream-completed',
453
504
  agentId: this.agentId,
454
- threadId: this.threadId,
505
+ threadId,
455
506
  durationMs,
456
507
  ...(finishUsage !== undefined ? { turnUsage: finishUsage } : {}),
457
508
  ...(stepCount > 0 ? { stepCount } : {}),
@@ -548,9 +599,14 @@ export class DefaultChatSession {
548
599
  async clearHistory() {
549
600
  this.assertNotDisposed();
550
601
  await this.harness.clearMessages(this.agentId, this.threadId);
602
+ this.resetUsageState();
603
+ }
604
+ /** Drops all retained per-turn usage state — shared by {@link clearHistory} and a successful {@link maybeAutoCompact} rotation, both of which retire the thread the retained state described. */
605
+ resetUsageState() {
551
606
  this.latestUsage = {};
552
607
  this.latestTurnUsage = undefined;
553
608
  this.latestStepCount = 0;
609
+ this.lastTurnUsageTrustworthy = false;
554
610
  }
555
611
  /**
556
612
  * @requirements
@@ -579,7 +635,7 @@ export class DefaultChatSession {
579
635
  this.assertNotDisposed();
580
636
  const contextWindow = this.getContextWindow();
581
637
  const contextTokens = this.effectiveInputTokens();
582
- const usedFraction = contextTokens === undefined ? undefined : Math.min(1, Math.max(0, contextTokens / contextWindow));
638
+ const usedFraction = contextTokens === undefined ? undefined : clampFraction(contextTokens, contextWindow);
583
639
  // Spread `latestUsage` so consumer mutation of the returned object cannot
584
640
  // leak back into the session's retained state on a later call. `usage` is
585
641
  // the legacy alias for `lastStepUsage` (Phase 2, W-24125085) — the SAME
@@ -642,9 +698,156 @@ export class DefaultChatSession {
642
698
  lastStepUsage: { ...this.latestUsage },
643
699
  contextTokens,
644
700
  contextWindow,
645
- usedFraction: Math.min(1, Math.max(0, contextTokens / contextWindow)),
701
+ usedFraction: clampFraction(contextTokens, contextWindow),
646
702
  };
647
703
  }
704
+ /**
705
+ * Pre-turn occupancy hook, called from {@link chat} before the harness call. Auto-compaction
706
+ * is always active — there is no "disabled" state to check. This method short-circuits
707
+ * immediately when `this.harness.hasNativeAutoCompaction` is `true`: a harness that runs its
708
+ * own native auto-compaction mechanism must not ALSO have the SDK's turn-boundary trigger run
709
+ * against the same conversation — that would double-compact via two uncoordinated mechanisms.
710
+ * A harness making that claim is expected to honor `AgentConfig.autoCompactionThreshold`
711
+ * itself, by forwarding it to its native mechanism; the SDK never verifies this.
712
+ *
713
+ * Otherwise, when {@link currentOccupancy} is at/above the resolved
714
+ * {@link ChatSessionDeps.getAutoCompactionThreshold} threshold, compacts the thread in place —
715
+ * the session keeps its object identity but {@link getId} returns a new value afterward, and
716
+ * {@link onThreadIdRotated} notifies `DefaultAgent` to re-key its bookkeeping. Both outcomes
717
+ * (`'compacted'` and `'failed'`) emit a structured `logBus` record tagged
718
+ * `event_type: 'auto-compaction'` — this log has no colocated telemetry sibling, unlike the
719
+ * Tier-1 sites documented in `ARCHITECTURE.md`, so a subscriber must read it off `logBus`
720
+ * directly.
721
+ *
722
+ * A `COMPACTION_FAILED` error is swallowed: the session is left intact (still pointed at
723
+ * the pre-compaction thread, per the harness contract's "leave it intact on failure"
724
+ * clause) and the turn proceeds normally against it rather than failing the whole `chat()`
725
+ * call over a best-effort maintenance operation. Any other error from {@link currentOccupancy}
726
+ * (e.g. a transient `getMessages()` failure while estimating transcript occupancy) is
727
+ * likewise swallowed (after a `logBus.warn`) — it is a best-effort heuristic read, not the
728
+ * actual harness operation, and a hiccup there must not fail the user's turn. Any other error
729
+ * from `harness.compactThread` propagates to `chat()`'s existing pre-stream-failure handling.
730
+ *
731
+ * `abortSignal` is `chat()`'s composed signal — `cancelTurn()`'s own per-turn controller
732
+ * folded together with any caller-supplied `options.abortSignal` — checked before starting
733
+ * the occupancy read and again before calling `compactThread`, so an abort from EITHER source
734
+ * that lands while this is running skips (rather than starts) compaction. This narrows, but
735
+ * does not close, the window an abort can block on: `AgentHarness.compactThread` takes no
736
+ * `AbortSignal`, so a `compactThread` call already in flight when the signal aborts cannot be
737
+ * interrupted — the caller blocks for the remaining compaction latency, and if
738
+ * `harness.stream()` subsequently runs against an already-aborted signal, the turn can end via
739
+ * `notifyPreStreamError` with `finishReason: 'error'` rather than `'cancelled'`. See
740
+ * `cancelTurn()`'s Critical Invariant below for the full contract and why this residual gap is
741
+ * accepted for now.
742
+ */
743
+ async maybeAutoCompact(abortSignal) {
744
+ if (this.harness.hasNativeAutoCompaction || abortSignal.aborted)
745
+ return;
746
+ const threshold = this.getAutoCompactionThreshold();
747
+ let occupancy;
748
+ try {
749
+ occupancy = await this.currentOccupancy();
750
+ }
751
+ catch (err) {
752
+ this.logBus.warn('auto-compaction occupancy read failed; skipping compaction for this turn', { agentId: this.agentId, threadId: this.threadId }, err instanceof Error ? err : undefined);
753
+ return;
754
+ }
755
+ if (occupancy === undefined || occupancy < threshold || abortSignal.aborted)
756
+ return;
757
+ try {
758
+ const newThreadId = await this.harness.compactThread(this.agentId, this.threadId);
759
+ const oldThreadId = this.threadId;
760
+ const contextWindow = this.getContextWindow();
761
+ this.threadId = newThreadId;
762
+ this.resetUsageState();
763
+ this.onThreadIdRotated?.(oldThreadId, newThreadId);
764
+ this.logBus.emitLog({
765
+ level: 'info',
766
+ message: 'Auto-compaction fired',
767
+ context: {
768
+ event_type: 'auto-compaction',
769
+ trigger: 'auto',
770
+ threshold,
771
+ agentId: this.agentId,
772
+ previousThreadId: oldThreadId,
773
+ newThreadId,
774
+ contextWindow,
775
+ occupancy,
776
+ outcome: 'compacted',
777
+ },
778
+ });
779
+ }
780
+ catch (err) {
781
+ if (err instanceof AgentSDKError && err.type === AgentSDKErrorType.COMPACTION_FAILED) {
782
+ this.logBus.emitLog({
783
+ level: 'warn',
784
+ message: 'Auto-compaction failed',
785
+ context: {
786
+ event_type: 'auto-compaction',
787
+ trigger: 'auto',
788
+ threshold,
789
+ agentId: this.agentId,
790
+ previousThreadId: this.threadId,
791
+ contextWindow: this.getContextWindow(),
792
+ occupancy,
793
+ outcome: 'failed',
794
+ },
795
+ });
796
+ return;
797
+ }
798
+ throw err;
799
+ }
800
+ }
801
+ /**
802
+ * The occupancy ratio (0-1) `maybeAutoCompact` checks against the configured threshold,
803
+ * from whichever of two signals is available, in priority order:
804
+ *
805
+ * 1. **Live usage** — when {@link lastTurnUsageTrustworthy}, the same `effectiveInputTokens()
806
+ * / contextWindow` computation {@link getContextUsage} exposes as `usedFraction`.
807
+ * 2. **Transcript-size estimate** — otherwise (cold session, or the prior turn ended
808
+ * abnormally), a rough estimate from the persisted transcript's character count via
809
+ * {@link estimateTranscriptOccupancy}.
810
+ *
811
+ * Returns `undefined` when neither signal is available (a brand-new session with no
812
+ * completed turn and no persisted messages), so `maybeAutoCompact` never compacts a
813
+ * session with nothing to compact.
814
+ */
815
+ async currentOccupancy() {
816
+ if (this.lastTurnUsageTrustworthy) {
817
+ const contextTokens = this.effectiveInputTokens();
818
+ if (contextTokens !== undefined) {
819
+ return clampFraction(contextTokens, this.getContextWindow());
820
+ }
821
+ }
822
+ return this.estimateTranscriptOccupancy();
823
+ }
824
+ /**
825
+ * Estimates occupancy from the thread's persisted transcript size (chars ÷
826
+ * {@link CHARS_PER_TOKEN} as a token-count proxy) when no trustworthy live usage reading is
827
+ * available. Returns `undefined` for an empty transcript so a brand-new thread never
828
+ * triggers compaction.
829
+ */
830
+ async estimateTranscriptOccupancy() {
831
+ const messages = await this.harness.getMessages(this.agentId, this.threadId);
832
+ if (messages.length === 0)
833
+ return undefined;
834
+ const estimatedTokens = estimateTranscriptChars(messages) / CHARS_PER_TOKEN;
835
+ return clampFraction(estimatedTokens, this.getContextWindow());
836
+ }
837
+ /**
838
+ * Re-points this session's inbound router-slice forwarding at a new slice, unsubscribing
839
+ * the old one first. Called by `DefaultAgent` after an auto-compaction rotation re-registers
840
+ * the telemetry-router slice under the new thread id, so any future thread-scoped
841
+ * harness-emitted event routes to this session under its new id rather than falling through
842
+ * to "unrouted". Not part of the public {@link ChatSession} interface — `DefaultAgent` holds
843
+ * the concrete `DefaultChatSession` type, same as {@link releaseWithoutEvent}.
844
+ */
845
+ rebindInboundSlice(slice) {
846
+ for (const unsub of this.inboundUnsubs)
847
+ unsub();
848
+ this.inboundUnsubs.length = 0;
849
+ this.inboundUnsubs.push(slice.telemetry.forwardTo(this.telemetryBus), slice.log.forwardTo(this.logBus));
850
+ }
648
851
  /**
649
852
  * @requirements
650
853
  * - IF `message` is a `string`, it MUST be formatted into a standard `Message` object array containing exactly one message.
@@ -936,23 +1139,26 @@ export class DefaultChatSession {
936
1139
  * not throw.
937
1140
  *
938
1141
  * `startedAt` is the timestamp captured by {@link emitChatStreamStarted} so `durationMs`
939
- * measures real elapsed time even for pre-stream rejections.
1142
+ * measures real elapsed time even for pre-stream rejections. `threadId` is the same
1143
+ * pre-auto-compaction-rotation id captured alongside `startedAt`, so this event stays paired
1144
+ * with its `chat-stream-started` even when the rejection surfaces after a mid-turn rotation
1145
+ * (e.g. `harness.stream()` itself fails on the post-rotation thread).
940
1146
  */
941
- notifyPreStreamError(err, startedAt) {
1147
+ notifyPreStreamError(err, startedAt, threadId) {
942
1148
  const error = err instanceof Error ? err : new Error(String(err));
943
1149
  this.chatEventBus.emit({ type: 'error', error });
944
1150
  this.chatEventBus.emit({ type: 'finish', finishReason: 'error' });
945
1151
  const finishedAt = this.clock.now();
946
1152
  const durationMs = finishedAt.getTime() - startedAt.getTime();
947
1153
  // Tier-1 pair: share one tick across the telemetry event and its colocated log sibling.
948
- this.telemetryBus.emitTelemetry({ type: 'chat-stream-error', agentId: this.agentId, threadId: this.threadId, durationMs, error }, finishedAt);
1154
+ this.telemetryBus.emitTelemetry({ type: 'chat-stream-error', agentId: this.agentId, threadId, durationMs, error }, finishedAt);
949
1155
  this.logBus.emitLog({
950
1156
  level: 'error',
951
1157
  message: 'Chat stream failed',
952
1158
  context: {
953
1159
  event_type: 'chat-stream-error',
954
1160
  agentId: this.agentId,
955
- threadId: this.threadId,
1161
+ threadId,
956
1162
  durationMs,
957
1163
  },
958
1164
  error,
@@ -72,6 +72,19 @@ export interface AgentHarness {
72
72
  * per-agent accessors take the agent id as their first argument.
73
73
  */
74
74
  readonly extensions: Record<string, unknown>;
75
+ /**
76
+ * `true` when this harness's underlying runtime already runs its own native
77
+ * auto-compaction mechanism, so the SDK's own turn-boundary auto-compaction
78
+ * trigger (`ChatSession`'s `maybeAutoCompact`) must NOT also run for this
79
+ * harness's sessions — running both would double-compact the same
80
+ * conversation via two uncoordinated mechanisms. Optional; a harness that
81
+ * omits the field (or sets it `false`) gets the SDK's trigger, which is the
82
+ * correct default for every harness with no native equivalent. A harness
83
+ * that sets `true` is expected to honor `AgentConfig.autoCompactionThreshold`
84
+ * itself, by forwarding it to its own native mechanism — the SDK never
85
+ * verifies this from the harness-agnostic side.
86
+ */
87
+ readonly hasNativeAutoCompaction?: boolean;
75
88
  /**
76
89
  * Shut down the harness gracefully, releasing all resources.
77
90
  * Disconnects MCP servers, closes storage connections, and
@@ -88,7 +88,28 @@ export type AgentConfig = {
88
88
  * annotation-matcher rules don't fire on tools with no annotations.
89
89
  */
90
90
  defaultToolDecision?: Decision;
91
+ /**
92
+ * Occupancy ratio (0-1) that triggers a compaction before the next turn's model call.
93
+ * Defaults to {@link DEFAULT_AUTO_COMPACTION_THRESHOLD} (0.9) when omitted. Values outside
94
+ * `[0.01, 0.99]` are clamped by {@link resolveAutoCompactionThreshold} — see its JSDoc for why.
95
+ *
96
+ * Auto-compaction is **always active** — there is no field to disable it. On a harness
97
+ * whose {@link AgentHarness.hasNativeAutoCompaction} is `true`, this value is forwarded to
98
+ * that harness's own native mechanism instead of running the SDK's turn-boundary trigger.
99
+ */
100
+ autoCompactionThreshold?: number;
91
101
  };
102
+ /** Default {@link AgentConfig.autoCompactionThreshold} occupancy ratio used when a threshold is not supplied. */
103
+ export declare const DEFAULT_AUTO_COMPACTION_THRESHOLD = 0.9;
104
+ /**
105
+ * Resolves an {@link AgentConfig.autoCompactionThreshold} value to its effective threshold:
106
+ * defaults to {@link DEFAULT_AUTO_COMPACTION_THRESHOLD} when `threshold` is omitted or not a
107
+ * finite number (`NaN`, `Infinity`, `-Infinity`), then clamps the result to
108
+ * `[0.01, 0.99]` — see the comment above {@link AUTO_COMPACTION_THRESHOLD_MIN} for why this
109
+ * clamps rather than throws (a live `chat()` turn is the wrong place to fail a config mistake
110
+ * that was already accepted at `createAgent`/`updateAgentConfig` time).
111
+ */
112
+ export declare function resolveAutoCompactionThreshold(threshold?: number): number;
92
113
  /**
93
114
  * Resolves the per-agent OAuth {@link McpAuthProviders} map (remote MCP server
94
115
  * name → provider) from the agent's id and the config the SDK has on file.
@@ -2,6 +2,33 @@
2
2
  * Copyright 2026, Salesforce, Inc. All rights reserved.
3
3
  * See LICENSE.txt for license terms.
4
4
  */
5
+ /** Default {@link AgentConfig.autoCompactionThreshold} occupancy ratio used when a threshold is not supplied. */
6
+ export const DEFAULT_AUTO_COMPACTION_THRESHOLD = 0.9;
7
+ /**
8
+ * Every harness must honor the same effective threshold for the same config value — that is the
9
+ * entire premise of a threshold-only (no on/off toggle) auto-compaction contract. An unvalidated
10
+ * out-of-range or non-numeric input would otherwise degrade differently per harness: on
11
+ * Mastra/OpenAI, a `threshold >= 1` makes the SDK's own `occupancy >= threshold` check
12
+ * (`occupancy` is itself clamped to `[0, 1]`) effectively unreachable — silently disabling
13
+ * auto-compaction on those two harnesses only, reproducing exactly the per-harness-meaningless
14
+ * "disabled" state this design exists to avoid. `[0.01, 0.99]` mirrors the Claude harness's own
15
+ * independent `toAutoCompactPctOverride` clamp, so every harness converges on the same effective
16
+ * value for any input, valid or not.
17
+ */
18
+ const AUTO_COMPACTION_THRESHOLD_MIN = 0.01;
19
+ const AUTO_COMPACTION_THRESHOLD_MAX = 0.99;
20
+ /**
21
+ * Resolves an {@link AgentConfig.autoCompactionThreshold} value to its effective threshold:
22
+ * defaults to {@link DEFAULT_AUTO_COMPACTION_THRESHOLD} when `threshold` is omitted or not a
23
+ * finite number (`NaN`, `Infinity`, `-Infinity`), then clamps the result to
24
+ * `[0.01, 0.99]` — see the comment above {@link AUTO_COMPACTION_THRESHOLD_MIN} for why this
25
+ * clamps rather than throws (a live `chat()` turn is the wrong place to fail a config mistake
26
+ * that was already accepted at `createAgent`/`updateAgentConfig` time).
27
+ */
28
+ export function resolveAutoCompactionThreshold(threshold) {
29
+ const resolved = threshold === undefined || !Number.isFinite(threshold) ? DEFAULT_AUTO_COMPACTION_THRESHOLD : threshold;
30
+ return Math.min(AUTO_COMPACTION_THRESHOLD_MAX, Math.max(AUTO_COMPACTION_THRESHOLD_MIN, resolved));
31
+ }
5
32
  /**
6
33
  * Converts a consumer-facing {@link AgentConfig} into a {@link HarnessAgentConfig}
7
34
  * ready for the harness layer.
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@ export type { ResolverResult, ResolverTiers, ToolInvocation } from './policy-res
7
7
  export type { ContextUsage, FinishReason, UsageMetadata } from './types/usage.js';
8
8
  export type { AgentHooks, HooksForAgent, ToolResultRedactor, ToolResultRedactionInput, ToolResultRedactionResult, } from './types/redaction.js';
9
9
  export type { AgentConfig, AgentStateUpdate, ConsumerMetadataMutation, HarnessAgentConfig, McpAuthProviderResolver, StreamOptions, } from './harness/harness-config.js';
10
- export { DEFAULT_MAX_STEPS } from './harness/harness-config.js';
10
+ export { DEFAULT_MAX_STEPS, DEFAULT_AUTO_COMPACTION_THRESHOLD, resolveAutoCompactionThreshold, } from './harness/harness-config.js';
11
11
  export type { MCPConfiguration, MCPServerConfig, MCPStdioServerConfig, MCPRemoteServerConfig, McpOAuthClientProvider, McpAuthProviders, McpServerInfo, McpServerErrorCategory, McpServerErrorDetail, McpToolInfo, McpToolAnnotations, } from './mcp-config.js';
12
12
  export { McpServerStatus, mcpServerConfigEqual } from './mcp-config.js';
13
13
  export { Model, ModelName, createClaudeModel, Models, validateMultimodalFiles, ACCEPTED_MODEL_WIRE_IDS, } from './models/index.js';
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // `resolveToolApprovalPolicy` into their gate sites (Phase 2); consumers author
9
9
  // `AgentConfig.toolPolicies` (directly or via `definePolicy`).
10
10
  export { BUILT_IN_TOOL_POLICIES, SKILL_BRIDGE_SERVER_ID, definePolicy, matcherMatches, resolveToolApprovalPolicy, } from './policy-resolver.js';
11
- export { DEFAULT_MAX_STEPS } from './harness/harness-config.js';
11
+ export { DEFAULT_MAX_STEPS, DEFAULT_AUTO_COMPACTION_THRESHOLD, resolveAutoCompactionThreshold, } from './harness/harness-config.js';
12
12
  export { McpServerStatus, mcpServerConfigEqual } from './mcp-config.js';
13
13
  export { Model, ModelName, createClaudeModel, Models, validateMultimodalFiles, ACCEPTED_MODEL_WIRE_IDS, } from './models/index.js';
14
14
  export { MimeType } from './models/index.js';
@@ -0,0 +1,17 @@
1
+ import type { Message } from '../types/messages.js';
2
+ /**
3
+ * Approximate characters-per-token ratio used to estimate context occupancy from raw transcript
4
+ * text when no live usage reading is available (cold session, or the last turn ended abnormally).
5
+ * A rough estimate is only ever used to decide whether to compact — the post-compaction turn's
6
+ * real usage reading is the source of truth.
7
+ */
8
+ export declare const CHARS_PER_TOKEN = 4;
9
+ /**
10
+ * Estimates the total character count of a message transcript, for use as a rough
11
+ * proxy for token count (via {@link CHARS_PER_TOKEN}) when no live usage reading is
12
+ * available. Sums plain string content directly; for structured `MessagePart[]`
13
+ * content, sums the text of `text` / `reasoning` parts, the serialized size of
14
+ * `tool-call` args and `tool-result` payloads, and the base64 payload length of
15
+ * `image` / `file` parts.
16
+ */
17
+ export declare function estimateTranscriptChars(messages: Message[]): number;
@@ -0,0 +1,48 @@
1
+ /*
2
+ * Copyright 2026, Salesforce, Inc. All rights reserved.
3
+ * See LICENSE.txt for license terms.
4
+ */
5
+ /**
6
+ * Approximate characters-per-token ratio used to estimate context occupancy from raw transcript
7
+ * text when no live usage reading is available (cold session, or the last turn ended abnormally).
8
+ * A rough estimate is only ever used to decide whether to compact — the post-compaction turn's
9
+ * real usage reading is the source of truth.
10
+ */
11
+ export const CHARS_PER_TOKEN = 4;
12
+ /**
13
+ * Estimates the total character count of a message transcript, for use as a rough
14
+ * proxy for token count (via {@link CHARS_PER_TOKEN}) when no live usage reading is
15
+ * available. Sums plain string content directly; for structured `MessagePart[]`
16
+ * content, sums the text of `text` / `reasoning` parts, the serialized size of
17
+ * `tool-call` args and `tool-result` payloads, and the base64 payload length of
18
+ * `image` / `file` parts.
19
+ */
20
+ export function estimateTranscriptChars(messages) {
21
+ let total = 0;
22
+ for (const message of messages) {
23
+ if (typeof message.content === 'string') {
24
+ total += message.content.length;
25
+ }
26
+ else {
27
+ for (const part of message.content) {
28
+ total += estimatePartChars(part);
29
+ }
30
+ }
31
+ }
32
+ return total;
33
+ }
34
+ function estimatePartChars(part) {
35
+ switch (part.type) {
36
+ case 'text':
37
+ case 'reasoning':
38
+ return part.text.length;
39
+ case 'tool-call':
40
+ return JSON.stringify(part.args).length;
41
+ case 'tool-result':
42
+ return JSON.stringify(part.result).length;
43
+ case 'image':
44
+ case 'file':
45
+ return part.data.length;
46
+ }
47
+ }
48
+ //# sourceMappingURL=transcript-occupancy.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-sdk",
3
- "version": "0.80.0",
3
+ "version": "0.82.0",
4
4
  "description": "Harness-agnostic agentic infrastructure for Salesforce developer experience tooling",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -48,24 +48,24 @@
48
48
  },
49
49
  "devDependencies": {
50
50
  "@eslint/js": "^10.0.1",
51
- "@salesforce/sfdx-agent-harness-claude": "0.76.0",
52
- "@salesforce/sfdx-agent-harness-mastra": "0.79.0",
53
- "@salesforce/sfdx-agent-harness-openai": "0.45.0",
51
+ "@salesforce/sfdx-agent-harness-claude": "0.78.0",
52
+ "@salesforce/sfdx-agent-harness-mastra": "0.81.0",
53
+ "@salesforce/sfdx-agent-harness-openai": "0.47.0",
54
54
  "@types/node": "^22.20.1",
55
55
  "@vitest/coverage-istanbul": "^4.1.11",
56
56
  "@vitest/eslint-plugin": "^1.6.27",
57
- "eslint": "^10.9.1",
57
+ "eslint": "^10.10.0",
58
58
  "eslint-config-prettier": "^10.1.8",
59
59
  "eslint-import-resolver-typescript": "^4.4.5",
60
60
  "eslint-plugin-import": "^2.32.0",
61
61
  "eslint-plugin-n": "^18.3.0",
62
62
  "globals": "^17.12.0",
63
- "lint-staged": "^17.4.1",
63
+ "lint-staged": "^17.5.0",
64
64
  "prettier": "^3.9.6",
65
65
  "rimraf": "^6.1.3",
66
66
  "tsx": "^4.23.13",
67
67
  "typescript": "^7.0.2",
68
- "typescript-eslint": "^8.69.0",
68
+ "typescript-eslint": "^8.70.0",
69
69
  "vitest": "^4.1.11"
70
70
  },
71
71
  "engines": {