@agentplat/sessions 0.2.0-beta.10

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/dist/index.js ADDED
@@ -0,0 +1,1005 @@
1
+ import { AgentPlatError } from '@agentplat/core';
2
+ const sessionPolicies = {
3
+ mode: 'multi_agent_session',
4
+ tools: 'denied',
5
+ externalWrites: 'denied',
6
+ };
7
+ /**
8
+ * Deterministic, ephemeral round-robin orchestration over an `AgentRuntime`.
9
+ *
10
+ * The session owns turn order and bounded transcript assembly only. It does not
11
+ * persist data, execute tools itself or imply Agent Room governance.
12
+ */
13
+ export class MultiAgentSession {
14
+ options;
15
+ speakers;
16
+ tenant;
17
+ credentials;
18
+ maxRounds;
19
+ historyLimit;
20
+ sessionTimeoutMs;
21
+ turnTimeoutMs;
22
+ fallbackPlatform;
23
+ maxTokens;
24
+ maxTokensBySpeaker;
25
+ maxCostUsd;
26
+ stopMarkers;
27
+ sinkFailureMode;
28
+ idGenerator;
29
+ clock;
30
+ constructor(options) {
31
+ this.options = options;
32
+ this.speakers = validateSpeakers(options.speakers);
33
+ this.tenant = options.tenant
34
+ ? structuredClone(options.tenant)
35
+ : { tenantId: 'local' };
36
+ required(this.tenant.tenantId, 'tenant.tenantId');
37
+ this.credentials = options.credentials
38
+ ? { ...options.credentials }
39
+ : undefined;
40
+ this.maxRounds = positiveInteger(options.maxRounds ?? 4, 'maxRounds', 100);
41
+ this.historyLimit = positiveInteger(options.historyLimit ?? 50, 'historyLimit', 1_000);
42
+ this.sessionTimeoutMs = optionalTimeout(options.sessionTimeoutMs, 'sessionTimeoutMs');
43
+ this.turnTimeoutMs = optionalTimeout(options.turnTimeoutMs, 'turnTimeoutMs');
44
+ this.fallbackPlatform = normalizeFallbackPlatform(options.fallbackPlatform);
45
+ this.maxTokens = optionalPositiveNumber(options.maxTokens, 'maxTokens');
46
+ this.maxTokensBySpeaker = normalizeSpeakerBudgets(options.maxTokensBySpeaker);
47
+ this.maxCostUsd = optionalPositiveNumber(options.maxCostUsd, 'maxCostUsd');
48
+ if (this.maxCostUsd !== undefined && !options.estimateCostUsd) {
49
+ throw new AgentPlatError('VALIDATION_ERROR', 'maxCostUsd requires estimateCostUsd');
50
+ }
51
+ this.stopMarkers = (options.stopMarkers ?? []).map((marker, index) => {
52
+ required(marker, `stopMarkers[${index}]`);
53
+ return marker;
54
+ });
55
+ this.idGenerator =
56
+ options.idGenerator ?? (() => globalThis.crypto.randomUUID());
57
+ this.clock = options.clock ?? (() => new Date());
58
+ this.sinkFailureMode = options.sinkFailureMode ?? 'best_effort';
59
+ if (this.sinkFailureMode !== 'best_effort' &&
60
+ this.sinkFailureMode !== 'required') {
61
+ throw new AgentPlatError('VALIDATION_ERROR', 'sinkFailureMode must be best_effort or required');
62
+ }
63
+ }
64
+ /** Stream one independent multi-agent execution. */
65
+ stream(input) {
66
+ return this.recordEvents(this.execute(input));
67
+ }
68
+ /** Run a session to completion and return its bounded final state. */
69
+ async run(input) {
70
+ let result;
71
+ for await (const _event of this.recordEvents(this.execute(input, (completed) => {
72
+ result = completed;
73
+ }))) {
74
+ // Draining the stream preserves the same execution path as web clients.
75
+ }
76
+ if (!result) {
77
+ throw new AgentPlatError('INTERNAL_ERROR', 'The multi-agent session ended without a result');
78
+ }
79
+ return result;
80
+ }
81
+ async *execute(input, onResult) {
82
+ validateInput(input.input);
83
+ const sessionId = input.sessionId ?? this.idGenerator();
84
+ required(sessionId, 'sessionId');
85
+ const startedAt = performance.now();
86
+ const sessionTimeout = createTimeout(optionalTimeout(input.timeoutMs, 'timeoutMs') ?? this.sessionTimeoutMs, 'Session timed out');
87
+ const history = cloneHistory(input.history ?? []).slice(-this.historyLimit);
88
+ const usage = emptyUsage();
89
+ let turnsCompleted = 0;
90
+ let roundsCompleted = 0;
91
+ let stopReason;
92
+ let stopDetail;
93
+ let stopMarker;
94
+ let activePlatform;
95
+ let estimatedCostUsd = 0;
96
+ const tokensBySpeaker = new Map();
97
+ try {
98
+ yield {
99
+ type: 'session_started',
100
+ runId: sessionId,
101
+ payload: {
102
+ sessionId,
103
+ speakers: this.speakers.map(speakerRef),
104
+ maxRounds: this.maxRounds,
105
+ historyLimit: this.historyLimit,
106
+ },
107
+ };
108
+ outer: for (let round = 1; round <= this.maxRounds; round += 1) {
109
+ for (const speaker of this.speakers) {
110
+ if (sessionTimeout.timedOut) {
111
+ stopReason = 'timeout';
112
+ stopDetail = 'Session timed out';
113
+ break outer;
114
+ }
115
+ if (input.stopSignal?.aborted) {
116
+ stopReason = 'stopped';
117
+ stopDetail = cooperativeStopMessage(input.stopSignal);
118
+ break outer;
119
+ }
120
+ if (input.signal?.aborted) {
121
+ stopReason = 'aborted';
122
+ stopDetail = abortMessage(input.signal);
123
+ break outer;
124
+ }
125
+ const turn = turnsCompleted + 1;
126
+ const turnId = this.idGenerator();
127
+ const turnPayload = {
128
+ sessionId,
129
+ turnId,
130
+ speaker: speakerRef(speaker),
131
+ round,
132
+ turn,
133
+ createdAt: this.clock().toISOString(),
134
+ };
135
+ yield {
136
+ type: 'speaker_changed',
137
+ runId: turnId,
138
+ payload: turnPayload,
139
+ };
140
+ yield { type: 'turn_started', runId: turnId, payload: turnPayload };
141
+ const turnStartedAt = performance.now();
142
+ const turnTimeout = createTimeout(this.turnTimeoutMs, 'Turn timed out');
143
+ const executionSignal = combineSignals(input.signal, sessionTimeout.signal, turnTimeout.signal);
144
+ let output = '';
145
+ let completion;
146
+ let turnFailure;
147
+ try {
148
+ for (let attempt = 0; attempt < 2; attempt += 1) {
149
+ const platform = activePlatform ?? speaker.platform;
150
+ const agent = this.agentDefinition(speaker, platform);
151
+ output = '';
152
+ completion = undefined;
153
+ turnFailure = undefined;
154
+ try {
155
+ const runInput = this.buildInput({
156
+ sessionId,
157
+ scenario: input.input,
158
+ speaker,
159
+ round,
160
+ turn,
161
+ history,
162
+ metadata: input.metadata,
163
+ });
164
+ for await (const event of this.options.runtime.stream(agent, runInput, {
165
+ tenant: this.tenant,
166
+ runId: turnId,
167
+ agentId: speaker.id,
168
+ signal: executionSignal.signal,
169
+ credentials: this.credentials,
170
+ policies: sessionPolicies,
171
+ metadata: {
172
+ ...(input.metadata ?? {}),
173
+ sessionId,
174
+ speakerId: speaker.id,
175
+ round,
176
+ turn,
177
+ },
178
+ })) {
179
+ switch (event.type) {
180
+ case 'started':
181
+ break;
182
+ case 'token':
183
+ output += event.content;
184
+ yield {
185
+ type: 'token',
186
+ runId: turnId,
187
+ content: event.content,
188
+ payload: turnPayload,
189
+ };
190
+ break;
191
+ case 'tool_call':
192
+ case 'tool_result':
193
+ yield {
194
+ type: event.type,
195
+ runId: turnId,
196
+ content: event.content,
197
+ payload: {
198
+ ...turnPayload,
199
+ ...(event.payload
200
+ ? { runtimePayload: event.payload }
201
+ : {}),
202
+ },
203
+ };
204
+ break;
205
+ case 'completed':
206
+ output = event.content ?? output;
207
+ completion = event.payload;
208
+ break;
209
+ case 'failed':
210
+ turnFailure = event.content;
211
+ break;
212
+ }
213
+ }
214
+ if (sessionTimeout.timedOut || turnTimeout.timedOut) {
215
+ stopReason = 'timeout';
216
+ stopDetail = sessionTimeout.timedOut
217
+ ? 'Session timed out'
218
+ : 'Turn timed out';
219
+ }
220
+ }
221
+ catch (error) {
222
+ if (sessionTimeout.timedOut || turnTimeout.timedOut) {
223
+ stopReason = 'timeout';
224
+ stopDetail = sessionTimeout.timedOut
225
+ ? 'Session timed out'
226
+ : 'Turn timed out';
227
+ }
228
+ else if (input.signal?.aborted) {
229
+ stopReason = 'aborted';
230
+ stopDetail = abortMessage(input.signal);
231
+ }
232
+ else {
233
+ turnFailure ??= errorMessage(error);
234
+ }
235
+ }
236
+ if (!turnFailure || stopReason)
237
+ break;
238
+ yield {
239
+ type: 'turn_failed',
240
+ runId: turnId,
241
+ payload: {
242
+ ...turnPayload,
243
+ reason: 'failed',
244
+ platform,
245
+ detail: turnFailure,
246
+ },
247
+ };
248
+ if (this.fallbackPlatform &&
249
+ this.fallbackPlatform !== platform &&
250
+ attempt === 0) {
251
+ activePlatform = this.fallbackPlatform;
252
+ yield {
253
+ type: 'provider_fallback',
254
+ runId: turnId,
255
+ payload: {
256
+ ...turnPayload,
257
+ fromPlatform: platform,
258
+ toPlatform: activePlatform,
259
+ detail: turnFailure,
260
+ },
261
+ };
262
+ continue;
263
+ }
264
+ stopReason = 'failed';
265
+ stopDetail = turnFailure;
266
+ break;
267
+ }
268
+ }
269
+ finally {
270
+ executionSignal.dispose();
271
+ turnTimeout.dispose();
272
+ }
273
+ if (turnFailure && !stopReason) {
274
+ stopReason = 'failed';
275
+ stopDetail = turnFailure;
276
+ }
277
+ if (stopReason === 'aborted' ||
278
+ stopReason === 'timeout' ||
279
+ stopReason === 'failed') {
280
+ if (stopReason === 'failed') {
281
+ yield {
282
+ type: 'session_failed',
283
+ runId: sessionId,
284
+ content: stopDetail ?? 'Session turn failed',
285
+ payload: {
286
+ ...turnPayload,
287
+ reason: 'failed',
288
+ ...(stopDetail ? { detail: stopDetail } : {}),
289
+ },
290
+ };
291
+ }
292
+ break outer;
293
+ }
294
+ const reportedUsage = normalizedUsage(completion?.usage);
295
+ addUsage(usage, reportedUsage, completion?.usage !== undefined);
296
+ const speakerTokens = (tokensBySpeaker.get(speaker.id) ?? 0) +
297
+ (reportedUsage.totalTokens ?? 0);
298
+ tokensBySpeaker.set(speaker.id, speakerTokens);
299
+ const turnCostUsd = this.options.estimateCostUsd
300
+ ? normalizedCost(this.options.estimateCostUsd({
301
+ speaker,
302
+ usage: reportedUsage,
303
+ aggregateUsage: { ...usage },
304
+ round,
305
+ turn,
306
+ }))
307
+ : undefined;
308
+ if (turnCostUsd !== undefined)
309
+ estimatedCostUsd += turnCostUsd;
310
+ turnsCompleted = turn;
311
+ roundsCompleted = round;
312
+ const message = {
313
+ speakerId: speaker.id,
314
+ speakerName: speaker.name,
315
+ content: output,
316
+ round,
317
+ turn,
318
+ createdAt: turnPayload.createdAt,
319
+ };
320
+ history.push(message);
321
+ if (history.length > this.historyLimit)
322
+ history.shift();
323
+ yield {
324
+ type: 'turn_completed',
325
+ runId: turnId,
326
+ content: output,
327
+ payload: {
328
+ ...turnPayload,
329
+ usage: reportedUsage,
330
+ aggregateUsage: { ...usage },
331
+ latencyMs: completion?.latencyMs ?? elapsedMilliseconds(turnStartedAt),
332
+ ...(completion?.model ? { model: completion.model } : {}),
333
+ ...(completion?.finishReason
334
+ ? { finishReason: completion.finishReason }
335
+ : {}),
336
+ ...(turnCostUsd !== undefined
337
+ ? { estimatedCostUsd: turnCostUsd }
338
+ : {}),
339
+ ...(this.options.estimateCostUsd
340
+ ? { totalEstimatedCostUsd: estimatedCostUsd }
341
+ : {}),
342
+ },
343
+ };
344
+ if (this.maxTokens !== undefined &&
345
+ usage.totalTokens >= this.maxTokens) {
346
+ stopReason = 'token_budget';
347
+ stopDetail = `Session token budget reached: ${this.maxTokens}`;
348
+ break outer;
349
+ }
350
+ const speakerBudget = this.maxTokensBySpeaker[speaker.id];
351
+ if (speakerBudget !== undefined && speakerTokens >= speakerBudget) {
352
+ stopReason = 'token_budget';
353
+ stopDetail = `Token budget reached for speaker ${speaker.id}: ${speakerBudget}`;
354
+ break outer;
355
+ }
356
+ if (this.maxCostUsd !== undefined &&
357
+ estimatedCostUsd >= this.maxCostUsd) {
358
+ stopReason = 'cost_budget';
359
+ stopDetail = `Session cost budget reached: $${this.maxCostUsd}`;
360
+ break outer;
361
+ }
362
+ if (input.stopSignal?.aborted) {
363
+ stopReason = 'stopped';
364
+ stopDetail = cooperativeStopMessage(input.stopSignal);
365
+ break outer;
366
+ }
367
+ stopMarker = matchingMarker(output, this.stopMarkers);
368
+ if (stopMarker) {
369
+ stopReason = 'marker';
370
+ stopDetail = `Matched stop marker: ${stopMarker}`;
371
+ break outer;
372
+ }
373
+ if (this.options.stopWhen) {
374
+ try {
375
+ const decision = await this.options.stopWhen({
376
+ sessionId,
377
+ speaker,
378
+ round,
379
+ turn,
380
+ history: cloneHistory(history),
381
+ latest: { ...message },
382
+ usage: { ...usage },
383
+ });
384
+ if (decision === true ||
385
+ (typeof decision === 'object' && decision.stop)) {
386
+ stopReason = 'predicate';
387
+ stopDetail =
388
+ typeof decision === 'object' ? decision.detail : undefined;
389
+ break outer;
390
+ }
391
+ }
392
+ catch (error) {
393
+ stopReason = 'failed';
394
+ stopDetail = errorMessage(error);
395
+ yield {
396
+ type: 'session_failed',
397
+ runId: sessionId,
398
+ content: stopDetail,
399
+ payload: {
400
+ ...turnPayload,
401
+ reason: 'failed',
402
+ detail: stopDetail,
403
+ },
404
+ };
405
+ break outer;
406
+ }
407
+ }
408
+ }
409
+ }
410
+ stopReason ??= sessionTimeout.timedOut
411
+ ? 'timeout'
412
+ : input.signal?.aborted
413
+ ? 'aborted'
414
+ : input.stopSignal?.aborted
415
+ ? 'stopped'
416
+ : 'max_rounds';
417
+ if (stopReason === 'aborted') {
418
+ stopDetail ??= input.signal ? abortMessage(input.signal) : undefined;
419
+ }
420
+ yield {
421
+ type: 'stop_reason',
422
+ runId: sessionId,
423
+ payload: {
424
+ sessionId,
425
+ reason: stopReason,
426
+ ...(stopDetail ? { detail: stopDetail } : {}),
427
+ ...(stopMarker ? { marker: stopMarker } : {}),
428
+ round: roundsCompleted,
429
+ turn: turnsCompleted,
430
+ },
431
+ };
432
+ const status = stopReason === 'aborted'
433
+ ? 'aborted'
434
+ : stopReason === 'failed' || stopReason === 'timeout'
435
+ ? 'failed'
436
+ : 'completed';
437
+ const result = {
438
+ sessionId,
439
+ status,
440
+ stopReason,
441
+ stopDetail,
442
+ roundsCompleted,
443
+ turnsCompleted,
444
+ history: cloneHistory(history),
445
+ usage: { ...usage },
446
+ ...(this.options.estimateCostUsd ? { estimatedCostUsd } : {}),
447
+ durationMs: elapsedMilliseconds(startedAt),
448
+ };
449
+ onResult?.(result);
450
+ yield {
451
+ type: 'session_completed',
452
+ runId: sessionId,
453
+ payload: {
454
+ sessionId,
455
+ status,
456
+ stopReason,
457
+ ...(stopDetail ? { stopDetail } : {}),
458
+ roundsCompleted,
459
+ turnsCompleted,
460
+ usage: { ...usage },
461
+ ...(this.options.estimateCostUsd ? { estimatedCostUsd } : {}),
462
+ durationMs: result.durationMs,
463
+ },
464
+ };
465
+ }
466
+ finally {
467
+ sessionTimeout.dispose();
468
+ }
469
+ }
470
+ async *recordEvents(events) {
471
+ let sequence = 0;
472
+ for await (const event of events) {
473
+ sequence += 1;
474
+ if (this.options.eventSink) {
475
+ const sessionId = event.payload.sessionId;
476
+ const record = {
477
+ eventId: `${sessionId}:${sequence}`,
478
+ tenantId: this.tenant.tenantId,
479
+ sessionId,
480
+ sequence,
481
+ occurredAt: this.clock().toISOString(),
482
+ event: structuredClone(event),
483
+ };
484
+ try {
485
+ await this.options.eventSink.append(record);
486
+ }
487
+ catch (error) {
488
+ if (this.sinkFailureMode === 'required') {
489
+ throw new AgentPlatError('ADAPTER_ERROR', `Session event sink failed: ${errorMessage(error)}`);
490
+ }
491
+ }
492
+ }
493
+ yield event;
494
+ }
495
+ }
496
+ agentDefinition(speaker, platform = speaker.platform) {
497
+ const now = this.clock().toISOString();
498
+ return {
499
+ id: speaker.id,
500
+ tenantId: this.tenant.tenantId,
501
+ name: speaker.name,
502
+ description: speaker.description,
503
+ instructions: speaker.instructions,
504
+ platform,
505
+ modelName: speaker.modelName,
506
+ config: speaker.config,
507
+ metadata: speaker.metadata,
508
+ createdAt: now,
509
+ updatedAt: now,
510
+ };
511
+ }
512
+ buildInput(context) {
513
+ return (this.options.buildInput?.({
514
+ ...context,
515
+ history: cloneHistory(context.history),
516
+ }) ?? defaultInput(context));
517
+ }
518
+ }
519
+ /** Create a reusable ephemeral multi-agent session. */
520
+ export function createMultiAgentSession(options) {
521
+ return new MultiAgentSession(options);
522
+ }
523
+ /** Format a bounded transcript without making its presentation a prompt DSL. */
524
+ export function formatSessionTranscript(history) {
525
+ return history.length
526
+ ? history
527
+ .map((message) => `${message.speakerName}: ${message.content}`)
528
+ .join('\n')
529
+ : '(no previous turns)';
530
+ }
531
+ /** Build a generic, overwriteable input mapper for persona-based turns. */
532
+ export function createPersonaInputBuilder(options = {}) {
533
+ return (context) => {
534
+ const persona = options.personas?.[context.speaker.id];
535
+ const scenario = typeof context.scenario === 'string'
536
+ ? context.scenario
537
+ : JSON.stringify(context.scenario);
538
+ const transcript = formatSessionTranscript(context.history);
539
+ const turnTemplate = context.history.length === 0
540
+ ? (options.openingTurn ??
541
+ 'Open the conversation in character and move the scenario forward.')
542
+ : (options.replyTurn ??
543
+ 'Reply to the conversation so far in character and move it forward.');
544
+ return {
545
+ input: [
546
+ {
547
+ role: 'user',
548
+ content: [
549
+ `Scenario:\n${scenario}`,
550
+ formatPersona(context.speaker, persona),
551
+ `Conversation so far:\n${transcript}`,
552
+ `Your turn:\n${turnTemplate}`,
553
+ ].join('\n\n'),
554
+ },
555
+ ],
556
+ mode: 'chat',
557
+ metadata: {
558
+ ...(context.metadata ?? {}),
559
+ sessionId: context.sessionId,
560
+ speakerId: context.speaker.id,
561
+ round: context.round,
562
+ turn: context.turn,
563
+ },
564
+ };
565
+ };
566
+ }
567
+ /** Create a pure reducer that maps streamed events into UI-friendly state. */
568
+ export function createSessionEventReducer() {
569
+ return { initialState: emptySessionViewState(), reduce: reduceSessionEvent };
570
+ }
571
+ /** Convert a completed session into stable numeric metrics for any backend. */
572
+ export function sessionMetrics(result) {
573
+ return {
574
+ inputTokens: result.usage.inputTokens,
575
+ outputTokens: result.usage.outputTokens,
576
+ totalTokens: result.usage.totalTokens,
577
+ reportedTurns: result.usage.reportedTurns,
578
+ turnsCompleted: result.turnsCompleted,
579
+ durationMs: result.durationMs,
580
+ estimatedCostUsd: result.estimatedCostUsd ?? 0,
581
+ };
582
+ }
583
+ /** Export reducer state as lossless session history for a later invocation. */
584
+ export function exportSessionHistory(state) {
585
+ return state.turnOrder.flatMap((turnId) => {
586
+ const turn = state.turns[turnId];
587
+ if (!turn || turn.status !== 'completed')
588
+ return [];
589
+ return [
590
+ {
591
+ speakerId: turn.speaker.id,
592
+ speakerName: turn.speaker.name,
593
+ content: turn.content,
594
+ round: turn.round,
595
+ turn: turn.turn,
596
+ createdAt: turn.createdAt,
597
+ },
598
+ ];
599
+ });
600
+ }
601
+ export function defineSpeaker(input) {
602
+ required(input.id, 'speaker.id');
603
+ required(input.name, 'speaker.name');
604
+ required(input.platform, 'speaker.platform');
605
+ const instructions = input.instructions?.trim() || input.role?.trim();
606
+ required(instructions, 'speaker.instructions or speaker.role');
607
+ return {
608
+ speaker: {
609
+ id: input.id,
610
+ name: input.name,
611
+ instructions,
612
+ platform: input.platform,
613
+ ...(input.description ? { description: input.description } : {}),
614
+ ...(input.modelName ? { modelName: input.modelName } : {}),
615
+ ...(input.config ? { config: structuredClone(input.config) } : {}),
616
+ ...(input.metadata ? { metadata: structuredClone(input.metadata) } : {}),
617
+ },
618
+ persona: {
619
+ ...(input.role ? { role: input.role } : {}),
620
+ ...(input.goals ? { goals: [...input.goals] } : {}),
621
+ ...(input.constraints ? { constraints: [...input.constraints] } : {}),
622
+ ...(input.peerDescription
623
+ ? { peerDescription: input.peerDescription }
624
+ : {}),
625
+ },
626
+ };
627
+ }
628
+ /** Format a structured scenario consistently before it becomes a session prompt. */
629
+ export function buildScenarioInput(input) {
630
+ required(input.topic, 'scenario.topic');
631
+ const parts = [
632
+ input.title ? `Scenario: ${input.title}` : undefined,
633
+ `Topic:\n${input.topic}`,
634
+ input.metadata && Object.keys(input.metadata).length
635
+ ? `Metadata:\n${JSON.stringify(input.metadata)}`
636
+ : undefined,
637
+ ].filter((part) => Boolean(part));
638
+ return parts.join('\n\n');
639
+ }
640
+ function validateSpeakers(speakers) {
641
+ if (!Array.isArray(speakers) || speakers.length < 2) {
642
+ throw new AgentPlatError('VALIDATION_ERROR', 'A multi-agent session requires at least two speakers');
643
+ }
644
+ const ids = new Set();
645
+ return speakers.map((speaker, index) => {
646
+ required(speaker.id, `speakers[${index}].id`);
647
+ required(speaker.name, `speakers[${index}].name`);
648
+ required(speaker.instructions, `speakers[${index}].instructions`);
649
+ required(speaker.platform, `speakers[${index}].platform`);
650
+ if (ids.has(speaker.id)) {
651
+ throw new AgentPlatError('VALIDATION_ERROR', `Duplicate session speaker id: ${speaker.id}`);
652
+ }
653
+ ids.add(speaker.id);
654
+ return structuredClone(speaker);
655
+ });
656
+ }
657
+ function validateInput(input) {
658
+ if ((typeof input === 'string' && !input.trim()) ||
659
+ (Array.isArray(input) && input.length === 0)) {
660
+ throw new AgentPlatError('VALIDATION_ERROR', 'Session input is required');
661
+ }
662
+ }
663
+ function defaultInput(context) {
664
+ const scenario = typeof context.scenario === 'string'
665
+ ? context.scenario
666
+ : JSON.stringify(context.scenario);
667
+ const transcript = formatSessionTranscript(context.history);
668
+ return {
669
+ input: [
670
+ {
671
+ role: 'user',
672
+ content: [
673
+ `Scenario:\n${scenario}`,
674
+ `Conversation so far:\n${transcript}`,
675
+ `It is now your turn as ${context.speaker.name}. Respond in character.`,
676
+ ].join('\n\n'),
677
+ },
678
+ ],
679
+ mode: 'chat',
680
+ metadata: {
681
+ ...(context.metadata ?? {}),
682
+ sessionId: context.sessionId,
683
+ speakerId: context.speaker.id,
684
+ round: context.round,
685
+ turn: context.turn,
686
+ },
687
+ };
688
+ }
689
+ function speakerRef(speaker) {
690
+ return { id: speaker.id, name: speaker.name };
691
+ }
692
+ function emptyUsage() {
693
+ return {
694
+ inputTokens: 0,
695
+ outputTokens: 0,
696
+ totalTokens: 0,
697
+ reportedTurns: 0,
698
+ };
699
+ }
700
+ function emptySessionViewState() {
701
+ return {
702
+ status: 'idle',
703
+ speakers: [],
704
+ turns: {},
705
+ turnOrder: [],
706
+ usage: emptyUsage(),
707
+ totalLatencyMs: 0,
708
+ metrics: {
709
+ inputTokens: 0,
710
+ outputTokens: 0,
711
+ totalTokens: 0,
712
+ reportedTurns: 0,
713
+ turnsCompleted: 0,
714
+ durationMs: 0,
715
+ estimatedCostUsd: 0,
716
+ },
717
+ estimatedCostUsd: 0,
718
+ canSoftStop: false,
719
+ canResume: false,
720
+ isLive: false,
721
+ };
722
+ }
723
+ function reduceSessionEvent(state, event) {
724
+ const next = {
725
+ ...state,
726
+ speakers: [...state.speakers],
727
+ turns: { ...state.turns },
728
+ turnOrder: [...state.turnOrder],
729
+ usage: { ...state.usage },
730
+ };
731
+ switch (event.type) {
732
+ case 'session_started':
733
+ next.sessionId = event.payload.sessionId;
734
+ next.status = 'running';
735
+ next.speakers = event.payload.speakers.map((speaker) => ({ ...speaker }));
736
+ next.canSoftStop = true;
737
+ next.canResume = false;
738
+ next.isLive = true;
739
+ return next;
740
+ case 'speaker_changed':
741
+ case 'turn_started': {
742
+ const { turnId, speaker, round, turn } = event.payload;
743
+ if (!next.turns[turnId]) {
744
+ next.turns[turnId] = {
745
+ turnId,
746
+ speaker: { ...speaker },
747
+ round,
748
+ turn,
749
+ createdAt: event.payload.createdAt,
750
+ content: '',
751
+ status: 'running',
752
+ };
753
+ next.turnOrder.push(turnId);
754
+ }
755
+ next.activeTurnId = turnId;
756
+ return next;
757
+ }
758
+ case 'token': {
759
+ const current = ensureTurn(next, event.payload);
760
+ current.content += event.content;
761
+ return next;
762
+ }
763
+ case 'turn_completed': {
764
+ const current = ensureTurn(next, event.payload);
765
+ current.content = event.content;
766
+ current.status = 'completed';
767
+ current.usage = { ...event.payload.usage };
768
+ current.latencyMs = event.payload.latencyMs;
769
+ current.model = event.payload.model;
770
+ current.finishReason = event.payload.finishReason;
771
+ next.totalLatencyMs += event.payload.latencyMs;
772
+ if (next.activeTurnId === current.turnId)
773
+ next.activeTurnId = undefined;
774
+ next.usage = { ...event.payload.aggregateUsage };
775
+ next.metrics = metricsFromState(next);
776
+ next.estimatedCostUsd =
777
+ event.payload.totalEstimatedCostUsd ?? next.estimatedCostUsd;
778
+ next.metrics.estimatedCostUsd = next.estimatedCostUsd;
779
+ next.canResume = true;
780
+ return next;
781
+ }
782
+ case 'turn_failed':
783
+ return next;
784
+ case 'provider_fallback':
785
+ return next;
786
+ case 'session_failed': {
787
+ const current = ensureTurn(next, event.payload);
788
+ current.status = 'failed';
789
+ current.content ||= event.content;
790
+ if (next.activeTurnId === current.turnId)
791
+ next.activeTurnId = undefined;
792
+ next.status = 'failed';
793
+ next.stopDetail = event.content;
794
+ next.canSoftStop = false;
795
+ next.canResume = next.turnOrder.some((turnId) => next.turns[turnId]?.status === 'completed');
796
+ next.isLive = false;
797
+ return next;
798
+ }
799
+ case 'stop_reason':
800
+ next.stopReason = event.payload.reason;
801
+ next.stopDetail = event.payload.detail;
802
+ return next;
803
+ case 'session_completed':
804
+ next.sessionId = event.payload.sessionId;
805
+ next.usage = { ...event.payload.usage };
806
+ next.stopReason = event.payload.stopReason;
807
+ next.stopDetail = event.payload.stopDetail;
808
+ next.durationMs = event.payload.durationMs;
809
+ next.activeTurnId = undefined;
810
+ next.metrics = {
811
+ inputTokens: event.payload.usage.inputTokens,
812
+ outputTokens: event.payload.usage.outputTokens,
813
+ totalTokens: event.payload.usage.totalTokens,
814
+ reportedTurns: event.payload.usage.reportedTurns,
815
+ turnsCompleted: event.payload.turnsCompleted,
816
+ durationMs: event.payload.durationMs,
817
+ estimatedCostUsd: event.payload.estimatedCostUsd ?? 0,
818
+ };
819
+ next.estimatedCostUsd = event.payload.estimatedCostUsd ?? 0;
820
+ next.status = event.payload.status;
821
+ next.canSoftStop = false;
822
+ next.canResume = next.turnOrder.some((turnId) => next.turns[turnId]?.status === 'completed');
823
+ next.isLive = false;
824
+ return next;
825
+ case 'tool_call':
826
+ case 'tool_result':
827
+ return next;
828
+ }
829
+ }
830
+ function ensureTurn(state, payload) {
831
+ const current = state.turns[payload.turnId];
832
+ if (current)
833
+ return current;
834
+ const created = {
835
+ turnId: payload.turnId,
836
+ speaker: { ...payload.speaker },
837
+ round: payload.round,
838
+ turn: payload.turn,
839
+ createdAt: payload.createdAt,
840
+ content: '',
841
+ status: 'running',
842
+ };
843
+ state.turns[payload.turnId] = created;
844
+ state.turnOrder.push(payload.turnId);
845
+ return created;
846
+ }
847
+ function metricsFromState(state) {
848
+ return {
849
+ inputTokens: state.usage.inputTokens,
850
+ outputTokens: state.usage.outputTokens,
851
+ totalTokens: state.usage.totalTokens,
852
+ reportedTurns: state.usage.reportedTurns,
853
+ turnsCompleted: state.turnOrder.filter((turnId) => state.turns[turnId]?.status === 'completed').length,
854
+ durationMs: state.durationMs ?? state.totalLatencyMs,
855
+ estimatedCostUsd: state.estimatedCostUsd,
856
+ };
857
+ }
858
+ function normalizeFallbackPlatform(value) {
859
+ if (!value)
860
+ return undefined;
861
+ const platform = typeof value === 'string' ? value : value.platform;
862
+ required(platform, 'fallbackPlatform');
863
+ return platform.trim();
864
+ }
865
+ function optionalPositiveNumber(value, name) {
866
+ if (value === undefined)
867
+ return undefined;
868
+ if (!Number.isFinite(value) || value <= 0) {
869
+ throw new AgentPlatError('VALIDATION_ERROR', `${name} must be positive`);
870
+ }
871
+ return value;
872
+ }
873
+ function normalizeSpeakerBudgets(budgets) {
874
+ if (!budgets)
875
+ return {};
876
+ return Object.fromEntries(Object.entries(budgets).map(([speakerId, budget]) => [
877
+ speakerId,
878
+ optionalPositiveNumber(budget, `maxTokensBySpeaker.${speakerId}`),
879
+ ]));
880
+ }
881
+ function normalizedCost(value) {
882
+ if (!Number.isFinite(value) || value < 0) {
883
+ throw new AgentPlatError('VALIDATION_ERROR', 'estimateCostUsd must return a non-negative finite number');
884
+ }
885
+ return value;
886
+ }
887
+ function formatPersona(speaker, persona) {
888
+ const details = [
889
+ `You are ${speaker.name}.`,
890
+ speaker.instructions ? `Instructions: ${speaker.instructions}` : undefined,
891
+ persona?.role ? `Role: ${persona.role}` : undefined,
892
+ persona?.goals?.length
893
+ ? `Private goals: ${persona.goals.join('; ')}`
894
+ : undefined,
895
+ persona?.constraints?.length
896
+ ? `Private constraints: ${persona.constraints.join('; ')}`
897
+ : undefined,
898
+ persona?.peerDescription ? `Peer: ${persona.peerDescription}` : undefined,
899
+ ].filter((part) => Boolean(part));
900
+ return details.join('\n');
901
+ }
902
+ function normalizedUsage(usage) {
903
+ return {
904
+ inputTokens: normalizedCount(usage?.inputTokens),
905
+ outputTokens: normalizedCount(usage?.outputTokens),
906
+ totalTokens: normalizedCount(usage?.totalTokens),
907
+ };
908
+ }
909
+ function addUsage(total, usage, providerReported) {
910
+ total.inputTokens += usage.inputTokens ?? 0;
911
+ total.outputTokens += usage.outputTokens ?? 0;
912
+ total.totalTokens += usage.totalTokens ?? 0;
913
+ if (providerReported)
914
+ total.reportedTurns += 1;
915
+ }
916
+ function normalizedCount(value) {
917
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0
918
+ ? value
919
+ : 0;
920
+ }
921
+ function matchingMarker(output, markers) {
922
+ const normalized = output.toLocaleLowerCase();
923
+ return markers.find((marker) => normalized.includes(marker.toLocaleLowerCase()));
924
+ }
925
+ function cloneHistory(history) {
926
+ return history.map((message) => ({ ...message }));
927
+ }
928
+ function elapsedMilliseconds(startedAt) {
929
+ return Math.max(0, Math.round((performance.now() - startedAt) * 100) / 100);
930
+ }
931
+ function abortMessage(signal) {
932
+ return signal.reason instanceof Error
933
+ ? signal.reason.message
934
+ : 'Session aborted';
935
+ }
936
+ function cooperativeStopMessage(signal) {
937
+ return signal.reason instanceof Error
938
+ ? signal.reason.message
939
+ : 'Session stop requested';
940
+ }
941
+ function optionalTimeout(value, field) {
942
+ if (value === undefined)
943
+ return undefined;
944
+ return positiveInteger(value, field, 86_400_000);
945
+ }
946
+ function createTimeout(timeoutMs, message) {
947
+ if (timeoutMs === undefined) {
948
+ return { signal: undefined, timedOut: false, dispose() { } };
949
+ }
950
+ const controller = new AbortController();
951
+ const state = {
952
+ timedOut: false,
953
+ signal: controller.signal,
954
+ dispose() {
955
+ clearTimeout(timer);
956
+ },
957
+ };
958
+ const timer = setTimeout(() => {
959
+ state.timedOut = true;
960
+ controller.abort(new Error(message));
961
+ }, timeoutMs);
962
+ return state;
963
+ }
964
+ function combineSignals(...signals) {
965
+ const controller = new AbortController();
966
+ const listeners = [];
967
+ const abort = (signal) => {
968
+ if (!controller.signal.aborted)
969
+ controller.abort(signal.reason);
970
+ };
971
+ for (const signal of signals) {
972
+ if (!signal)
973
+ continue;
974
+ if (signal.aborted) {
975
+ abort(signal);
976
+ continue;
977
+ }
978
+ const listener = () => abort(signal);
979
+ signal.addEventListener('abort', listener, { once: true });
980
+ listeners.push([signal, listener]);
981
+ }
982
+ return {
983
+ signal: controller.signal,
984
+ dispose() {
985
+ for (const [signal, listener] of listeners) {
986
+ signal.removeEventListener('abort', listener);
987
+ }
988
+ },
989
+ };
990
+ }
991
+ function errorMessage(error) {
992
+ return error instanceof Error ? error.message : 'Session execution failed';
993
+ }
994
+ function required(value, field) {
995
+ if (typeof value !== 'string' || !value.trim()) {
996
+ throw new AgentPlatError('VALIDATION_ERROR', `${field} is required`);
997
+ }
998
+ }
999
+ function positiveInteger(value, field, maximum) {
1000
+ if (!Number.isInteger(value) || value < 1 || value > maximum) {
1001
+ throw new AgentPlatError('VALIDATION_ERROR', `${field} must be an integer between 1 and ${maximum}`);
1002
+ }
1003
+ return value;
1004
+ }
1005
+ //# sourceMappingURL=index.js.map