ai-sdk-harness-sessions 0.2.1 → 0.3.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
@@ -1,5 +1,11 @@
1
1
  # ai-sdk-harness-sessions
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 02b3ad7: `onTurn`, a new option of `createSessionManager()`, is called once each turn is over and the session written, with a `TurnReport`: the turn's tokens, cache reads and writes included, the model, tokens and tool calls of each step, and the raw events of its harness — Claude Code's `rate_limit_event`, for instance. An application can account for its sessions, and read the rate limits of a subscription. `SessionStreamResult` may give `steps` and `fullStream`, as a `StreamTextResult` does; an agent whose results give neither reports its tokens only.
8
+
3
9
  ## 0.2.1
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -371,6 +371,33 @@ A resume state holds secrets: the token of the harness bridge, the placeholders
371
371
 
372
372
  Keep one manager per store, unless the store gives leases (below): at its start, a manager takes the sessions the store holds as live for those of a process that crashed, and marks them `interrupted`.
373
373
 
374
+ ### What a turn cost
375
+
376
+ `onTurn` is called once each turn is over and the session written, with a `TurnReport`: the turn's tokens, cache reads and writes included, each of its steps — the model that answered, its tokens and the tools it called, with their input — and the raw events of its harness, in order. Keep them to account for your sessions, or to read the rate limits of a subscription, which Claude Code reports as `rate_limit_event`:
377
+
378
+ ```ts
379
+ const sessions = createSessionManager<Metadata>({
380
+ // …
381
+ onTurn: async ({ sessionId, usage, steps, rawEvents }) => {
382
+ await db.turns.insert({
383
+ sessionId,
384
+ inputTokens: usage.inputTokens,
385
+ cacheReadTokens: usage.inputTokenDetails.cacheReadTokens,
386
+ outputTokens: usage.outputTokens,
387
+ models: [...new Set(steps.map(({ modelId }) => modelId))],
388
+ });
389
+ const limits = rawEvents.filter(
390
+ (event) => (event as { type?: string }).type === 'rate_limit_event',
391
+ );
392
+ if (limits.length > 0) await db.rateLimits.insert(sessionId, limits.at(-1));
393
+ },
394
+ });
395
+ ```
396
+
397
+ A `rate_limit_event` of Claude Code (signed in with a subscription) gives every window in `rate_limit_info.unifiedWindows`, such as `five_hour` and `seven_day`, each with its `utilization` (a fraction) and its `resetsAt` (in seconds). The harness names its tools in lower case: `bash`, not `Bash`.
398
+
399
+ The steps and the raw events come from the agent's result, `steps` and `fullStream`: a `HarnessAgent` gives both; an agent of your own that gives neither reports its tokens only. What `onTurn` throws goes to `onError`; the turn is settled either way.
400
+
374
401
  ### Several instances
375
402
 
376
403
  An application that runs on several instances — behind a load balancer, scaled out, or a new version deployed beside the old one — shares one store between their managers. The store tells them apart with leases: three more methods, on the same database, and the managers share it.
@@ -433,6 +460,7 @@ The lease's expiry is read on the database's clock, the one clock all instances
433
460
  | `approve` | none | Answers an approval before anyone sees it: `{ approved, reason }`, or `undefined` to ask a person |
434
461
  | `generateId` | `crypto.randomUUID` | The ids of new sessions and of the messages of their turns |
435
462
  | `errorMessage` | `getHarnessErrorMessage` | What a client is told of an error in a turn; the session's `error` keeps the full message |
463
+ | `onTurn` | none | Called once each turn is over, with its `TurnReport`: tokens in detail, steps, raw harness events |
436
464
  | `onError` | `console.error` | Called with what fails in the background: a start, a suspension, a write to the store |
437
465
 
438
466
  | Method | What it does |
@@ -3,8 +3,12 @@ import type { HarnessAgentResumeSessionState, HarnessAgentSession } from '@ai-sd
3
3
  import type { StreamTextResult, ToolApprovalResponse, ToolResultPart, ToolSet } from 'ai';
4
4
  /** A turn's result, whatever the agent's runtime context and output. */
5
5
  type AnyStreamTextResult = StreamTextResult<ToolSet, any, any>;
6
- /** What the manager reads of a turn: a `StreamTextResult` is one. */
7
- export type SessionStreamResult = Pick<AnyStreamTextResult, 'totalUsage' | 'toUIMessageStream'>;
6
+ /**
7
+ * What the manager reads of a turn: a `StreamTextResult` is one. Its `steps` and `fullStream`, when
8
+ * it has them, give `onTurn` the model, the tokens and the tools of each step, and the raw events of
9
+ * the harness.
10
+ */
11
+ export type SessionStreamResult = Partial<Pick<AnyStreamTextResult, 'fullStream' | 'steps'>> & Pick<AnyStreamTextResult, 'totalUsage' | 'toUIMessageStream'>;
8
12
  /**
9
13
  * What the manager needs of an agent: a `HarnessAgent` is one, whatever its harness, tools and
10
14
  * settings.
@@ -1,7 +1,7 @@
1
1
  import type { ToolApprovalResponse, ToolResultPart, UIMessage, UIMessageChunk } from 'ai';
2
2
  import type { SessionAgent } from './agent.js';
3
3
  import type { SessionSandboxes } from './sandboxes.js';
4
- import type { ApprovalVerdict, PendingApproval, SessionRecord, SessionSummary } from './session.js';
4
+ import type { ApprovalVerdict, PendingApproval, SessionRecord, SessionSummary, TurnReport } from './session.js';
5
5
  import type { SessionStore } from './store.js';
6
6
  export interface SessionManagerOptions<METADATA> {
7
7
  /**
@@ -68,6 +68,15 @@ export interface SessionManagerOptions<METADATA> {
68
68
  * `@ai-sdk/harness`. The session's `error` keeps the full message.
69
69
  */
70
70
  errorMessage?: (error: unknown) => string;
71
+ /**
72
+ * Called once each turn is over and the session written, with what the turn cost: its tokens,
73
+ * cache reads and writes included, the model, tokens and tool calls of each step, and the raw
74
+ * events of its harness — what an application needs to account for its sessions, or to read the
75
+ * rate limits of a subscription. What it throws goes to `onError`; the turn is settled either way.
76
+ */
77
+ onTurn?: (report: TurnReport, context: {
78
+ session: SessionSummary<METADATA>;
79
+ }) => PromiseLike<void> | void;
71
80
  /**
72
81
  * Called with what fails in the background: a start, a suspension, a write to the store. Default:
73
82
  * `console.error`.
@@ -1,4 +1,4 @@
1
- import type { UIMessage } from 'ai';
1
+ import type { LanguageModelUsage, UIMessage } from 'ai';
2
2
  /**
3
3
  * Where a session stands in its lifecycle.
4
4
  *
@@ -29,6 +29,36 @@ export interface SessionUsage {
29
29
  outputTokens: number;
30
30
  totalTokens: number;
31
31
  }
32
+ /** A step of a turn: the model that answered, the tokens it used, and the tools it called. */
33
+ export interface TurnStepReport {
34
+ /** The model, as the harness names it: `claude-sonnet-4-5`, for instance. */
35
+ modelId: string | undefined;
36
+ usage: LanguageModelUsage;
37
+ /** The tools it called, with their input: the skill it loaded, the subagent it started… */
38
+ toolCalls: {
39
+ toolCallId: string;
40
+ toolName: string;
41
+ input: unknown;
42
+ }[];
43
+ }
44
+ /**
45
+ * A turn once it is over, for accounting: its tokens, cache reads and writes included, each of its
46
+ * steps, and the raw events its harness sent — rate limits, for instance, which Claude Code reports
47
+ * as `rate_limit_event`.
48
+ */
49
+ export interface TurnReport {
50
+ sessionId: string;
51
+ startedAt: Date;
52
+ endedAt: Date;
53
+ /** What went wrong, when the turn failed or was cut short. */
54
+ failure: string | undefined;
55
+ /** The tokens of the whole turn, over all its streams. */
56
+ usage: LanguageModelUsage;
57
+ /** Its steps, in order; empty when the agent's results do not give them. */
58
+ steps: TurnStepReport[];
59
+ /** The raw events of the harness, in order; empty when the agent's results do not stream them. */
60
+ rawEvents: unknown[];
61
+ }
32
62
  /** An answer to an approval request, as `ToolApprovalResponse` carries it. */
33
63
  export interface ApprovalVerdict {
34
64
  approved: boolean;
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export type { SessionAgent, SessionStreamResult } from './definitions/agent.js';
2
2
  export type { ContinueOptions, SendOptions, SessionEvent, SessionManager, SessionManagerOptions, SessionTurn, } from './definitions/manager.js';
3
3
  export type { SessionSandboxes, SessionSandboxLease } from './definitions/sandboxes.js';
4
4
  export { LIVE_STATUSES, SESSION_STATUSES } from './definitions/session.js';
5
- export type { PendingApproval, PendingInput, PendingToolCall, SessionRecord, SessionStatus, SessionSummary, SessionUsage, } from './definitions/session.js';
5
+ export type { PendingApproval, PendingInput, PendingToolCall, SessionRecord, SessionStatus, SessionSummary, SessionUsage, TurnReport, TurnStepReport, } from './definitions/session.js';
6
6
  export type { SessionClaim, SessionStore } from './definitions/store.js';
7
7
  export { SessionCapacityError } from './errors/session-capacity-error.js';
8
8
  export { SessionConflictError } from './errors/session-conflict-error.js';
@@ -294,6 +294,18 @@ class HarnessSessionManager {
294
294
  await this.options.sandboxes.shutdown?.().catch(this.report('shutdown'));
295
295
  await this.journal.flush();
296
296
  };
297
+ /** Hands a turn that is over to `onTurn`: what it throws goes to `onError`, the turn stays settled. */
298
+ async reportTurn(session, turn) {
299
+ if (this.options.onTurn === undefined)
300
+ return;
301
+ const report = { sessionId: session.record.id, ...turn, endedAt: new Date() };
302
+ try {
303
+ await this.options.onTurn(report, { session: summaryOf(session.record) });
304
+ }
305
+ catch (error) {
306
+ this.report('report turn', session.record.id)(error);
307
+ }
308
+ }
297
309
  report = (action, sessionId) => (error) => this.settings.onError(error, { action, ...(sessionId !== undefined && { sessionId }) });
298
310
  async ready() {
299
311
  if (this.stopped)
@@ -579,6 +591,7 @@ class HarnessSessionManager {
579
591
  : undefined;
580
592
  let finish;
581
593
  session.turn = { abort, done: new Promise((resolve) => (finish = resolve)) };
594
+ const startedAt = new Date();
582
595
  await this.transition(session, 'busy');
583
596
  const settle = async (failure) => {
584
597
  clearTimeout(timer);
@@ -604,7 +617,7 @@ class HarnessSessionManager {
604
617
  generateMessageId: generateId,
605
618
  errorMessage,
606
619
  next: (messages) => this.answerPause(session, messages, answered),
607
- onSettled: async ({ messages, failure, usage }) => {
620
+ onSettled: async ({ messages, failure, usage, report }) => {
608
621
  if (messages !== undefined)
609
622
  record.messages = messages;
610
623
  record.turns += 1;
@@ -612,6 +625,7 @@ class HarnessSessionManager {
612
625
  record.usage.outputTokens += usage.outputTokens ?? 0;
613
626
  record.usage.totalTokens += usage.totalTokens ?? 0;
614
627
  await settle(failure);
628
+ await this.reportTurn(session, { startedAt, failure, ...report });
615
629
  },
616
630
  });
617
631
  // A turn cut short by a close or a shutdown is over once they wrote the session.
@@ -1,5 +1,6 @@
1
1
  import type { LanguageModelUsage, UIMessage, UIMessageChunk } from 'ai';
2
2
  import type { SessionStreamResult } from '../definitions/agent.js';
3
+ import type { TurnStepReport } from '../definitions/session.js';
3
4
  /** The tokens a turn used, added up over its streams. */
4
5
  export type TurnUsage = Pick<LanguageModelUsage, 'inputTokens' | 'outputTokens' | 'totalTokens'>;
5
6
  /** How a turn ended. */
@@ -9,6 +10,12 @@ export interface TurnOutcome {
9
10
  /** What went wrong, when the turn failed or was cut short. */
10
11
  failure: string | undefined;
11
12
  usage: TurnUsage;
13
+ /** The turn's tokens in detail, its steps and the raw events of its harness, for `onTurn`. */
14
+ report: {
15
+ usage: LanguageModelUsage;
16
+ steps: TurnStepReport[];
17
+ rawEvents: unknown[];
18
+ };
12
19
  }
13
20
  /**
14
21
  * What a turn does once one of its streams ends: answers given in between, sent to the client and
@@ -40,6 +40,68 @@ async function readSegment(result, originalMessages, { first, send, options, })
40
40
  }
41
41
  return outcome;
42
42
  }
43
+ /** Tokens of nothing yet: what the turn's tokens are added to. */
44
+ function emptyUsage() {
45
+ return {
46
+ inputTokens: 0,
47
+ inputTokenDetails: { noCacheTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
48
+ outputTokens: 0,
49
+ outputTokenDetails: { textTokens: 0, reasoningTokens: 0 },
50
+ totalTokens: 0,
51
+ };
52
+ }
53
+ const add = (a, b) => a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0);
54
+ /** `total` plus `spent`, detail by detail: a count neither of them knows stays unknown. */
55
+ function addDetails(total, spent) {
56
+ if (spent === undefined)
57
+ return total;
58
+ return {
59
+ inputTokens: add(total.inputTokens, spent.inputTokens),
60
+ inputTokenDetails: {
61
+ noCacheTokens: add(total.inputTokenDetails.noCacheTokens, spent.inputTokenDetails?.noCacheTokens),
62
+ cacheReadTokens: add(total.inputTokenDetails.cacheReadTokens, spent.inputTokenDetails?.cacheReadTokens),
63
+ cacheWriteTokens: add(total.inputTokenDetails.cacheWriteTokens, spent.inputTokenDetails?.cacheWriteTokens),
64
+ },
65
+ outputTokens: add(total.outputTokens, spent.outputTokens),
66
+ outputTokenDetails: {
67
+ textTokens: add(total.outputTokenDetails.textTokens, spent.outputTokenDetails?.textTokens),
68
+ reasoningTokens: add(total.outputTokenDetails.reasoningTokens, spent.outputTokenDetails?.reasoningTokens),
69
+ },
70
+ totalTokens: add(total.totalTokens, spent.totalTokens),
71
+ };
72
+ }
73
+ /** The steps of a stream of the turn: their model, tokens and tool calls. */
74
+ async function stepsOf(result) {
75
+ const steps = await Promise.resolve(result.steps).catch(() => undefined);
76
+ return (steps ?? []).map((step) => ({
77
+ modelId: step.response?.modelId,
78
+ usage: step.usage,
79
+ toolCalls: (step.toolCalls ?? []).map(({ toolCallId, toolName, input }) => ({
80
+ toolCallId,
81
+ toolName,
82
+ input: input,
83
+ })),
84
+ }));
85
+ }
86
+ /**
87
+ * The raw events a stream of the turn carries, read from its own copy of the stream, beside the
88
+ * client's: what the harness sends that no other part says.
89
+ */
90
+ async function rawEventsOf(result) {
91
+ const events = [];
92
+ if (result.fullStream === undefined)
93
+ return events;
94
+ try {
95
+ for await (const part of result.fullStream) {
96
+ if (part.type === 'raw')
97
+ events.push(part.rawValue);
98
+ }
99
+ }
100
+ catch {
101
+ // The turn's failure is told by the client's stream: the events read so far are kept.
102
+ }
103
+ return events;
104
+ }
43
105
  /** Adds the tokens a stream of the turn used, once it is over. */
44
106
  async function addUsage(usage, result) {
45
107
  const spent = await Promise.resolve(result.totalUsage).catch(() => undefined);
@@ -52,6 +114,9 @@ class TurnRun {
52
114
  options;
53
115
  send;
54
116
  usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
117
+ details = emptyUsage();
118
+ steps = [];
119
+ rawEvents = [];
55
120
  messages;
56
121
  failure;
57
122
  finish;
@@ -63,12 +128,19 @@ class TurnRun {
63
128
  let result = first;
64
129
  let originalMessages = this.options.originalMessages;
65
130
  for (let index = 0; result !== undefined; index += 1) {
131
+ const raw = rawEventsOf(result);
66
132
  const outcome = await readSegment(result, originalMessages, {
67
133
  first: index === 0,
68
134
  send: this.send,
69
135
  options: this.options,
70
136
  });
71
137
  await addUsage(this.usage, result);
138
+ const spent = await Promise.resolve(result.totalUsage).catch(() => undefined);
139
+ this.details = addDetails(this.details, spent);
140
+ const steps = await stepsOf(result);
141
+ const events = await raw;
142
+ this.steps.push(...steps);
143
+ this.rawEvents.push(...events);
72
144
  this.failure ??= outcome.failure;
73
145
  this.finish = outcome.finish;
74
146
  this.messages = outcome.messages ?? this.messages;
@@ -79,7 +151,12 @@ class TurnRun {
79
151
  }
80
152
  if (this.finish !== undefined)
81
153
  this.send(this.finish);
82
- return { messages: this.messages, failure: this.failure, usage: this.usage };
154
+ return {
155
+ messages: this.messages,
156
+ failure: this.failure,
157
+ usage: this.usage,
158
+ report: { usage: this.details, steps: this.steps, rawEvents: this.rawEvents },
159
+ };
83
160
  }
84
161
  /**
85
162
  * What follows a stream that ended well, its approval requests sent first, with the answers
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-sdk-harness-sessions",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Sessions for AI SDK harness agents: the lifecycle of conversations with Claude Code, Codex and friends — one turn at a time, suspended when idle, resumed on the next message and after a restart, several of them sharing one sandbox.",
5
5
  "keywords": [
6
6
  "ai",
@@ -55,12 +55,12 @@
55
55
  "ai": "^7.0.136",
56
56
  "typescript": "^6.0.3",
57
57
  "vitest": "^5.0.3",
58
- "@repo/build": "0.0.0",
59
- "@repo/prettier-config": "0.0.0",
60
58
  "@repo/eslint-config": "0.0.0",
59
+ "@repo/prettier-config": "0.0.0",
60
+ "@repo/build": "0.0.0",
61
61
  "@repo/typescript-config": "0.0.0",
62
62
  "@repo/vitest-config": "0.0.0",
63
- "ai-sdk-sandbox-sbx": "0.1.2"
63
+ "ai-sdk-sandbox-sbx": "0.1.3"
64
64
  },
65
65
  "author": {
66
66
  "name": "Fabien Pasquet",