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 +6 -0
- package/README.md +28 -0
- package/dist/definitions/agent.d.ts +6 -2
- package/dist/definitions/manager.d.ts +10 -1
- package/dist/definitions/session.d.ts +31 -1
- package/dist/index.d.ts +1 -1
- package/dist/manager/session-manager.js +15 -1
- package/dist/turns/turn-stream.d.ts +7 -0
- package/dist/turns/turn-stream.js +78 -1
- package/package.json +4 -4
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
|
-
/**
|
|
7
|
-
|
|
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 {
|
|
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.
|
|
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.
|
|
63
|
+
"ai-sdk-sandbox-sbx": "0.1.3"
|
|
64
64
|
},
|
|
65
65
|
"author": {
|
|
66
66
|
"name": "Fabien Pasquet",
|