@salesforce/sfdx-agent-sdk 0.75.0 → 0.77.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,16 @@
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.77.0] - 2026-09-08
7
+
8
+ ### Fixes
9
+ - **agent-sdk**: AgentManager.shutdown disposes agents instead of destroying them @W-23632695@ ([#794](https://github.com/forcedotcom/agentic-dx/pull/794))
10
+
11
+ ## [0.76.0] - 2026-09-08
12
+
13
+ ### Features
14
+ - **agent-sdk**: self-describing usage fields on getContextUsage + telemetry @W-24125085@ ([#802](https://github.com/forcedotcom/agentic-dx/pull/802))
15
+
6
16
  ## [0.75.0] - 2026-09-08
7
17
 
8
18
  _No changes — released alongside dependent packages._
package/README.md CHANGED
@@ -52,8 +52,9 @@ for await (const event of eventStream) {
52
52
  }
53
53
  }
54
54
 
55
- // 4. Shut down. The harness is torn down; persisted identity files are NOT
56
- // removed, so a subsequent `createAgentManager` call restores them.
55
+ // 4. Shut down. Agents are disposed (in-memory release) and the harness is torn down;
56
+ // persisted identity, threads, message history, and session context are NOT removed,
57
+ // so a subsequent `createAgentManager` restores the agents and their chat sessions.
57
58
  await manager.shutdown();
58
59
  ```
59
60
 
@@ -110,7 +111,7 @@ explicitly. The `createAgent` config parameter narrows automatically when the ha
110
111
  | `getAgent` | `(agentId: string) => Agent<H>` | Retrieve a live agent by ID. Throws `AgentSDKError` (`AGENT_NOT_FOUND`) for unknown ids and for ids that are only present in `getRestoreFailures()`. |
111
112
  | `getAgentIds` | `() => string[]` | List all live agent IDs (successful + successfully restored). Failed-restore agents are not included — query `getRestoreFailures()` separately. |
112
113
  | `destroyAgent` | `(agentId: string) => Promise<void>` | Destroy an agent, remove its identity record from disk, and clear any matching `getRestoreFailures()` entry. Failed-restore-only ids are accepted (no harness call made). |
113
- | `shutdown` | `() => Promise<void>` | Destroy all live agents and shut down the harness. Identity files survive (that's the whole point) restart `createAgentManager` over the same root to bring them back. |
114
+ | `shutdown` | `() => Promise<void>` | Dispose all live agents (release in-memory resources) and shut down the harness. Persisted threads, message history, and session context are NOT deleted; restart `createAgentManager` over the same root to restore the agents and their chat sessions. |
114
115
  | `onTelemetry` | `(callback: TelemetryEventCallback) => Unsubscribe` | Subscribe to telemetry across all managed agents. |
115
116
  | `onLog` | `(callback: (record: LogRecord) => void) => Unsubscribe` | Subscribe to structured logs across all managed agents. Bridge this into your host logger to observe restore-failure events + soft-skip warnings. |
116
117
  | `onWireCommunication` | `(callback: WireCommunicationEventCallback) => Unsubscribe` | Subscribe to wire-level communication events from the harness. Opt-in diagnostic channel that surfaces outbound LLM requests, responses, and harness-specific monitoring metadata. Subscriber-gated end-to-end — harnesses pay no cost when nobody listens. See "Wire-Communication Events" below for the event-shape catalog and the harness-asymmetric coverage (Mastra emits per-call request/response pairs; Claude emits a per-stream pointer to a debug log file). |
@@ -725,18 +726,40 @@ type UsageMetadata = {
725
726
 
726
727
  type ContextUsage = {
727
728
  /**
728
- * Last per-step usage reading observed on this session. Pre-first-turn and
729
- * immediately after `clearHistory()` this is `{}` (every token field undefined).
729
+ * Legacy alias for `lastStepUsage` (identical value). Retained so existing
730
+ * callers don't break; new consumers should read `lastStepUsage`. `{}`
731
+ * pre-first-turn / post-`clearHistory()`.
730
732
  */
731
733
  usage: UsageMetadata;
734
+ /**
735
+ * The last model step's usage — the "how full is my context" reading.
736
+ * ≡ `usage`. Always populated; `{}` pre-first-turn / post-`clearHistory()`.
737
+ */
738
+ lastStepUsage: UsageMetadata;
739
+ /**
740
+ * The whole-turn aggregate (`finish.usage`) from the last completed turn — the
741
+ * billing/throughput total. Sums every step and can exceed `contextWindow`, so
742
+ * it is NOT an occupancy reading; read `stepCount` to interpret it. Absent
743
+ * pre-first-turn, after `clearHistory()`, and when the turn produced no
744
+ * `finish.usage`.
745
+ */
746
+ turnUsage?: UsageMetadata;
747
+ /** Model steps in the last completed turn. Absent pre-first-turn / post-`clearHistory()`. */
748
+ stepCount?: number;
732
749
  /** The model's total context-window size in tokens. Always populated. */
733
750
  contextWindow: number;
734
751
  /**
735
- * `(usage.inputTokens + usage.cachedInputTokens + usage.cacheWriteInputTokens) / contextWindow`,
736
- * clamped to [0, 1]. Cached prompt tokens are summed in because they occupy the
737
- * model's context window — on Bedrock-Claude, the bulk of the prompt is reported
738
- * via `cachedInputTokens` / `cacheWriteInputTokens`, not `inputTokens`. `undefined`
739
- * when ALL three input-bearing fields are missing.
752
+ * The raw numerator behind `usedFraction` the last step's effective input
753
+ * `(inputTokens ?? 0) + (cachedInputTokens ?? 0) + (cacheWriteInputTokens ?? 0)`.
754
+ * `undefined` under the same condition as `usedFraction`.
755
+ */
756
+ contextTokens: number | undefined;
757
+ /**
758
+ * `contextTokens / contextWindow`, clamped to [0, 1]. Cached prompt tokens are
759
+ * summed into `contextTokens` because they occupy the model's context window —
760
+ * on Bedrock-Claude, the bulk of the prompt is reported via `cachedInputTokens`
761
+ * / `cacheWriteInputTokens`, not `inputTokens`. `undefined` when ALL three
762
+ * input-bearing fields are missing.
740
763
  */
741
764
  usedFraction: number | undefined;
742
765
  };
@@ -765,9 +788,17 @@ const pct = ctx.usedFraction !== undefined ? `${Math.round(ctx.usedFraction * 10
765
788
  return `${used} / ${limit} tokens (${pct})`;
766
789
  ```
767
790
 
768
- The snapshot uses **last-step** semantics, not the per-turn billing aggregate — `finish.usage` sums all steps in a turn
769
- and double-counts persistent context, which is the wrong denominator for "how full is my context." For per-turn billing
770
- totals, subscribe to `chat-stream-completed` telemetry instead.
791
+ Occupancy (`contextTokens` / `usedFraction`) uses **last-step** semantics, not the per-turn billing aggregate —
792
+ `finish.usage` sums all steps in a turn and double-counts persistent context, which is the wrong denominator for "how
793
+ full is my context." When you do want the per-turn total, read `turnUsage` (and `stepCount` to interpret it) on the same
794
+ snapshot, or subscribe to `chat-stream-completed` telemetry — both carry the identical aggregate.
795
+
796
+ > **`usage` is deprecated.** `usage` still works and is unchanged, but it is marked `@deprecated` (W-24125085) — read
797
+ > the explicit names instead: `lastStepUsage` for occupancy, `turnUsage` + `stepCount` for the per-turn total. The alias
798
+ > points at different measurements per surface, which is exactly the ambiguity the explicit names remove: on
799
+ > `ContextUsage` / the REST `context-usage` response `usage` aliases the **last step**, while on `chat-stream-completed`
800
+ > telemetry it aliases the **whole-turn aggregate**. Removal is gated on a future major once consumers have migrated
801
+ > (W-24125086).
771
802
 
772
803
  ### Error Handling
773
804
 
@@ -136,7 +136,11 @@ export interface AgentManager<H extends AgentHarness = AgentHarness> {
136
136
  * restore-failure entry.
137
137
  */
138
138
  destroyAgent(agentId: string): Promise<void>;
139
- /** Shuts down the harness and destroys all live agents. */
139
+ /**
140
+ * Disposes every live agent (in-memory release) and shuts down the harness. Persisted
141
+ * threads, message history, and per-thread session context are NOT deleted, so a subsequent
142
+ * `createAgentManager` over the same storage root restores the agents and their chat sessions.
143
+ */
140
144
  shutdown(): Promise<void>;
141
145
  /**
142
146
  * Harness-specific extensions namespace, typed off the harness subtype `H`.
@@ -128,7 +128,13 @@ export class DefaultAgentManager {
128
128
  return;
129
129
  }
130
130
  for (const [agentId, agent] of this.agents) {
131
- await agent.destroy();
131
+ // Graceful shutdown releases in-memory resources but MUST NOT delete persisted
132
+ // threads / message history / session context — `dispose()`, not `destroy()`. A
133
+ // subsequent createAgentManager over the same storageRootFolder then restores every
134
+ // agent's chat sessions and seeded context. `harness.shutdown()` below releases the
135
+ // harness-side per-agent runtime (coordinators, MCP clients, storage handle). Using
136
+ // `destroy()` here deleted the very state boot-restore exists to bring back (W-23632695).
137
+ agent.dispose();
132
138
  this.router.unregisterAgent(agentId);
133
139
  }
134
140
  this.agents.clear();
package/dist/agent.d.ts CHANGED
@@ -345,6 +345,31 @@ export declare class DefaultAgent implements Agent {
345
345
  * - MUST delegate to `this.harness.destroyAgent(this.config.agentId)` to clean up the agent's harness resources.
346
346
  */
347
347
  destroy(): Promise<void>;
348
+ /**
349
+ * Release this agent's in-memory resources WITHOUT deleting any persisted state.
350
+ * Used by {@link DefaultAgentManager.shutdown} on a graceful process stop.
351
+ *
352
+ * Unlike {@link destroy} (the `DELETE /agents` verb), `dispose` does NOT call
353
+ * `harness.destroyThread` / `harness.destroyAgent`, so the agent's threads, message
354
+ * history, and per-thread session context stay on the storage root and a subsequent
355
+ * `createAgentManager` over that root restores them. The harness's own per-agent
356
+ * runtime (coordinators, MCP clients, storage handle) is released by
357
+ * `harness.shutdown()`, which the manager calls after disposing every agent.
358
+ *
359
+ * Emits NO `agent-destroyed` telemetry — the agent is being released for restart,
360
+ * not destroyed. Idempotent via the shared `disposed` guard.
361
+ *
362
+ * Not on the public {@link Agent} interface — only {@link DefaultAgentManager} calls
363
+ * it, and it holds the concrete `DefaultAgent` (same reason {@link restoreSessions}
364
+ * is DefaultAgent-only). See W-23632695.
365
+ */
366
+ dispose(): void;
367
+ /**
368
+ * Tear down inbound + parent telemetry/log forwarding and dispose this agent's own
369
+ * buses, then flip `disposed`. Shared by {@link destroy} and {@link dispose} so the
370
+ * two teardown verbs can't drift on forwarding / bus cleanup.
371
+ */
372
+ private teardownForwardingAndBuses;
348
373
  onTelemetry(callback: TelemetryEventCallback): Unsubscribe;
349
374
  onLog(callback: (record: LogRecord) => void): Unsubscribe;
350
375
  /**
package/dist/agent.js CHANGED
@@ -387,6 +387,42 @@ export class DefaultAgent {
387
387
  message: 'Agent destroyed',
388
388
  context: { event_type: 'agent-destroyed', agentId: this.agentId },
389
389
  }, agentDestroyedAt);
390
+ this.teardownForwardingAndBuses();
391
+ }
392
+ /**
393
+ * Release this agent's in-memory resources WITHOUT deleting any persisted state.
394
+ * Used by {@link DefaultAgentManager.shutdown} on a graceful process stop.
395
+ *
396
+ * Unlike {@link destroy} (the `DELETE /agents` verb), `dispose` does NOT call
397
+ * `harness.destroyThread` / `harness.destroyAgent`, so the agent's threads, message
398
+ * history, and per-thread session context stay on the storage root and a subsequent
399
+ * `createAgentManager` over that root restores them. The harness's own per-agent
400
+ * runtime (coordinators, MCP clients, storage handle) is released by
401
+ * `harness.shutdown()`, which the manager calls after disposing every agent.
402
+ *
403
+ * Emits NO `agent-destroyed` telemetry — the agent is being released for restart,
404
+ * not destroyed. Idempotent via the shared `disposed` guard.
405
+ *
406
+ * Not on the public {@link Agent} interface — only {@link DefaultAgentManager} calls
407
+ * it, and it holds the concrete `DefaultAgent` (same reason {@link restoreSessions}
408
+ * is DefaultAgent-only). See W-23632695.
409
+ */
410
+ dispose() {
411
+ if (this.disposed) {
412
+ return;
413
+ }
414
+ for (const [sessionId, session] of this.sessions) {
415
+ this.detachSession(sessionId, session, false);
416
+ }
417
+ this.sessions.clear();
418
+ this.teardownForwardingAndBuses();
419
+ }
420
+ /**
421
+ * Tear down inbound + parent telemetry/log forwarding and dispose this agent's own
422
+ * buses, then flip `disposed`. Shared by {@link destroy} and {@link dispose} so the
423
+ * two teardown verbs can't drift on forwarding / bus cleanup.
424
+ */
425
+ teardownForwardingAndBuses() {
390
426
  for (const unsub of this.inboundUnsubs)
391
427
  unsub();
392
428
  for (const unsub of this.parentUnsubs)
@@ -456,8 +492,17 @@ export class DefaultAgent {
456
492
  }, sessionCreatedAt);
457
493
  return session;
458
494
  }
459
- detachSession(threadId, session) {
460
- session.dispose();
495
+ detachSession(threadId, session, emitDestroyed = true) {
496
+ // `emitDestroyed` is false only on the graceful-shutdown release path (`dispose()`): the
497
+ // session is preserved on disk for restore, so it must not emit `session-destroyed` — the
498
+ // session-level analog of `dispose()` suppressing `agent-destroyed` (W-23632695). Every real
499
+ // teardown (destroy / destroyChatSession / compact / clone source) keeps the default `true`.
500
+ if (emitDestroyed) {
501
+ session.dispose();
502
+ }
503
+ else {
504
+ session.releaseWithoutEvent();
505
+ }
461
506
  const unregister = this.sessionSliceUnregisters.get(threadId);
462
507
  if (unregister) {
463
508
  unregister();
@@ -193,16 +193,22 @@ export interface ChatSession {
193
193
  /**
194
194
  * Snapshot of how much of the model's context window the most recent
195
195
  * turn used. Always returns a `ContextUsage` — pre-first-turn and
196
- * immediately after `clearHistory()`, `usage` is `{}` and `usedFraction`
197
- * is `undefined`, but `contextWindow` is always populated from the
198
- * agent's currently-bound model.
196
+ * immediately after `clearHistory()`, `usage` / `lastStepUsage` are `{}`,
197
+ * `turnUsage` / `stepCount` are absent, and `usedFraction` / `contextTokens`
198
+ * are `undefined`, but `contextWindow` is always populated from the agent's
199
+ * currently-bound model.
199
200
  *
200
- * `usage` carries the **last per-step** reading from the model the
201
- * size of the prompt the model saw on its most recent invocation,
202
- * which is the right "how full is my context" answer for deciding
203
- * when to call `compactThread()`. This is **not** the per-turn billing
204
- * aggregate; consumers who want billing totals should subscribe to
205
- * `chat-stream-completed` telemetry.
201
+ * Two self-describing measurements (Phase 2, W-24125085), with `usage`
202
+ * retained as the legacy alias for the last-step reading:
203
+ *
204
+ * - **`lastStepUsage`** (≡ `usage`) the **last per-step** reading from the
205
+ * model, the size of the prompt the model saw on its most recent
206
+ * invocation. The right "how full is my context" answer for deciding when
207
+ * to call `compactThread()`; `usedFraction` / `contextTokens` derive from it.
208
+ * - **`turnUsage`** (+ `stepCount`) — the whole-turn billing/throughput
209
+ * aggregate (`finish.usage`). It sums every step and can exceed
210
+ * `contextWindow`, so it is **not** an occupancy reading. Equivalent to the
211
+ * per-turn total on `chat-stream-completed` telemetry.
206
212
  *
207
213
  * The `contextWindow` is read live from the agent's currently-bound
208
214
  * model, so it reflects any `Agent.updateAgentConfig()` model swap
@@ -339,6 +345,19 @@ export declare class DefaultChatSession implements ChatSession {
339
345
  * thread starts unprimed.
340
346
  */
341
347
  private latestUsage;
348
+ /**
349
+ * The whole-turn aggregate (`finish.usage`) and model-step count from the
350
+ * last COMPLETED turn, retained as session state so {@link getContextUsage}
351
+ * can return them as `turnUsage` / `stepCount` (Phase 1 counted `stepCount`
352
+ * as a `wrapEventStream` local; Phase 2, W-24125085, promotes both to
353
+ * session state — mirroring {@link latestUsage}). Set in `wrapEventStream`'s
354
+ * natural-completion branch (never on an errored / abandoned turn), read by
355
+ * `getContextUsage`, and reset by `clearHistory()`. `latestTurnUsage` is
356
+ * `undefined` (not `{}`) pre-first-turn so `turnUsage` is *absent* rather
357
+ * than empty; `latestStepCount` is `0` so `stepCount` is likewise omitted.
358
+ */
359
+ private latestTurnUsage;
360
+ private latestStepCount;
342
361
  private disposed;
343
362
  /**
344
363
  * True while a turn started by {@link chat} is in flight — from the `chat()`
@@ -459,8 +478,10 @@ export declare class DefaultChatSession implements ChatSession {
459
478
  /**
460
479
  * @requirements
461
480
  * - MUST delegate to `this.harness.clearMessages()`, passing `this.agentId` and `this.threadId`.
462
- * - MUST reset `latestUsage` to `{}` so the next `getContextUsage()` reports a fresh
463
- * "no reading yet" snapshot until the next turn produces one.
481
+ * - MUST reset `latestUsage` to `{}` and drop the retained per-turn state
482
+ * (`latestTurnUsage` / `latestStepCount`) so the next `getContextUsage()`
483
+ * reports a fresh "no reading yet" snapshot (`turnUsage` / `stepCount`
484
+ * absent) until the next turn produces one.
464
485
  */
465
486
  clearHistory(): Promise<void>;
466
487
  /**
@@ -545,6 +566,19 @@ export declare class DefaultChatSession implements ChatSession {
545
566
  onTelemetry(callback: TelemetryEventCallback): Unsubscribe;
546
567
  onLog(callback: (record: LogRecord) => void): Unsubscribe;
547
568
  dispose(): void;
569
+ /**
570
+ * Release this session's in-memory resources WITHOUT emitting `session-destroyed`.
571
+ * Used on the graceful-shutdown release path (`DefaultAgent.dispose()`), where the thread
572
+ * and its session context are preserved on disk for restore, so a "destroyed" signal would
573
+ * misinform telemetry observers. Teardown is otherwise identical to `dispose()`. Not on the
574
+ * public `ChatSession` interface — only `DefaultAgent.detachSession` calls it (W-23632695).
575
+ */
576
+ releaseWithoutEvent(): void;
577
+ /**
578
+ * Shared in-memory teardown for `dispose()` (which first emits `session-destroyed`) and
579
+ * `releaseWithoutEvent()` (silent), so the two paths can't drift on cleanup.
580
+ */
581
+ private teardown;
548
582
  private emitToolApprovalResolved;
549
583
  /**
550
584
  * Clears the per-turn tracking maps at a terminal `finish`. Both maps are
@@ -72,6 +72,19 @@ export class DefaultChatSession {
72
72
  * thread starts unprimed.
73
73
  */
74
74
  latestUsage = {};
75
+ /**
76
+ * The whole-turn aggregate (`finish.usage`) and model-step count from the
77
+ * last COMPLETED turn, retained as session state so {@link getContextUsage}
78
+ * can return them as `turnUsage` / `stepCount` (Phase 1 counted `stepCount`
79
+ * as a `wrapEventStream` local; Phase 2, W-24125085, promotes both to
80
+ * session state — mirroring {@link latestUsage}). Set in `wrapEventStream`'s
81
+ * natural-completion branch (never on an errored / abandoned turn), read by
82
+ * `getContextUsage`, and reset by `clearHistory()`. `latestTurnUsage` is
83
+ * `undefined` (not `{}`) pre-first-turn so `turnUsage` is *absent* rather
84
+ * than empty; `latestStepCount` is `0` so `stepCount` is likewise omitted.
85
+ */
86
+ latestTurnUsage = undefined;
87
+ latestStepCount = 0;
75
88
  disposed = false;
76
89
  /**
77
90
  * True while a turn started by {@link chat} is in flight — from the `chat()`
@@ -311,12 +324,34 @@ export class DefaultChatSession {
311
324
  }, finishedAt);
312
325
  }
313
326
  else {
327
+ // Retain the turn's aggregate + step count as session state so a later
328
+ // `getContextUsage()` can return `turnUsage` / `stepCount` (the last-step
329
+ // `latestUsage` is already retained per step-finish). Only the natural-
330
+ // completion branch runs this — an errored or abandoned turn leaves the
331
+ // last COMPLETED turn's figures in place, matching the completion log,
332
+ // which is likewise emitted only here. COPY `finishUsage` on retain so the
333
+ // retained state does NOT alias the object the emit sites below forward by
334
+ // reference: otherwise a telemetry/log subscriber mutating the emitted
335
+ // `turnUsage` would corrupt `latestTurnUsage` and surface on a later
336
+ // `getContextUsage()` (which spreads on read, carrying the mutation).
337
+ this.latestTurnUsage = finishUsage === undefined ? undefined : { ...finishUsage };
338
+ this.latestStepCount = stepCount;
314
339
  this.telemetryBus.emitTelemetry({
315
340
  type: 'chat-stream-completed',
316
341
  agentId: this.agentId,
317
342
  threadId: this.threadId,
318
343
  durationMs,
344
+ // Self-describing usage on telemetry (W-24125085, Phase 2): `usage`
345
+ // stays as the legacy alias for the whole-turn aggregate, now also
346
+ // named `turnUsage`; `lastStepUsage` carries the last-step reading
347
+ // (spread so consumer mutation can't leak into retained state); and
348
+ // `stepCount` makes the aggregate interpretable. Omitted-when-absent
349
+ // for `stepCount` only — `usage`/`turnUsage` keep the prior field's
350
+ // always-present shape (both are `finishUsage`, so they stay ≡).
319
351
  usage: finishUsage,
352
+ turnUsage: finishUsage,
353
+ lastStepUsage: { ...this.latestUsage },
354
+ ...(stepCount > 0 ? { stepCount } : {}),
320
355
  }, finishedAt);
321
356
  // Fold the turn's usage + post-turn context-window occupancy onto the
322
357
  // existing completion log so an operator can read both without
@@ -334,11 +369,14 @@ export class DefaultChatSession {
334
369
  // step reports, and `contextUsageLogFields()` stays empty pre-first-turn /
335
370
  // post-`clearHistory()`.
336
371
  //
337
- // `turnUsage` forwards `finishUsage` BY REFERENCE (unlike `lastStepUsage`,
338
- // which spreads `latestUsage`): `finishUsage` is a turn-local value discarded
339
- // when this method returns, so no consumer can observe mutation of it;
340
- // `latestUsage` is retained session state a later `getContextUsage()` reads,
341
- // so it must be copied.
372
+ // Both emit sites (this log and the telemetry event above) forward
373
+ // `turnUsage`/`usage` as `finishUsage` BY REFERENCE. That stays safe because
374
+ // `finishUsage` is a turn-local value discarded when this method returns the
375
+ // retained `latestTurnUsage` is a COPY of it (see above), so it does not alias
376
+ // `finishUsage` and no consumer can corrupt session state by mutating the
377
+ // emitted object. `lastStepUsage` instead spreads `latestUsage` (retained
378
+ // session state a later `getContextUsage()` reads), so it is copied at the
379
+ // emit site.
342
380
  this.logBus.emitLog({
343
381
  level: 'info',
344
382
  message: 'Chat stream completed',
@@ -434,13 +472,17 @@ export class DefaultChatSession {
434
472
  /**
435
473
  * @requirements
436
474
  * - MUST delegate to `this.harness.clearMessages()`, passing `this.agentId` and `this.threadId`.
437
- * - MUST reset `latestUsage` to `{}` so the next `getContextUsage()` reports a fresh
438
- * "no reading yet" snapshot until the next turn produces one.
475
+ * - MUST reset `latestUsage` to `{}` and drop the retained per-turn state
476
+ * (`latestTurnUsage` / `latestStepCount`) so the next `getContextUsage()`
477
+ * reports a fresh "no reading yet" snapshot (`turnUsage` / `stepCount`
478
+ * absent) until the next turn produces one.
439
479
  */
440
480
  async clearHistory() {
441
481
  this.assertNotDisposed();
442
482
  await this.harness.clearMessages(this.agentId, this.threadId);
443
483
  this.latestUsage = {};
484
+ this.latestTurnUsage = undefined;
485
+ this.latestStepCount = 0;
444
486
  }
445
487
  /**
446
488
  * @requirements
@@ -468,15 +510,29 @@ export class DefaultChatSession {
468
510
  getContextUsage() {
469
511
  this.assertNotDisposed();
470
512
  const contextWindow = this.getContextWindow();
471
- const effectiveInputTokens = this.effectiveInputTokens();
472
- const usedFraction = effectiveInputTokens === undefined
473
- ? undefined
474
- : Math.min(1, Math.max(0, effectiveInputTokens / contextWindow));
475
- // Spread `latestUsage` so consumer mutation of the returned `usage`
476
- // object cannot leak back into the session's internal state on a
477
- // subsequent `getContextUsage()` call. `UsageMetadata`'s fields are
478
- // all primitives, so a shallow copy is sufficient.
479
- return { usage: { ...this.latestUsage }, contextWindow, usedFraction };
513
+ const contextTokens = this.effectiveInputTokens();
514
+ const usedFraction = contextTokens === undefined ? undefined : Math.min(1, Math.max(0, contextTokens / contextWindow));
515
+ // Spread `latestUsage` so consumer mutation of the returned object cannot
516
+ // leak back into the session's retained state on a later call. `usage` is
517
+ // the legacy alias for `lastStepUsage` (Phase 2, W-24125085) the SAME
518
+ // object, so a consumer migrating from `usage` reads identical data.
519
+ // `UsageMetadata`'s fields are all primitives, so a shallow copy suffices.
520
+ const lastStepUsage = { ...this.latestUsage };
521
+ return {
522
+ usage: lastStepUsage,
523
+ lastStepUsage,
524
+ contextWindow,
525
+ // `contextTokens` mirrors `usedFraction`'s presence (both `undefined`
526
+ // when the latest reading carries no input-side tokens).
527
+ contextTokens,
528
+ usedFraction,
529
+ // `turnUsage` (spread for the same anti-leak reason) and `stepCount`
530
+ // describe the last COMPLETED turn; omitted pre-first-turn /
531
+ // post-`clearHistory()` and (for `turnUsage`) when the turn produced
532
+ // no `finish.usage`, matching the completion log's omit-when-absent.
533
+ ...(this.latestTurnUsage !== undefined ? { turnUsage: { ...this.latestTurnUsage } } : {}),
534
+ ...(this.latestStepCount > 0 ? { stepCount: this.latestStepCount } : {}),
535
+ };
480
536
  }
481
537
  /**
482
538
  * The last-step context numerator — the sum of all three input-bearing usage
@@ -607,6 +663,26 @@ export class DefaultChatSession {
607
663
  threadId: this.threadId,
608
664
  },
609
665
  }, sessionDestroyedAt);
666
+ this.teardown();
667
+ }
668
+ /**
669
+ * Release this session's in-memory resources WITHOUT emitting `session-destroyed`.
670
+ * Used on the graceful-shutdown release path (`DefaultAgent.dispose()`), where the thread
671
+ * and its session context are preserved on disk for restore, so a "destroyed" signal would
672
+ * misinform telemetry observers. Teardown is otherwise identical to `dispose()`. Not on the
673
+ * public `ChatSession` interface — only `DefaultAgent.detachSession` calls it (W-23632695).
674
+ */
675
+ releaseWithoutEvent() {
676
+ if (this.disposed) {
677
+ return;
678
+ }
679
+ this.teardown();
680
+ }
681
+ /**
682
+ * Shared in-memory teardown for `dispose()` (which first emits `session-destroyed`) and
683
+ * `releaseWithoutEvent()` (silent), so the two paths can't drift on cleanup.
684
+ */
685
+ teardown() {
610
686
  for (const unsub of this.inboundUnsubs)
611
687
  unsub();
612
688
  for (const unsub of this.parentUnsubs)
@@ -42,7 +42,31 @@ export type ChatStreamCompletedEvent = Base<'chat-stream-completed'> & {
42
42
  agentId: string;
43
43
  threadId: string;
44
44
  durationMs: number;
45
+ /**
46
+ * @deprecated Use {@link turnUsage} (identical value). `usage` is the
47
+ * ambiguous legacy name — it aliases the *whole-turn aggregate* here but the
48
+ * *last step* on `ContextUsage`, which is exactly the confusion the explicit
49
+ * names remove. Retained so existing telemetry consumers don't break
50
+ * (W-24125085); slated for removal in a future major (W-24125086).
51
+ *
52
+ * The whole-turn aggregate (`finish.usage`).
53
+ */
45
54
  usage?: UsageMetadata;
55
+ /**
56
+ * The whole-turn aggregate (`finish.usage`) — the billing/throughput total.
57
+ * Identical to {@link usage} (the legacy alias). Sums every step; read
58
+ * {@link stepCount} to interpret it.
59
+ */
60
+ turnUsage?: UsageMetadata;
61
+ /**
62
+ * The last model step's usage — the right "how full is my context" reading
63
+ * (the same measurement `ChatSession.getContextUsage()` returns). Carries
64
+ * forward the last reported reading across a usage gap (W-22692131); `{}`
65
+ * only when no step has reported since session start / `clearHistory()`.
66
+ */
67
+ lastStepUsage?: UsageMetadata;
68
+ /** Number of model steps in the turn. Absent when the turn had no reporting step. */
69
+ stepCount?: number;
46
70
  };
47
71
  export type ChatStreamErrorEvent = Base<'chat-stream-error'> & {
48
72
  agentId: string;
@@ -42,20 +42,33 @@ export type UsageMetadata = {
42
42
  * smaller model, or warn the user as the conversation approaches the
43
43
  * model's context limit.
44
44
  *
45
- * `usage` carries the **last per-step** reading from the model —
46
- * specifically the `usage` from the latest `step-finish` event whose `usage`
47
- * was defined. This is the size of the prompt the model saw on its last
48
- * invocation, which is the right "how full is my context" reading. This is
49
- * **not** the per-turn billing aggregate (which sums steps and double-counts
50
- * persistent context). For per-turn billing totals, subscribe to
51
- * `chat-stream-completed` telemetry instead.
45
+ * Two self-describing measurements name the two things a reader might want
46
+ * (Phase 2, W-24125085), and the ambiguous **`usage` is retained as a
47
+ * documented alias** for the last-step reading so no existing consumer breaks:
48
+ *
49
+ * - **`lastStepUsage`** (≡ `usage`) the `usage` from the latest `step-finish`
50
+ * event whose `usage` was defined: the size of the prompt the model saw on
51
+ * its last invocation, the right "how full is my context" reading. New
52
+ * consumers should read `lastStepUsage`; `usage` is the legacy alias.
53
+ * - **`turnUsage`** — the whole-turn aggregate (`finish.usage`): the
54
+ * billing/throughput total. It sums every step and can exceed `contextWindow`,
55
+ * so it is **not** an occupancy reading; read `stepCount` alongside it to
56
+ * interpret its size.
52
57
  *
53
58
  * Field shapes:
54
59
  *
55
- * - `usage` is always populated. Pre-first-turn (or post-`clearHistory()`)
56
- * it is the empty object `{}` — i.e., a `UsageMetadata` whose token fields
57
- * are all `undefined` — making "no reading yet" indistinguishable from
58
- * "harness reported every field as undefined."
60
+ * - `usage` / `lastStepUsage` are always populated (and identical).
61
+ * Pre-first-turn (or post-`clearHistory()`) they are the empty object `{}` —
62
+ * i.e., a `UsageMetadata` whose token fields are all `undefined` — making
63
+ * "no reading yet" indistinguishable from "harness reported every field as
64
+ * undefined."
65
+ * - `turnUsage` and `stepCount` describe the last **completed** turn and are
66
+ * **absent** pre-first-turn, after `clearHistory()`, and (for `turnUsage`)
67
+ * when the completed turn produced no `finish.usage`.
68
+ * - `contextTokens` is the raw numerator behind `usedFraction`; `undefined`
69
+ * under the same condition as `usedFraction`. Added so the SDK exposes the
70
+ * same three occupancy fields (`contextTokens` / `contextWindow` /
71
+ * `usedFraction`) as the completion log.
59
72
  * - `contextWindow` is always populated, contractually. Every `Model`
60
73
  * bound to an `Agent` via `ModelConnectivityInfo.model` must publish a
61
74
  * `contextWindow`; see the `sfdx-agent-sdk` ARCHITECTURE.md Critical
@@ -70,17 +83,56 @@ export type UsageMetadata = {
70
83
  */
71
84
  export type ContextUsage = {
72
85
  /**
73
- * Last per-step usage reading observed on this session. Pre-first-turn
74
- * and immediately after `clearHistory()` this is `{}` (every token field
75
- * undefined).
86
+ * @deprecated Use {@link lastStepUsage} (identical value). `usage` is the
87
+ * ambiguous legacy name it aliases the *last step* here but the *whole-turn
88
+ * aggregate* on `chat-stream-completed` telemetry, which is exactly the
89
+ * confusion the explicit names remove. Retained so existing callers don't
90
+ * break (W-24125085); slated for removal in a future major (W-24125086).
91
+ *
92
+ * Last per-step usage reading. Pre-first-turn and immediately after
93
+ * `clearHistory()` this is `{}` (every token field undefined).
76
94
  */
77
95
  usage: UsageMetadata;
96
+ /**
97
+ * The last model step's usage — the size of the prompt the model saw on its
98
+ * most recent invocation, the right "how full is my context" reading.
99
+ * Identical to {@link usage} (the legacy alias). Always populated; `{}`
100
+ * pre-first-turn / post-`clearHistory()`.
101
+ */
102
+ lastStepUsage: UsageMetadata;
103
+ /**
104
+ * The whole-turn aggregate (`finish.usage`) from the last **completed** turn —
105
+ * the billing/throughput total. It sums every step and can exceed
106
+ * {@link contextWindow}, so read {@link stepCount} to interpret it:
107
+ * `lastStepUsage.inputTokens ≤ turnUsage.inputTokens ≤ lastStepUsage.inputTokens × stepCount`
108
+ * (the lower bound is strict only when `stepCount > 1`). **Absent**
109
+ * pre-first-turn, after `clearHistory()`, and when the completed turn
110
+ * produced no `finish.usage`. This is the value `chat-stream-completed`
111
+ * telemetry carries as its `usage` alias.
112
+ */
113
+ turnUsage?: UsageMetadata;
114
+ /**
115
+ * Number of model steps in the last **completed** turn (counts every step,
116
+ * including one whose `step-finish` reported undefined usage — W-22692131).
117
+ * Makes {@link turnUsage} interpretable next to {@link lastStepUsage}.
118
+ * **Absent** pre-first-turn / post-`clearHistory()`.
119
+ */
120
+ stepCount?: number;
78
121
  /**
79
122
  * The model's total context-window size in tokens. Read live at call
80
123
  * time from the agent's currently-bound `ModelConnectivityInfo.model`,
81
124
  * so it stays correct across `Agent.updateAgentConfig()` model swaps.
82
125
  */
83
126
  contextWindow: number;
127
+ /**
128
+ * The exact numerator behind {@link usedFraction}: the last step's effective
129
+ * input `(inputTokens ?? 0) + (cachedInputTokens ?? 0) + (cacheWriteInputTokens ?? 0)`.
130
+ * `undefined` under the same condition as `usedFraction` (all three
131
+ * input-bearing fields missing on the latest reading). Added in Phase 2
132
+ * (W-24125085) so occupancy reads identically to the completion log's three
133
+ * fields — `contextTokens` / `contextWindow` / `usedFraction`.
134
+ */
135
+ contextTokens: number | undefined;
84
136
  /**
85
137
  * `(usage.inputTokens + usage.cachedInputTokens + usage.cacheWriteInputTokens) /
86
138
  * contextWindow`, clamped to `[0, 1]`. The denominator-numerator includes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-sdk",
3
- "version": "0.75.0",
3
+ "version": "0.77.0",
4
4
  "description": "Harness-agnostic agentic infrastructure for Salesforce developer experience tooling",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -48,9 +48,9 @@
48
48
  },
49
49
  "devDependencies": {
50
50
  "@eslint/js": "^10.0.1",
51
- "@salesforce/sfdx-agent-harness-claude": "0.71.0",
52
- "@salesforce/sfdx-agent-harness-mastra": "0.74.0",
53
- "@salesforce/sfdx-agent-harness-openai": "0.40.0",
51
+ "@salesforce/sfdx-agent-harness-claude": "0.73.0",
52
+ "@salesforce/sfdx-agent-harness-mastra": "0.76.0",
53
+ "@salesforce/sfdx-agent-harness-openai": "0.42.0",
54
54
  "@types/node": "^22.20.1",
55
55
  "@vitest/coverage-istanbul": "^4.1.11",
56
56
  "@vitest/eslint-plugin": "^1.6.27",