openbot 0.4.4 → 0.4.5

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/app/cli.js CHANGED
@@ -16,7 +16,7 @@ function checkNodeVersion() {
16
16
  }
17
17
  }
18
18
  checkNodeVersion();
19
- program.name('openbot').description('OpenBot CLI').version('0.4.4');
19
+ program.name('openbot').description('OpenBot CLI').version('0.4.5');
20
20
  program
21
21
  .command('start')
22
22
  .description('Start the OpenBot harness')
@@ -0,0 +1,48 @@
1
+ import { ORCHESTRATOR_AGENT_ID } from './agent-ids.js';
2
+ import { storageService } from '../plugins/storage/service.js';
3
+ /** Thread `state.json` key for the sticky responding agent id. */
4
+ export const THREAD_RESPONDING_AGENT_ID_KEY = 'respondingAgentId';
5
+ const readBoundAgentId = (state) => {
6
+ if (!state || typeof state !== 'object')
7
+ return undefined;
8
+ const value = state[THREAD_RESPONDING_AGENT_ID_KEY];
9
+ if (typeof value !== 'string')
10
+ return undefined;
11
+ const trimmed = value.trim();
12
+ return trimmed || undefined;
13
+ };
14
+ /**
15
+ * Resolves which agent should handle a thread-scoped publish.
16
+ * Once `respondingAgentId` is stored on the thread, that value wins over request overrides.
17
+ */
18
+ export async function resolveRespondingAgentId(options) {
19
+ const { channelId, threadId, requestedAgentId, bindIfUnbound = false } = options;
20
+ const requested = requestedAgentId?.trim() || undefined;
21
+ const fallback = requested || ORCHESTRATOR_AGENT_ID;
22
+ if (!threadId) {
23
+ return { agentId: fallback, bound: false, overridden: false };
24
+ }
25
+ const details = await storageService.getThreadDetails({ channelId, threadId });
26
+ const bound = readBoundAgentId(details.state);
27
+ if (bound) {
28
+ const overridden = !!requested && requested !== bound;
29
+ if (overridden) {
30
+ console.warn('[publish] Ignoring agentId override; thread is bound to responding agent', {
31
+ threadId,
32
+ bound,
33
+ requested,
34
+ });
35
+ }
36
+ return { agentId: bound, bound: true, overridden };
37
+ }
38
+ if (!bindIfUnbound) {
39
+ return { agentId: fallback, bound: false, overridden: false };
40
+ }
41
+ await storageService.getAgentDetails({ agentId: fallback });
42
+ await storageService.patchThreadState({
43
+ channelId,
44
+ threadId,
45
+ state: { [THREAD_RESPONDING_AGENT_ID_KEY]: fallback },
46
+ });
47
+ return { agentId: fallback, bound: true, overridden: false };
48
+ }
@@ -16,6 +16,7 @@ import { storageService } from '../plugins/storage/service.js';
16
16
  import { buildWorkspaceFileUrl, getPublicBaseUrl, openChannelFileStream, } from '../plugins/storage/files.js';
17
17
  import { ensureEventId, openBotEventFromQuery } from './utils.js';
18
18
  import { abortRegistry, abortKey } from '../services/abort.js';
19
+ import { resolveRespondingAgentId } from './responding-agent.js';
19
20
  export async function startServer(options = {}) {
20
21
  const publishEventSchema = z
21
22
  .object({
@@ -116,18 +117,58 @@ export async function startServer(options = {}) {
116
117
  data: { channels },
117
118
  };
118
119
  };
120
+ const broadcastActiveRunsSnapshot = () => {
121
+ const snapshot = buildActiveRunsSnapshot();
122
+ ensureEventId(snapshot);
123
+ sendToClientKey(GLOBAL_CHANNEL_ID, snapshot);
124
+ };
125
+ const persistLifecycleEvent = (event, targetChannelId, targetThreadId) => {
126
+ ensureEventId(event);
127
+ storageService
128
+ .storeEvent({
129
+ channelId: targetChannelId,
130
+ threadId: targetThreadId,
131
+ event,
132
+ })
133
+ .catch((error) => {
134
+ console.error('[server] Failed to persist lifecycle event', {
135
+ type: event.type,
136
+ channelId: targetChannelId,
137
+ threadId: targetThreadId,
138
+ error,
139
+ });
140
+ });
141
+ };
119
142
  // Drop every tracked run for a channel/thread. A stop aborts the whole
120
143
  // chain (parent + delegated sub-agents), but the sub-agents' `agent:run:end`
121
144
  // events can be swallowed when the parent run loop breaks on abort, leaving
122
- // orphaned entries that keep a channel falsely "active". Purging by
123
- // channel/thread guarantees the snapshot self-heals after a stop.
145
+ // orphaned entries that keep a channel falsely "active". Emit explicit
146
+ // `agent:run:end` for each purged run and refresh the global snapshot so
147
+ // clients stay in sync even when the parent harness stops yielding.
124
148
  const purgeActiveRunsForThread = (channelId, threadId) => {
125
149
  const target = threadId || undefined;
150
+ const removed = [];
126
151
  for (const [key, run] of activeRuns) {
127
152
  if (run.channelId === channelId && (run.threadId || undefined) === target) {
153
+ removed.push(run);
128
154
  activeRuns.delete(key);
129
155
  }
130
156
  }
157
+ for (const run of removed) {
158
+ const endEvent = {
159
+ type: 'agent:run:end',
160
+ data: {
161
+ runId: run.runId,
162
+ agentId: run.agentId,
163
+ channelId: run.channelId,
164
+ threadId: run.threadId,
165
+ },
166
+ };
167
+ ensureEventId(endEvent);
168
+ persistLifecycleEvent(endEvent, run.channelId, run.threadId);
169
+ sendToClientKey(getClientKey(run.channelId, run.threadId), endEvent);
170
+ sendToClientKey(GLOBAL_CHANNEL_ID, endEvent);
171
+ }
131
172
  };
132
173
  // Support for Chrome's Private Network Access (PNA)
133
174
  // https://developer.chrome.com/blog/private-network-access-preflight/
@@ -328,19 +369,38 @@ export async function startServer(options = {}) {
328
369
  const data = (event.data ?? {});
329
370
  const targetChannelId = data.channelId || channelId;
330
371
  const targetThreadId = data.threadId || threadId;
372
+ let resolvedStopAgentId = data.agentId || agentId || ORCHESTRATOR_AGENT_ID;
373
+ try {
374
+ const resolved = await resolveRespondingAgentId({
375
+ channelId: targetChannelId,
376
+ threadId: targetThreadId,
377
+ requestedAgentId: data.agentId || agentId,
378
+ });
379
+ resolvedStopAgentId = resolved.agentId;
380
+ }
381
+ catch (error) {
382
+ console.warn('[publish] Failed to resolve responding agent for stop request', {
383
+ channelId: targetChannelId,
384
+ threadId: targetThreadId,
385
+ error,
386
+ });
387
+ }
331
388
  const stopped = abortRegistry.abort(abortKey(targetChannelId, targetThreadId));
332
389
  purgeActiveRunsForThread(targetChannelId, targetThreadId);
390
+ // Resync global clients even when nothing was tracked server-side.
391
+ broadcastActiveRunsSnapshot();
333
392
  const stoppedEvent = {
334
393
  type: 'agent:run:stopped',
335
394
  data: {
336
395
  runId: data.runId || runId,
337
- agentId: data.agentId || agentId || ORCHESTRATOR_AGENT_ID,
396
+ agentId: resolvedStopAgentId,
338
397
  channelId: targetChannelId,
339
398
  threadId: targetThreadId,
340
399
  reason: data.reason,
341
400
  },
342
401
  };
343
402
  ensureEventId(stoppedEvent);
403
+ persistLifecycleEvent(stoppedEvent, targetChannelId, targetThreadId);
344
404
  sendToClientKey(getClientKey(targetChannelId, targetThreadId), stoppedEvent);
345
405
  sendToClientKey(GLOBAL_CHANNEL_ID, stoppedEvent);
346
406
  res.json({ success: stopped });
@@ -363,6 +423,7 @@ export async function startServer(options = {}) {
363
423
  }
364
424
  else if (chunk.type === 'agent:run:stopped') {
365
425
  purgeActiveRunsForThread(chunk.data.channelId, chunk.data.threadId);
426
+ broadcastActiveRunsSnapshot();
366
427
  }
367
428
  sendToClientKey(targetClientKey, chunk);
368
429
  if (chunk.type === 'agent:run:start' ||
@@ -373,9 +434,16 @@ export async function startServer(options = {}) {
373
434
  };
374
435
  try {
375
436
  ensureEventId(event);
437
+ const bindIfUnbound = event.type === 'agent:invoke';
438
+ const resolved = await resolveRespondingAgentId({
439
+ channelId,
440
+ threadId,
441
+ requestedAgentId: agentId,
442
+ bindIfUnbound,
443
+ });
376
444
  await runAgent({
377
445
  runId,
378
- agentId: agentId || ORCHESTRATOR_AGENT_ID,
446
+ agentId: resolved.agentId,
379
447
  event,
380
448
  channelId,
381
449
  threadId,
@@ -385,6 +453,11 @@ export async function startServer(options = {}) {
385
453
  res.sendStatus(200);
386
454
  }
387
455
  catch (error) {
456
+ const message = error instanceof Error ? error.message : String(error);
457
+ const isUnknownAgent = (error instanceof Error &&
458
+ (error.code === 'AGENT_NOT_FOUND' ||
459
+ error.message.includes('does not exist'))) ||
460
+ message.includes('does not exist');
388
461
  console.error('[publish] Failed to dispatch event', {
389
462
  runId,
390
463
  channelId,
@@ -392,6 +465,10 @@ export async function startServer(options = {}) {
392
465
  eventType: event.type,
393
466
  error,
394
467
  });
468
+ if (isUnknownAgent) {
469
+ res.status(400).json({ error: message });
470
+ return;
471
+ }
395
472
  res.status(500).json({ error: 'Failed to process publish event' });
396
473
  }
397
474
  });
@@ -406,6 +483,13 @@ export async function startServer(options = {}) {
406
483
  return;
407
484
  }
408
485
  const { channelId, threadId, agentId, runId } = getContext(req);
486
+ // In-memory active runs (not persisted). Mirrors the initial SSE frame on __global__.
487
+ if (channelId === GLOBAL_CHANNEL_ID && event.type === 'action:storage:get-active-runs') {
488
+ const snapshot = buildActiveRunsSnapshot();
489
+ ensureEventId(snapshot);
490
+ res.json({ events: [snapshot] });
491
+ return;
492
+ }
409
493
  if (event.type === 'action:storage:serve-file') {
410
494
  const filePath = event.data?.path;
411
495
  if (!channelId?.trim()) {
@@ -614,7 +614,7 @@ export const storageService = {
614
614
  id: threadId,
615
615
  name: threadName || threadId,
616
616
  channelId,
617
- state,
617
+ state: (isRecord(state) ? state : {}),
618
618
  };
619
619
  },
620
620
  getChannelDetails: async ({ channelId }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openbot",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
package/src/app/cli.ts CHANGED
@@ -25,7 +25,7 @@ function checkNodeVersion() {
25
25
 
26
26
  checkNodeVersion();
27
27
 
28
- program.name('openbot').description('OpenBot CLI').version('0.4.4');
28
+ program.name('openbot').description('OpenBot CLI').version('0.4.5');
29
29
 
30
30
  program
31
31
  .command('start')
@@ -0,0 +1,74 @@
1
+ import { ORCHESTRATOR_AGENT_ID } from './agent-ids.js';
2
+ import { storageService } from '../plugins/storage/service.js';
3
+
4
+ /** Thread `state.json` key for the sticky responding agent id. */
5
+ export const THREAD_RESPONDING_AGENT_ID_KEY = 'respondingAgentId';
6
+
7
+ export type ResolveRespondingAgentOptions = {
8
+ channelId: string;
9
+ threadId?: string;
10
+ requestedAgentId?: string;
11
+ /** When true, persist `respondingAgentId` on the first unbound thread touch. */
12
+ bindIfUnbound?: boolean;
13
+ };
14
+
15
+ export type ResolveRespondingAgentResult = {
16
+ agentId: string;
17
+ /** True when the thread already had or now has a persisted responding agent. */
18
+ bound: boolean;
19
+ /** True when the request asked for a different agent than the bound one. */
20
+ overridden: boolean;
21
+ };
22
+
23
+ const readBoundAgentId = (state: unknown): string | undefined => {
24
+ if (!state || typeof state !== 'object') return undefined;
25
+ const value = (state as Record<string, unknown>)[THREAD_RESPONDING_AGENT_ID_KEY];
26
+ if (typeof value !== 'string') return undefined;
27
+ const trimmed = value.trim();
28
+ return trimmed || undefined;
29
+ };
30
+
31
+ /**
32
+ * Resolves which agent should handle a thread-scoped publish.
33
+ * Once `respondingAgentId` is stored on the thread, that value wins over request overrides.
34
+ */
35
+ export async function resolveRespondingAgentId(
36
+ options: ResolveRespondingAgentOptions,
37
+ ): Promise<ResolveRespondingAgentResult> {
38
+ const { channelId, threadId, requestedAgentId, bindIfUnbound = false } = options;
39
+ const requested = requestedAgentId?.trim() || undefined;
40
+ const fallback = requested || ORCHESTRATOR_AGENT_ID;
41
+
42
+ if (!threadId) {
43
+ return { agentId: fallback, bound: false, overridden: false };
44
+ }
45
+
46
+ const details = await storageService.getThreadDetails({ channelId, threadId });
47
+ const bound = readBoundAgentId(details.state);
48
+
49
+ if (bound) {
50
+ const overridden = !!requested && requested !== bound;
51
+ if (overridden) {
52
+ console.warn('[publish] Ignoring agentId override; thread is bound to responding agent', {
53
+ threadId,
54
+ bound,
55
+ requested,
56
+ });
57
+ }
58
+ return { agentId: bound, bound: true, overridden };
59
+ }
60
+
61
+ if (!bindIfUnbound) {
62
+ return { agentId: fallback, bound: false, overridden: false };
63
+ }
64
+
65
+ await storageService.getAgentDetails({ agentId: fallback });
66
+
67
+ await storageService.patchThreadState({
68
+ channelId,
69
+ threadId,
70
+ state: { [THREAD_RESPONDING_AGENT_ID_KEY]: fallback },
71
+ });
72
+
73
+ return { agentId: fallback, bound: true, overridden: false };
74
+ }
package/src/app/server.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  } from '../plugins/storage/files.js';
22
22
  import { ensureEventId, openBotEventFromQuery } from './utils.js';
23
23
  import { abortRegistry, abortKey } from '../services/abort.js';
24
+ import { resolveRespondingAgentId } from './responding-agent.js';
24
25
 
25
26
  type Bucket = { channelId: string; threadId?: string; activeCount: number; agentIds: Set<string> };
26
27
 
@@ -150,18 +151,71 @@ export async function startServer(options: ServerOptions = {}) {
150
151
  };
151
152
  };
152
153
 
154
+ const broadcastActiveRunsSnapshot = (): void => {
155
+ const snapshot = buildActiveRunsSnapshot();
156
+ ensureEventId(snapshot);
157
+ sendToClientKey(GLOBAL_CHANNEL_ID, snapshot);
158
+ };
159
+
160
+ const persistLifecycleEvent = (
161
+ event: OpenBotEvent,
162
+ targetChannelId: string,
163
+ targetThreadId?: string,
164
+ ): void => {
165
+ ensureEventId(event);
166
+ storageService
167
+ .storeEvent({
168
+ channelId: targetChannelId,
169
+ threadId: targetThreadId,
170
+ event,
171
+ })
172
+ .catch((error) => {
173
+ console.error('[server] Failed to persist lifecycle event', {
174
+ type: event.type,
175
+ channelId: targetChannelId,
176
+ threadId: targetThreadId,
177
+ error,
178
+ });
179
+ });
180
+ };
181
+
153
182
  // Drop every tracked run for a channel/thread. A stop aborts the whole
154
183
  // chain (parent + delegated sub-agents), but the sub-agents' `agent:run:end`
155
184
  // events can be swallowed when the parent run loop breaks on abort, leaving
156
- // orphaned entries that keep a channel falsely "active". Purging by
157
- // channel/thread guarantees the snapshot self-heals after a stop.
185
+ // orphaned entries that keep a channel falsely "active". Emit explicit
186
+ // `agent:run:end` for each purged run and refresh the global snapshot so
187
+ // clients stay in sync even when the parent harness stops yielding.
158
188
  const purgeActiveRunsForThread = (channelId: string, threadId?: string): void => {
159
189
  const target = threadId || undefined;
190
+ const removed: Array<{
191
+ runId: string;
192
+ channelId: string;
193
+ threadId?: string;
194
+ agentId: string;
195
+ }> = [];
196
+
160
197
  for (const [key, run] of activeRuns) {
161
198
  if (run.channelId === channelId && (run.threadId || undefined) === target) {
199
+ removed.push(run);
162
200
  activeRuns.delete(key);
163
201
  }
164
202
  }
203
+
204
+ for (const run of removed) {
205
+ const endEvent: OpenBotEvent = {
206
+ type: 'agent:run:end',
207
+ data: {
208
+ runId: run.runId,
209
+ agentId: run.agentId,
210
+ channelId: run.channelId,
211
+ threadId: run.threadId,
212
+ },
213
+ } as OpenBotEvent;
214
+ ensureEventId(endEvent);
215
+ persistLifecycleEvent(endEvent, run.channelId, run.threadId);
216
+ sendToClientKey(getClientKey(run.channelId, run.threadId), endEvent);
217
+ sendToClientKey(GLOBAL_CHANNEL_ID, endEvent);
218
+ }
165
219
  };
166
220
 
167
221
  // Support for Chrome's Private Network Access (PNA)
@@ -401,20 +455,38 @@ export async function startServer(options: ServerOptions = {}) {
401
455
  };
402
456
  const targetChannelId = data.channelId || channelId;
403
457
  const targetThreadId = data.threadId || threadId;
458
+ let resolvedStopAgentId = data.agentId || agentId || ORCHESTRATOR_AGENT_ID;
459
+ try {
460
+ const resolved = await resolveRespondingAgentId({
461
+ channelId: targetChannelId,
462
+ threadId: targetThreadId,
463
+ requestedAgentId: data.agentId || agentId,
464
+ });
465
+ resolvedStopAgentId = resolved.agentId;
466
+ } catch (error) {
467
+ console.warn('[publish] Failed to resolve responding agent for stop request', {
468
+ channelId: targetChannelId,
469
+ threadId: targetThreadId,
470
+ error,
471
+ });
472
+ }
404
473
  const stopped = abortRegistry.abort(abortKey(targetChannelId, targetThreadId));
405
474
  purgeActiveRunsForThread(targetChannelId, targetThreadId);
475
+ // Resync global clients even when nothing was tracked server-side.
476
+ broadcastActiveRunsSnapshot();
406
477
 
407
478
  const stoppedEvent: OpenBotEvent = {
408
479
  type: 'agent:run:stopped',
409
480
  data: {
410
481
  runId: data.runId || runId,
411
- agentId: data.agentId || agentId || ORCHESTRATOR_AGENT_ID,
482
+ agentId: resolvedStopAgentId,
412
483
  channelId: targetChannelId,
413
484
  threadId: targetThreadId,
414
485
  reason: data.reason,
415
486
  },
416
487
  } as OpenBotEvent;
417
488
  ensureEventId(stoppedEvent);
489
+ persistLifecycleEvent(stoppedEvent, targetChannelId, targetThreadId);
418
490
  sendToClientKey(getClientKey(targetChannelId, targetThreadId), stoppedEvent);
419
491
  sendToClientKey(GLOBAL_CHANNEL_ID, stoppedEvent);
420
492
 
@@ -443,6 +515,7 @@ export async function startServer(options: ServerOptions = {}) {
443
515
  );
444
516
  } else if (chunk.type === 'agent:run:stopped') {
445
517
  purgeActiveRunsForThread(chunk.data.channelId, chunk.data.threadId);
518
+ broadcastActiveRunsSnapshot();
446
519
  }
447
520
 
448
521
  sendToClientKey(targetClientKey, chunk);
@@ -459,9 +532,17 @@ export async function startServer(options: ServerOptions = {}) {
459
532
  try {
460
533
  ensureEventId(event);
461
534
 
535
+ const bindIfUnbound = event.type === 'agent:invoke';
536
+ const resolved = await resolveRespondingAgentId({
537
+ channelId,
538
+ threadId,
539
+ requestedAgentId: agentId,
540
+ bindIfUnbound,
541
+ });
542
+
462
543
  await runAgent({
463
544
  runId,
464
- agentId: agentId || ORCHESTRATOR_AGENT_ID,
545
+ agentId: resolved.agentId,
465
546
  event,
466
547
  channelId,
467
548
  threadId,
@@ -470,6 +551,13 @@ export async function startServer(options: ServerOptions = {}) {
470
551
  });
471
552
  res.sendStatus(200);
472
553
  } catch (error) {
554
+ const message = error instanceof Error ? error.message : String(error);
555
+ const isUnknownAgent =
556
+ (error instanceof Error &&
557
+ ((error as Error & { code?: string }).code === 'AGENT_NOT_FOUND' ||
558
+ error.message.includes('does not exist'))) ||
559
+ message.includes('does not exist');
560
+
473
561
  console.error('[publish] Failed to dispatch event', {
474
562
  runId,
475
563
  channelId,
@@ -477,6 +565,12 @@ export async function startServer(options: ServerOptions = {}) {
477
565
  eventType: event.type,
478
566
  error,
479
567
  });
568
+
569
+ if (isUnknownAgent) {
570
+ res.status(400).json({ error: message });
571
+ return;
572
+ }
573
+
480
574
  res.status(500).json({ error: 'Failed to process publish event' });
481
575
  }
482
576
  });
@@ -493,6 +587,14 @@ export async function startServer(options: ServerOptions = {}) {
493
587
 
494
588
  const { channelId, threadId, agentId, runId } = getContext(req);
495
589
 
590
+ // In-memory active runs (not persisted). Mirrors the initial SSE frame on __global__.
591
+ if (channelId === GLOBAL_CHANNEL_ID && event.type === 'action:storage:get-active-runs') {
592
+ const snapshot = buildActiveRunsSnapshot();
593
+ ensureEventId(snapshot);
594
+ res.json({ events: [snapshot] });
595
+ return;
596
+ }
597
+
496
598
  if (event.type === 'action:storage:serve-file') {
497
599
  const filePath = (event.data as { path?: string })?.path;
498
600
  if (!channelId?.trim()) {
package/src/app/types.ts CHANGED
@@ -141,6 +141,10 @@ export type GetEventsResultEvent = BaseEvent & {
141
141
  };
142
142
  };
143
143
 
144
+ export type GetActiveRunsEvent = BaseEvent & {
145
+ type: 'action:storage:get-active-runs';
146
+ };
147
+
144
148
  export type GetAgentDetailsEvent = BaseEvent & {
145
149
  type: 'action:storage:get-agent-details';
146
150
  data: {
@@ -1059,6 +1063,7 @@ export type OpenBotEvent =
1059
1063
  | DeleteAgentResultEvent
1060
1064
  | GetEventsEvent
1061
1065
  | GetEventsResultEvent
1066
+ | GetActiveRunsEvent
1062
1067
  | StreamThreadEvent
1063
1068
  | GetVariablesEvent
1064
1069
  | GetVariablesResultEvent
@@ -24,6 +24,7 @@ import {
24
24
  PluginDescriptor,
25
25
  Thread,
26
26
  ThreadDetails,
27
+ ThreadState,
27
28
  } from '../../services/plugins/domain.js';
28
29
  import type { PluginRef } from '../../services/plugins/types.js';
29
30
  import { openbotPlugin } from '../openbot/index.js';
@@ -771,7 +772,7 @@ export const storageService = {
771
772
  id: threadId,
772
773
  name: threadName || threadId,
773
774
  channelId,
774
- state,
775
+ state: (isRecord(state) ? state : {}) as ThreadState,
775
776
  };
776
777
  },
777
778
  getChannelDetails: async ({ channelId }: { channelId: string }): Promise<ChannelDetails> => {
@@ -81,11 +81,25 @@ export type Thread = {
81
81
  hasUnseenMessages?: boolean;
82
82
  };
83
83
 
84
+ /** Persisted thread `state.json` fields (additional keys are allowed). */
85
+ export type ThreadState = {
86
+ name?: string;
87
+ /** Sticky agent id for this thread (`system` = orchestrator). Set once, then enforced on publish. */
88
+ respondingAgentId?: string;
89
+ pendingToolCallIds?: string[];
90
+ usage?: {
91
+ promptTokens?: number;
92
+ completionTokens?: number;
93
+ totalTokens?: number;
94
+ };
95
+ [key: string]: unknown;
96
+ };
97
+
84
98
  export type ThreadDetails = {
85
99
  id: string;
86
100
  name: string;
87
101
  channelId: string;
88
- state: unknown;
102
+ state: ThreadState;
89
103
  };
90
104
 
91
105
  export type ChannelDetails = {