openbot 0.3.6 → 0.4.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.
Files changed (96) hide show
  1. package/README.md +15 -16
  2. package/dist/app/agent-ids.js +4 -0
  3. package/dist/app/cli.js +1 -1
  4. package/dist/app/config.js +0 -19
  5. package/dist/app/server.js +8 -14
  6. package/dist/bus/services.js +34 -124
  7. package/dist/harness/agent-invoke-run.js +44 -0
  8. package/dist/harness/agent-turn.js +99 -0
  9. package/dist/harness/channel-participants.js +40 -0
  10. package/dist/harness/constants.js +2 -0
  11. package/dist/harness/context-meter.js +97 -0
  12. package/dist/harness/context.js +95 -47
  13. package/dist/harness/dispatch.js +144 -0
  14. package/dist/harness/dispatcher.js +45 -156
  15. package/dist/harness/history.js +177 -0
  16. package/dist/harness/index.js +91 -0
  17. package/dist/harness/orchestration.js +88 -0
  18. package/dist/harness/participants.js +22 -0
  19. package/dist/harness/run-harness.js +154 -0
  20. package/dist/harness/run.js +98 -0
  21. package/dist/harness/runtime-factory.js +0 -34
  22. package/dist/harness/runtime.js +57 -0
  23. package/dist/harness/todo-dispatch.js +51 -0
  24. package/dist/harness/todos.js +5 -0
  25. package/dist/harness/turn.js +79 -0
  26. package/dist/plugins/approval/index.js +105 -149
  27. package/dist/plugins/delegation/index.js +119 -32
  28. package/dist/plugins/memory/index.js +103 -14
  29. package/dist/plugins/memory/service.js +152 -0
  30. package/dist/plugins/openbot/context.js +80 -0
  31. package/dist/plugins/openbot/history.js +98 -0
  32. package/dist/plugins/openbot/index.js +31 -0
  33. package/dist/plugins/openbot/runtime.js +317 -0
  34. package/dist/plugins/openbot/system-prompt.js +5 -0
  35. package/dist/plugins/plugin-manager/index.js +105 -0
  36. package/dist/plugins/storage/index.js +573 -0
  37. package/dist/plugins/storage/service.js +1159 -0
  38. package/dist/plugins/storage-tools/index.js +2 -2
  39. package/dist/plugins/thread-namer/index.js +72 -0
  40. package/dist/plugins/thread-naming/generate-title.js +44 -0
  41. package/dist/plugins/thread-naming/index.js +103 -0
  42. package/dist/plugins/threads/index.js +114 -0
  43. package/dist/plugins/todo/index.js +24 -25
  44. package/dist/plugins/ui/index.js +2 -32
  45. package/dist/registry/plugins.js +3 -9
  46. package/dist/services/plugins/domain.js +1 -0
  47. package/dist/services/plugins/plugin-cache.js +9 -0
  48. package/dist/services/plugins/registry.js +110 -0
  49. package/dist/services/plugins/service.js +177 -0
  50. package/dist/services/plugins/types.js +1 -0
  51. package/dist/services/process.js +29 -0
  52. package/dist/services/storage.js +11 -10
  53. package/dist/services/thread-naming.js +81 -0
  54. package/docs/agents.md +16 -10
  55. package/docs/architecture.md +2 -2
  56. package/docs/plugins.md +6 -15
  57. package/docs/templates/AGENT.example.md +7 -13
  58. package/package.json +1 -2
  59. package/src/app/agent-ids.ts +5 -0
  60. package/src/app/cli.ts +1 -1
  61. package/src/app/config.ts +1 -31
  62. package/src/app/server.ts +8 -16
  63. package/src/app/types.ts +63 -189
  64. package/src/harness/index.ts +145 -0
  65. package/src/plugins/approval/index.ts +91 -189
  66. package/src/plugins/delegation/index.ts +136 -39
  67. package/src/plugins/memory/index.ts +112 -15
  68. package/src/{services/memory.ts → plugins/memory/service.ts} +1 -1
  69. package/src/plugins/openbot/context.ts +91 -0
  70. package/src/plugins/openbot/history.ts +107 -0
  71. package/src/plugins/openbot/index.ts +37 -0
  72. package/src/plugins/openbot/runtime.ts +384 -0
  73. package/src/plugins/openbot/system-prompt.ts +7 -0
  74. package/src/plugins/plugin-manager/index.ts +122 -0
  75. package/src/plugins/shell/index.ts +1 -1
  76. package/src/plugins/storage/index.ts +633 -0
  77. package/src/{services/storage.ts → plugins/storage/service.ts} +224 -67
  78. package/src/{bus/types.ts → services/plugins/domain.ts} +16 -7
  79. package/src/services/plugins/plugin-cache.ts +13 -0
  80. package/src/{registry/plugins.ts → services/plugins/registry.ts} +25 -27
  81. package/src/services/{plugins.ts → plugins/service.ts} +96 -2
  82. package/src/{bus/plugin.ts → services/plugins/types.ts} +3 -3
  83. package/src/bus/services.ts +0 -954
  84. package/src/harness/context.ts +0 -365
  85. package/src/harness/dispatcher.ts +0 -379
  86. package/src/harness/mcp.ts +0 -78
  87. package/src/harness/runtime-factory.ts +0 -129
  88. package/src/harness/todo-advance.ts +0 -128
  89. package/src/plugins/ai-sdk/index.ts +0 -41
  90. package/src/plugins/ai-sdk/runtime.ts +0 -468
  91. package/src/plugins/ai-sdk/system-prompt.ts +0 -18
  92. package/src/plugins/mcp/index.ts +0 -128
  93. package/src/plugins/storage-tools/index.ts +0 -90
  94. package/src/plugins/todo/index.ts +0 -64
  95. package/src/plugins/ui/index.ts +0 -227
  96. /package/src/{harness → services}/process.ts +0 -0
@@ -1,3 +1,26 @@
1
+ import { isDmSoloChannel } from './channel-participants.js';
2
+ export const DEFAULT_CONTEXT_BUDGET = 8000;
3
+ /**
4
+ * Returns the known context window budget (in tokens) for a given model string.
5
+ * This is used to drive the context usage ring in the UI and to configure
6
+ * the prompt pruning budget.
7
+ */
8
+ export const getContextBudgetForModel = (modelString) => {
9
+ const budgets = {
10
+ 'openai/gpt-4o': 128000,
11
+ 'openai/gpt-4o-mini': 128000,
12
+ 'openai/o1-preview': 128000,
13
+ 'openai/o1-mini': 128000,
14
+ 'anthropic/claude-3-5-sonnet-20240620': 200000,
15
+ 'anthropic/claude-3-5-sonnet-latest': 200000,
16
+ 'anthropic/claude-3-opus-20240229': 200000,
17
+ 'anthropic/claude-3-sonnet-20240229': 200000,
18
+ 'anthropic/claude-3-haiku-20240307': 200000,
19
+ };
20
+ return budgets[modelString] || DEFAULT_CONTEXT_BUDGET;
21
+ };
22
+ /** Built-in orchestrator agent id (`~/.openbot/agents/system/AGENT.md` overrides instructions). */
23
+ export const ORCHESTRATOR_AGENT_ID = 'system';
1
24
  /**
2
25
  * Cheap, dependency-free token estimator. Roughly char/4 — fine for budget
3
26
  * enforcement; can be swapped for a tokenizer-backed implementation later
@@ -55,71 +78,95 @@ export class ContextEngine {
55
78
  */
56
79
  export function createDefaultContextEngine() {
57
80
  const engine = new ContextEngine();
81
+ engine.registerProvider(new EnvironmentProvider());
82
+ engine.registerProvider(new ChannelSpecProvider());
58
83
  engine.registerProvider(new AgentDetailsProvider());
59
- engine.registerProvider(new ChannelDetailsProvider());
60
- engine.registerProvider(new ThreadDetailsProvider());
61
84
  engine.registerProvider(new TodoProvider());
62
85
  engine.registerProvider(new MemoryProvider());
63
- engine.registerProvider(new RecentEventsProvider());
86
+ // engine.registerProvider(new RecentEventsProvider());
64
87
  engine.registerProcessor(new TokenBudgetProcessor());
65
88
  return engine;
66
89
  }
67
- class AgentDetailsProvider {
90
+ class EnvironmentProvider {
68
91
  constructor() {
69
- this.name = 'agent-details';
92
+ this.name = 'environment';
70
93
  }
71
94
  async provide(state) {
72
- if (!state.agentDetails)
73
- return [];
74
- const instructions = state.agentDetails.instructions?.trim();
75
- if (!instructions)
76
- return [];
77
- return [{
78
- id: 'agent-details',
79
- type: 'agent',
80
- priority: 100,
81
- content: `# ${state.agentDetails.name}\n\n${instructions}`,
82
- }];
95
+ const { channelId, threadId, channelDetails, agentId, threadDetails } = state;
96
+ const participants = channelDetails?.participants || [];
97
+ const isDm = isDmSoloChannel(participants, agentId);
98
+ let content = '## ENVIRONMENT\n';
99
+ if (isDm) {
100
+ content += '- Mode: Direct Message (Solo)\n';
101
+ content += '- Context: You are in a private conversation. No other agents are present.\n';
102
+ }
103
+ else {
104
+ const channelName = channelDetails?.name || channelId;
105
+ content += `- Mode: Channel (#${channelName})\n`;
106
+ if (threadId) {
107
+ content += `- Thread: ${threadDetails?.name || threadId}\n`;
108
+ }
109
+ const peerIds = participants.filter((id) => id !== agentId);
110
+ if (peerIds.length > 0) {
111
+ content += `- Participants: ${peerIds.join(', ')}\n`;
112
+ content += ` (Use these plain ids for todo assignees and delegate_to_agent — no @ prefix.)\n`;
113
+ }
114
+ }
115
+ return [
116
+ {
117
+ id: 'environment',
118
+ type: 'environment',
119
+ priority: 110,
120
+ content,
121
+ },
122
+ ];
83
123
  }
84
124
  }
85
- class ChannelDetailsProvider {
125
+ /**
126
+ * Injects SPEC.md (`channelDetails.spec`). Kept distinct from EnvironmentProvider
127
+ * so each block gets its own truncate budget and channel rules survive long
128
+ * participant lists under {@link ITEM_HARD_CHAR_CAP}.
129
+ */
130
+ class ChannelSpecProvider {
86
131
  constructor() {
87
- this.name = 'channel-details';
132
+ this.name = 'channel-spec';
88
133
  }
89
134
  async provide(state) {
90
- if (!state.channelDetails)
135
+ const raw = state.channelDetails?.spec;
136
+ const spec = typeof raw === 'string' ? raw.trim() : '';
137
+ if (!spec)
91
138
  return [];
92
- const participants = state.channelDetails.participants;
93
- if (!participants?.length)
94
- return [];
95
- const channelLabel = state.channelDetails.name?.trim() || state.channelDetails.id;
96
- const lines = participants.map((id) => `- \`${id}\``).join('\n');
97
139
  return [
98
140
  {
99
- id: 'channel-details',
100
- type: 'channel',
101
- priority: 80,
102
- content: `## Channel participants (${channelLabel})\n` +
103
- `Agent ids collaborating in this channel:\n${lines}`,
141
+ id: 'channel-spec',
142
+ type: 'channel-spec',
143
+ /** Below environment (110), above agent / {@link TokenBudgetProcessor.KEEP_FLOOR}. */
144
+ priority: 108,
145
+ content: `## CHANNEL SPECIFICATION (SPEC.md)\n` +
146
+ `Channel-level goals and constraints. Prefer these unless the user contradicts them.\n\n` +
147
+ `${spec}`,
104
148
  },
105
149
  ];
106
150
  }
107
151
  }
108
- class ThreadDetailsProvider {
152
+ class AgentDetailsProvider {
109
153
  constructor() {
110
- this.name = 'thread-details';
154
+ this.name = 'agent-details';
111
155
  }
112
156
  async provide(state) {
113
- if (!state.threadDetails)
157
+ if (!state.agentDetails)
158
+ return [];
159
+ if (state.agentId === ORCHESTRATOR_AGENT_ID)
160
+ return [];
161
+ const instructions = state.agentDetails.instructions?.trim();
162
+ if (!instructions)
114
163
  return [];
115
- // For now, this provider is a placeholder for future state-based assembly.
116
- // It currently only surfaces the thread name to provide basic context.
117
164
  return [
118
165
  {
119
- id: 'thread-details',
120
- type: 'thread',
121
- priority: 90,
122
- content: `# Thread you are in: ${state.threadDetails.name}`,
166
+ id: 'agent-details',
167
+ type: 'agent',
168
+ priority: 100,
169
+ content: `## AGENT: ${state.agentDetails.name}\n\n${instructions}`,
123
170
  },
124
171
  ];
125
172
  }
@@ -135,6 +182,8 @@ class TodoProvider {
135
182
  this.name = 'todos';
136
183
  }
137
184
  async provide(state) {
185
+ if (state.agentId !== ORCHESTRATOR_AGENT_ID)
186
+ return [];
138
187
  const raw = state.threadDetails?.state?.todos;
139
188
  const todos = Array.isArray(raw) ? raw : [];
140
189
  if (todos.length === 0)
@@ -165,9 +214,7 @@ class TodoProvider {
165
214
  id: 'todos',
166
215
  type: 'todos',
167
216
  priority: 92,
168
- content: `## Shared todo plan (thread state)\n` +
169
- `Orchestrator authors with \`todo_write\`; assignees run one step at a time. ` +
170
- `When an item is \`done\`, its captured output appears below so every agent can see prior steps without relying on merged chat history.\n\n` +
217
+ content: `## SHARED TODO PLAN (thread state)\n` +
171
218
  `${formatted}`,
172
219
  },
173
220
  ];
@@ -204,7 +251,7 @@ class MemoryProvider {
204
251
  id: 'memory',
205
252
  type: 'memory',
206
253
  priority: 95,
207
- content: `## Remembered facts\nTrust these unless the user contradicts them. Use \`forget\` to remove stale ones.\n\n${formatted}`,
254
+ content: `## Remembered global facts\nTrust these unless the user contradicts them. Use \`forget\` to remove stale ones.\n\n${formatted}`,
208
255
  },
209
256
  ];
210
257
  }
@@ -280,20 +327,21 @@ class RecentEventsProvider {
280
327
  }
281
328
  /**
282
329
  * Drops the lowest-priority items until the assembled prompt fits within the
283
- * token budget. The first item with priority >= `keepFloor` is always kept,
330
+ * token budget. The first item with priority >= \`keepFloor\` is always kept,
284
331
  * so the agent's own instructions can never be evicted. Stable on ties:
285
332
  * later-emitted items go first.
286
333
  */
287
334
  export class TokenBudgetProcessor {
288
- constructor(budget = TokenBudgetProcessor.DEFAULT_BUDGET, keepFloor = TokenBudgetProcessor.KEEP_FLOOR) {
335
+ constructor(budget = undefined, keepFloor = TokenBudgetProcessor.KEEP_FLOOR) {
289
336
  this.budget = budget;
290
337
  this.keepFloor = keepFloor;
291
338
  this.name = 'token-budget';
292
339
  }
293
- async process(items) {
340
+ async process(items, state) {
294
341
  const sorted = [...items].sort((a, b) => b.priority - a.priority);
295
342
  const out = [];
296
343
  let used = 0;
344
+ const activeBudget = this.budget ?? (state.model ? getContextBudgetForModel(state.model) : TokenBudgetProcessor.DEFAULT_BUDGET);
297
345
  for (const item of sorted) {
298
346
  const cost = estimateTokens(item.content);
299
347
  if (item.priority >= this.keepFloor) {
@@ -301,7 +349,7 @@ export class TokenBudgetProcessor {
301
349
  used += cost;
302
350
  continue;
303
351
  }
304
- if (used + cost <= this.budget) {
352
+ if (used + cost <= activeBudget) {
305
353
  out.push(item);
306
354
  used += cost;
307
355
  }
@@ -310,6 +358,6 @@ export class TokenBudgetProcessor {
310
358
  }
311
359
  }
312
360
  /** Soft prompt budget in tokens (matches gpt-4o-mini's reasonable system slice). */
313
- TokenBudgetProcessor.DEFAULT_BUDGET = 8000;
361
+ TokenBudgetProcessor.DEFAULT_BUDGET = DEFAULT_CONTEXT_BUDGET;
314
362
  /** Items at or above this priority are never dropped. */
315
363
  TokenBudgetProcessor.KEEP_FLOOR = 100;
@@ -0,0 +1,144 @@
1
+ import { ensureEventId } from '../app/utils.js';
2
+ import { storageService } from '../services/storage.js';
3
+ import { ORCHESTRATOR_AGENT_ID } from './constants.js';
4
+ import { runTurn } from './run.js';
5
+ const stopRequests = [];
6
+ const STOP_REQUEST_TTL_MS = 30 * 60 * 1000;
7
+ const pruneStopRequests = () => {
8
+ const now = Date.now();
9
+ for (let i = stopRequests.length - 1; i >= 0; i -= 1) {
10
+ if (now - stopRequests[i].requestedAt > STOP_REQUEST_TTL_MS) {
11
+ stopRequests.splice(i, 1);
12
+ }
13
+ }
14
+ };
15
+ const findStopRequest = (target) => {
16
+ pruneStopRequests();
17
+ return stopRequests.find((r) => {
18
+ if (r.runId !== target.runId)
19
+ return false;
20
+ if (r.agentId && r.agentId !== target.agentId)
21
+ return false;
22
+ if (r.channelId && r.channelId !== target.channelId)
23
+ return false;
24
+ if (r.threadId && r.threadId !== target.threadId)
25
+ return false;
26
+ return true;
27
+ });
28
+ };
29
+ export async function dispatch(options) {
30
+ const { runId, channelId, onEvent } = options;
31
+ let { threadId } = options;
32
+ const { event } = options;
33
+ ensureEventId(event);
34
+ if (event.type === 'action:agent_run_stop') {
35
+ await handleStop(event, options);
36
+ return;
37
+ }
38
+ const agentId = options.agentId || ORCHESTRATOR_AGENT_ID;
39
+ let turnEvent = event;
40
+ if (event.type === 'user:input' || event.type === 'agent:invoke') {
41
+ turnEvent = await normalizeUserInput(event, { runId, channelId, threadId, onEvent });
42
+ if (event.type === 'user:input') {
43
+ threadId = turnEvent.meta?.threadId || threadId || event.id;
44
+ }
45
+ }
46
+ const target = { runId, agentId, channelId, threadId };
47
+ const preStop = findStopRequest(target);
48
+ if (preStop) {
49
+ const state = await storageService.getOpenBotState({ ...target, event: turnEvent });
50
+ await onEvent({ type: 'agent:run:stopped', data: { ...target, reason: preStop.reason } }, state);
51
+ return;
52
+ }
53
+ let state;
54
+ try {
55
+ state = await storageService.getOpenBotState({ ...target, event: turnEvent });
56
+ }
57
+ catch (error) {
58
+ if (error.code === 'AGENT_NOT_FOUND') {
59
+ const fallback = await storageService.getOpenBotState({
60
+ ...target,
61
+ agentId: ORCHESTRATOR_AGENT_ID,
62
+ event: turnEvent,
63
+ });
64
+ await onEvent({
65
+ type: 'agent:output',
66
+ data: {
67
+ content: `⚠️ Agent **${agentId}** does not exist. Use participant ids without an @ prefix.`,
68
+ },
69
+ meta: { agentId: ORCHESTRATOR_AGENT_ID, threadId },
70
+ }, fallback);
71
+ return;
72
+ }
73
+ throw error;
74
+ }
75
+ const turn = runTurn({ runId, channelId, threadId, agentId, event: turnEvent });
76
+ let next = await turn.next();
77
+ while (!next.done) {
78
+ const chunk = next.value;
79
+ const stop = findStopRequest(target);
80
+ if (stop) {
81
+ await onEvent({ type: 'agent:run:stopped', data: { ...target, reason: stop.reason } }, state);
82
+ break;
83
+ }
84
+ if (chunk.type === 'action:create_thread:result' && chunk.data.success) {
85
+ threadId = chunk.data.threadId || threadId;
86
+ }
87
+ await onEvent(chunk, state);
88
+ next = await turn.next();
89
+ }
90
+ }
91
+ async function normalizeUserInput(event, ctx) {
92
+ const rawContent = event.data?.content || '';
93
+ const userFacing = {
94
+ type: 'agent:invoke',
95
+ id: event.id,
96
+ data: { content: rawContent, role: 'user' },
97
+ meta: {
98
+ agentId: ORCHESTRATOR_AGENT_ID,
99
+ userId: event.meta?.userId,
100
+ userName: event.meta?.userName,
101
+ userAvatarUrl: event.meta?.userAvatarUrl,
102
+ },
103
+ };
104
+ const initialState = await storageService.getOpenBotState({
105
+ runId: ctx.runId,
106
+ agentId: ORCHESTRATOR_AGENT_ID,
107
+ channelId: ctx.channelId,
108
+ threadId: ctx.threadId,
109
+ event: userFacing,
110
+ });
111
+ await ctx.onEvent(userFacing, initialState);
112
+ return {
113
+ ...event,
114
+ type: 'agent:invoke',
115
+ data: { ...(event.data || {}), content: rawContent, role: 'user' },
116
+ meta: {
117
+ ...(event.meta || {}),
118
+ threadId: ctx.threadId || event.id,
119
+ },
120
+ };
121
+ }
122
+ async function handleStop(stopEvent, options) {
123
+ const { runId, channelId, threadId, onEvent } = options;
124
+ stopRequests.push({
125
+ runId: stopEvent.data.runId,
126
+ agentId: stopEvent.data.agentId,
127
+ channelId: stopEvent.data.channelId || channelId,
128
+ threadId: stopEvent.data.threadId || threadId,
129
+ reason: stopEvent.data.reason,
130
+ requestedAt: Date.now(),
131
+ });
132
+ const state = await storageService.getOpenBotState({
133
+ runId,
134
+ agentId: options.agentId || ORCHESTRATOR_AGENT_ID,
135
+ channelId,
136
+ threadId,
137
+ event: stopEvent,
138
+ });
139
+ await onEvent({
140
+ type: 'action:agent_run_stop:result',
141
+ data: { success: true, message: `Stop requested for run ${stopEvent.data.runId}.` },
142
+ meta: stopEvent.meta,
143
+ }, state);
144
+ }
@@ -1,8 +1,8 @@
1
1
  import { ensureEventId } from '../app/utils.js';
2
2
  import { storageService } from '../services/storage.js';
3
- import { createAgentRuntime } from './runtime-factory.js';
4
- import { advanceAfterRun } from './todo-advance.js';
5
- const MAX_CHAIN_DEPTH = 20;
3
+ import { ORCHESTRATOR_AGENT_ID } from './context.js';
4
+ import { resolveMessageTargetAgent } from './channel-participants.js';
5
+ import { runTurn } from './turn.js';
6
6
  const stopRequests = [];
7
7
  const STOP_REQUEST_TTL_MS = 30 * 60 * 1000;
8
8
  const pruneStopRequests = () => {
@@ -27,9 +27,6 @@ const findStopRequest = (target) => {
27
27
  return true;
28
28
  });
29
29
  };
30
- // ---------------------------------------------------------------------------
31
- // Public API
32
- // ---------------------------------------------------------------------------
33
30
  export async function dispatch(options) {
34
31
  const { event } = options;
35
32
  ensureEventId(event);
@@ -44,188 +41,86 @@ export async function dispatch(options) {
44
41
  onEvent: options.onEvent,
45
42
  };
46
43
  if (event.type === 'user:input' || event.type === 'agent:invoke') {
47
- const invoke = await normalizeUserInput(event, ctx);
48
- await runStep({ agentId: options.agentId || 'system', event: invoke }, ctx, 0);
44
+ const { invoke, targetAgentId } = await normalizeUserInput(event, ctx, options.agentId);
45
+ await executeTurn(targetAgentId, invoke, ctx);
49
46
  return;
50
47
  }
51
- // Bus pass-through: route to the targeted agent's runtime once. No agent step,
52
- // no advance, no follow-ups. Keeps queries (`/api/state`) cheap and idempotent.
53
- await runBusEvent(event, options.agentId || 'system', ctx);
48
+ await executeTurn(options.agentId || ORCHESTRATOR_AGENT_ID, event, ctx, { lifecycle: false });
54
49
  }
55
- // ---------------------------------------------------------------------------
56
- // Agent step: run:start -> runtime -> run:end -> advance -> chain
57
- // ---------------------------------------------------------------------------
58
- async function runStep(step, ctx, depth) {
59
- if (depth >= MAX_CHAIN_DEPTH) {
60
- console.warn(`[dispatcher] Reached MAX_CHAIN_DEPTH (${MAX_CHAIN_DEPTH}); stopping chain.`);
61
- return;
62
- }
50
+ async function executeTurn(agentId, event, ctx, opts = {}) {
63
51
  const target = {
64
52
  runId: ctx.runId,
65
- agentId: step.agentId,
53
+ agentId,
66
54
  channelId: ctx.channelId,
67
55
  threadId: ctx.threadId,
68
56
  };
69
- const preStop = findStopRequest(target);
57
+ const preStop = opts.lifecycle !== false ? findStopRequest(target) : undefined;
70
58
  if (preStop) {
71
- const state = await storageService.getOpenBotState({ ...target, event: step.event });
59
+ const state = await storageService.getOpenBotState({ ...target, event });
72
60
  await ctx.onEvent({ type: 'agent:run:stopped', data: { ...target, reason: preStop.reason } }, state);
73
61
  return;
74
62
  }
75
63
  let state;
76
64
  try {
77
- state = await storageService.getOpenBotState({ ...target, event: step.event });
65
+ state = await storageService.getOpenBotState({ ...target, event });
78
66
  }
79
67
  catch (error) {
80
68
  if (error.code === 'AGENT_NOT_FOUND') {
81
69
  const fallback = await storageService.getOpenBotState({
82
70
  ...target,
83
- agentId: 'system',
84
- event: step.event,
71
+ agentId: ORCHESTRATOR_AGENT_ID,
72
+ event,
85
73
  });
86
74
  await ctx.onEvent({
87
75
  type: 'agent:output',
88
- data: { content: `⚠️ Agent **${step.agentId}** does not exist. Please check the agent ID and try again.` },
89
- meta: { agentId: 'system', threadId: ctx.threadId },
76
+ data: {
77
+ content: `⚠️ Agent **${agentId}** does not exist. Use participant ids without an @ prefix.`,
78
+ },
79
+ meta: { agentId: ORCHESTRATOR_AGENT_ID, threadId: ctx.threadId },
90
80
  }, fallback);
91
81
  return;
92
82
  }
93
83
  throw error;
94
84
  }
95
- await ctx.onEvent({ type: 'agent:run:start', data: { ...target } }, state);
96
- const followUps = [];
97
- const queuedAgentIds = new Set();
98
- let lastAgentOutput;
99
- try {
100
- const runtime = await createAgentRuntime(state);
101
- for await (const chunk of runtime.run(step.event, { state, runId: ctx.runId })) {
102
- const stop = findStopRequest(target);
103
- if (stop) {
104
- await ctx.onEvent({ type: 'agent:run:stopped', data: { ...target, reason: stop.reason } }, state);
105
- break;
106
- }
107
- if (chunk.id === step.event.id && chunk.type === step.event.type)
108
- continue;
109
- if (chunk.type === 'action:create_thread:result' && chunk.data.success) {
110
- ctx.threadId = chunk.data.threadId || ctx.threadId;
111
- }
112
- if (chunk.type === 'agent:output' &&
113
- chunk.meta?.agentId === step.agentId) {
114
- const content = chunk.data?.content;
115
- if (typeof content === 'string' && content.trim())
116
- lastAgentOutput = content.trim();
117
- }
118
- // Handoff requests are internal: queue a follow-up step instead of forwarding.
119
- if (chunk.type === 'handoff:request') {
120
- const req = chunk;
121
- const targetAgent = req.data?.agentId;
122
- if (targetAgent && targetAgent !== step.agentId && !queuedAgentIds.has(targetAgent)) {
123
- queuedAgentIds.add(targetAgent);
124
- followUps.push({
125
- agentId: targetAgent,
126
- event: makeInvoke(req.data.content, ctx.threadId, req.meta),
127
- });
128
- }
129
- continue;
130
- }
131
- chunk.meta = { ...chunk.meta, agentId: step.agentId };
132
- await ctx.onEvent(chunk, state);
133
- }
134
- }
135
- catch (error) {
136
- console.error(`[dispatcher] Agent run failed: ${step.agentId}`, error);
137
- }
138
- finally {
139
- const endState = await storageService.getOpenBotState({ ...target, event: step.event });
140
- await ctx.onEvent({ type: 'agent:run:end', data: { ...target } }, endState);
141
- }
142
- // Autonomous todo advance: single trigger point, runs once per `agent:run:end`.
143
- try {
144
- const handoff = await advanceAfterRun({
145
- storage: storageService,
146
- channelId: ctx.channelId,
147
- threadId: ctx.threadId,
148
- endedAgentId: step.agentId,
149
- lastAgentOutput,
150
- });
151
- if (handoff && !queuedAgentIds.has(handoff.agentId)) {
152
- queuedAgentIds.add(handoff.agentId);
153
- followUps.push({
154
- agentId: handoff.agentId,
155
- event: makeInvoke(handoff.content, ctx.threadId),
156
- });
157
- }
158
- }
159
- catch (error) {
160
- console.warn('[dispatcher] todo advance failed', error);
161
- }
162
- for (const next of followUps) {
163
- await runStep(next, ctx, depth + 1);
164
- }
165
- }
166
- // ---------------------------------------------------------------------------
167
- // Bus pass-through: run an event through the targeted agent's runtime, forward
168
- // chunks. No run:start/end, no advance, no follow-ups.
169
- // ---------------------------------------------------------------------------
170
- async function runBusEvent(event, agentId, ctx) {
171
- let state;
172
- try {
173
- state = await storageService.getOpenBotState({
174
- runId: ctx.runId,
175
- agentId,
176
- channelId: ctx.channelId,
177
- threadId: ctx.threadId,
178
- event,
179
- });
180
- }
181
- catch (error) {
182
- if (error.code === 'AGENT_NOT_FOUND') {
183
- // Silently drop: bus pass-through has no UI surface to warn into.
184
- return;
185
- }
186
- throw error;
187
- }
188
- try {
189
- const runtime = await createAgentRuntime(state);
190
- for await (const chunk of runtime.run(event, { state, runId: ctx.runId })) {
191
- if (chunk.id === event.id && chunk.type === event.type)
192
- continue;
193
- chunk.meta = { ...chunk.meta, agentId };
194
- await ctx.onEvent(chunk, state);
195
- }
196
- }
197
- catch (error) {
198
- console.error(`[dispatcher] Bus event failed: ${event.type} (${agentId})`, error);
85
+ const turn = runTurn({
86
+ ...target,
87
+ event,
88
+ lifecycle: opts.lifecycle !== false,
89
+ shouldStop: () => findStopRequest(target),
90
+ onThreadId: (threadId) => {
91
+ ctx.threadId = threadId;
92
+ },
93
+ });
94
+ let next = await turn.next();
95
+ while (!next.done) {
96
+ await ctx.onEvent(next.value, state);
97
+ next = await turn.next();
199
98
  }
200
99
  }
201
- // ---------------------------------------------------------------------------
202
- // Helpers
203
- // ---------------------------------------------------------------------------
204
- async function normalizeUserInput(event, ctx) {
100
+ async function normalizeUserInput(event, ctx, requestedAgentId) {
205
101
  const rawContent = event.data?.content || '';
206
- // The user-facing copy stored/streamed for the UI.
102
+ const previewState = await storageService.getOpenBotState({
103
+ runId: ctx.runId,
104
+ agentId: ORCHESTRATOR_AGENT_ID,
105
+ channelId: ctx.channelId,
106
+ threadId: ctx.threadId,
107
+ event,
108
+ });
109
+ const participants = previewState.channelDetails?.participants ?? [];
110
+ const targetAgentId = resolveMessageTargetAgent(participants, ORCHESTRATOR_AGENT_ID, requestedAgentId);
207
111
  const userFacing = {
208
112
  type: 'agent:invoke',
209
113
  id: event.id,
210
114
  data: { content: rawContent, role: 'user' },
211
115
  meta: {
212
- agentId: 'system',
116
+ agentId: targetAgentId,
213
117
  userId: event.meta?.userId,
214
118
  userName: event.meta?.userName,
215
119
  userAvatarUrl: event.meta?.userAvatarUrl,
216
120
  },
217
121
  };
218
- const initialState = await storageService.getOpenBotState({
219
- runId: ctx.runId,
220
- agentId: 'system',
221
- channelId: ctx.channelId,
222
- threadId: ctx.threadId,
223
- event: userFacing,
224
- });
225
- await ctx.onEvent(userFacing, initialState);
226
- // The event actually fed to the target agent. Carries the input threadId (or the
227
- // message id, used as the anchor for Slack-style new threads).
228
- return {
122
+ await ctx.onEvent(userFacing, previewState);
123
+ const invoke = {
229
124
  ...event,
230
125
  type: 'agent:invoke',
231
126
  data: { ...(event.data || {}), content: rawContent, role: 'user' },
@@ -234,13 +129,7 @@ async function normalizeUserInput(event, ctx) {
234
129
  threadId: ctx.threadId || event.id,
235
130
  },
236
131
  };
237
- }
238
- function makeInvoke(content, threadId, baseMeta) {
239
- return ensureEventId({
240
- type: 'agent:invoke',
241
- data: { role: 'user', content },
242
- meta: { ...(baseMeta || {}), threadId },
243
- });
132
+ return { invoke, targetAgentId };
244
133
  }
245
134
  async function handleStop(stopEvent, options) {
246
135
  const { runId, channelId, threadId, onEvent } = options;
@@ -254,7 +143,7 @@ async function handleStop(stopEvent, options) {
254
143
  });
255
144
  const state = await storageService.getOpenBotState({
256
145
  runId,
257
- agentId: options.agentId || 'system',
146
+ agentId: options.agentId || ORCHESTRATOR_AGENT_ID,
258
147
  channelId,
259
148
  threadId,
260
149
  event: stopEvent,