openbot 0.3.0 → 0.3.2

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 (43) hide show
  1. package/dist/app/cli.js +1 -1
  2. package/dist/app/server.js +1 -4
  3. package/dist/bus/services.js +222 -15
  4. package/dist/harness/context.js +205 -26
  5. package/dist/harness/queue-processor.js +44 -110
  6. package/dist/harness/runtime-factory.js +11 -7
  7. package/dist/harness/todo-advance.js +93 -0
  8. package/dist/plugins/ai-sdk/index.js +0 -3
  9. package/dist/plugins/ai-sdk/runtime.js +78 -13
  10. package/dist/plugins/ai-sdk/system-prompt.js +18 -3
  11. package/dist/plugins/delegation/index.js +7 -46
  12. package/dist/plugins/memory/index.js +71 -0
  13. package/dist/plugins/storage-tools/index.js +2 -11
  14. package/dist/plugins/todo/index.js +54 -0
  15. package/dist/plugins/workflow/index.js +65 -0
  16. package/dist/registry/plugins.js +4 -2
  17. package/dist/services/memory.js +152 -0
  18. package/dist/services/storage.js +9 -31
  19. package/dist/workflow/service.js +106 -0
  20. package/dist/workflow/types.js +3 -0
  21. package/docs/agents.md +15 -1
  22. package/docs/plugins.md +0 -1
  23. package/package.json +1 -1
  24. package/src/app/cli.ts +1 -1
  25. package/src/app/server.ts +3 -4
  26. package/src/app/types.ts +140 -45
  27. package/src/bus/plugin.ts +0 -2
  28. package/src/bus/services.ts +258 -17
  29. package/src/bus/types.ts +13 -4
  30. package/src/harness/context.ts +233 -37
  31. package/src/harness/queue-processor.ts +54 -143
  32. package/src/harness/runtime-factory.ts +11 -7
  33. package/src/harness/todo-advance.ts +128 -0
  34. package/src/plugins/ai-sdk/index.ts +0 -3
  35. package/src/plugins/ai-sdk/runtime.ts +356 -298
  36. package/src/plugins/ai-sdk/system-prompt.ts +18 -4
  37. package/src/plugins/delegation/index.ts +7 -50
  38. package/src/plugins/memory/index.ts +85 -0
  39. package/src/plugins/storage-tools/index.ts +8 -19
  40. package/src/plugins/todo/index.ts +64 -0
  41. package/src/registry/plugins.ts +4 -3
  42. package/src/services/memory.ts +213 -0
  43. package/src/services/storage.ts +9 -49
@@ -1,8 +1,16 @@
1
- import { OpenBotState } from '../app/types.js';
1
+ import { OpenBotEvent, OpenBotState, TodoItem } from '../app/types.js';
2
2
  import { Storage } from '../bus/types.js';
3
3
 
4
4
  /**
5
5
  * Represents a piece of context that can be used in a prompt.
6
+ *
7
+ * Items flow through the engine in two phases:
8
+ * 1. Each registered `ContextProvider` emits zero or more items.
9
+ * 2. Each registered `ContextProcessor` may transform / drop / re-rank
10
+ * items (e.g. token-budget enforcement).
11
+ *
12
+ * Higher `priority` items appear first in the assembled prompt and are the
13
+ * last to be dropped under budget pressure.
6
14
  */
7
15
  export interface ContextItem {
8
16
  id: string;
@@ -12,25 +20,33 @@ export interface ContextItem {
12
20
  metadata?: Record<string, any>;
13
21
  }
14
22
 
15
- /**
16
- * A provider that can fetch or generate context items.
17
- */
18
23
  export interface ContextProvider {
19
24
  name: string;
20
25
  provide(state: OpenBotState, storage?: Storage): Promise<ContextItem[]>;
21
26
  }
22
27
 
23
- /**
24
- * A processor that can transform or filter context items (e.g., ranking, truncation).
25
- */
26
28
  export interface ContextProcessor {
27
29
  name: string;
28
30
  process(items: ContextItem[], state: OpenBotState): Promise<ContextItem[]>;
29
31
  }
30
32
 
31
33
  /**
32
- * The core engine that orchestrates context building.
34
+ * Cheap, dependency-free token estimator. Roughly char/4 — fine for budget
35
+ * enforcement; can be swapped for a tokenizer-backed implementation later
36
+ * without touching providers.
33
37
  */
38
+ export const estimateTokens = (text: string): number =>
39
+ Math.ceil((text?.length ?? 0) / 4);
40
+
41
+ /**
42
+ * Hard cap (in characters) on a single context item. Keeps any one provider
43
+ * — typically the recent-events feed — from monopolising the prompt budget.
44
+ */
45
+ const ITEM_HARD_CHAR_CAP = 6000;
46
+
47
+ const truncate = (text: string, maxChars: number): string =>
48
+ text.length <= maxChars ? text : `${text.slice(0, maxChars)}\n…[truncated]`;
49
+
34
50
  export class ContextEngine {
35
51
  private providers: ContextProvider[] = [];
36
52
  private processors: ContextProcessor[] = [];
@@ -44,18 +60,18 @@ export class ContextEngine {
44
60
  }
45
61
 
46
62
  async buildContext(state: OpenBotState, storage?: Storage): Promise<string> {
47
- // 1. Collect context from all providers
48
63
  let items: ContextItem[] = [];
49
64
  for (const provider of this.providers) {
50
65
  try {
51
66
  const providedItems = await provider.provide(state, storage);
52
- items.push(...providedItems);
67
+ for (const item of providedItems) {
68
+ items.push({ ...item, content: truncate(item.content, ITEM_HARD_CHAR_CAP) });
69
+ }
53
70
  } catch (error) {
54
71
  console.warn(`[ContextEngine] Provider ${provider.name} failed:`, error);
55
72
  }
56
73
  }
57
74
 
58
- // 2. Run through processors
59
75
  for (const processor of this.processors) {
60
76
  try {
61
77
  items = await processor.process(items, state);
@@ -64,26 +80,30 @@ export class ContextEngine {
64
80
  }
65
81
  }
66
82
 
67
- // 3. Format items into a single string
68
83
  return items
69
84
  .sort((a, b) => b.priority - a.priority)
70
- .map(item => item.content)
85
+ .map((item) => item.content)
71
86
  .join('\n\n');
72
87
  }
73
88
  }
74
89
 
75
90
  /**
76
- * Default implementation of a Context Engine with basic providers.
91
+ * Default context engine. Order of providers is by emit order; final ordering
92
+ * in the prompt is determined by `priority`. The token-budget processor runs
93
+ * last so dropping happens after every provider has contributed.
77
94
  */
78
95
  export function createDefaultContextEngine(): ContextEngine {
79
96
  const engine = new ContextEngine();
80
97
 
81
- // Basic Providers
82
98
  engine.registerProvider(new AgentDetailsProvider());
83
99
  engine.registerProvider(new ChannelDetailsProvider());
84
100
  engine.registerProvider(new ThreadDetailsProvider());
101
+ engine.registerProvider(new TodoProvider());
102
+ engine.registerProvider(new MemoryProvider());
85
103
  engine.registerProvider(new RecentEventsProvider());
86
104
 
105
+ engine.registerProcessor(new TokenBudgetProcessor());
106
+
87
107
  return engine;
88
108
  }
89
109
 
@@ -91,11 +111,14 @@ class AgentDetailsProvider implements ContextProvider {
91
111
  name = 'agent-details';
92
112
  async provide(state: OpenBotState): Promise<ContextItem[]> {
93
113
  if (!state.agentDetails) return [];
114
+ const instructions = state.agentDetails.instructions?.trim();
115
+ if (!instructions) return [];
116
+
94
117
  return [{
95
118
  id: 'agent-details',
96
119
  type: 'agent',
97
120
  priority: 100,
98
- content: `## AGENT NAME\n${state.agentDetails.name}\n\n## AGENT SPECIFICATION\n${state.agentDetails.instructions}`
121
+ content: `# ${state.agentDetails.name}\n\n${instructions}`,
99
122
  }];
100
123
  }
101
124
  }
@@ -104,11 +127,14 @@ class ChannelDetailsProvider implements ContextProvider {
104
127
  name = 'channel-details';
105
128
  async provide(state: OpenBotState): Promise<ContextItem[]> {
106
129
  if (!state.channelDetails) return [];
130
+ const spec = state.channelDetails.spec?.trim();
131
+ if (!spec) return [];
132
+
107
133
  return [{
108
134
  id: 'channel-details',
109
135
  type: 'channel',
110
136
  priority: 80,
111
- content: `## CHANNEL NAME\n${state.channelDetails.name}\n\n## CHANNEL SPECIFICATION\n${state.channelDetails.spec}`
137
+ content: `# Channel you are in: ${state.channelDetails.name}\n\n Channel Specification: ${spec}`,
112
138
  }];
113
139
  }
114
140
  }
@@ -117,44 +143,214 @@ class ThreadDetailsProvider implements ContextProvider {
117
143
  name = 'thread-details';
118
144
  async provide(state: OpenBotState): Promise<ContextItem[]> {
119
145
  if (!state.threadDetails) return [];
120
- return [{
121
- id: 'thread-details',
122
- type: 'thread',
123
- priority: 90,
124
- content: `## THREAD NAME\n${state.threadDetails.name}\n\n## THREAD SPECIFICATION\n${state.threadDetails.spec}`
125
- }];
146
+
147
+ // For now, this provider is a placeholder for future state-based assembly.
148
+ // It currently only surfaces the thread name to provide basic context.
149
+ return [
150
+ {
151
+ id: 'thread-details',
152
+ type: 'thread',
153
+ priority: 90,
154
+ content: `# Thread you are in: ${state.threadDetails.name}`,
155
+ },
156
+ ];
126
157
  }
127
158
  }
128
159
 
160
+ /**
161
+ * Surfaces the shared per-thread todo list. The list lives in
162
+ * `threadDetails.state.todos` and is owned by bus services — every agent in
163
+ * the thread reads from the same canonical source, which is how multi-agent
164
+ * autonomous flows stay coordinated.
165
+ */
166
+ class TodoProvider implements ContextProvider {
167
+ name = 'todos';
168
+ async provide(state: OpenBotState): Promise<ContextItem[]> {
169
+ const raw = (state.threadDetails?.state as Record<string, unknown> | undefined)?.todos;
170
+ const todos: TodoItem[] = Array.isArray(raw) ? (raw as TodoItem[]) : [];
171
+ if (todos.length === 0) return [];
172
+
173
+ const DISPLAY_RESULT_CAP = 2500;
174
+
175
+ const marker: Record<TodoItem['status'], string> = {
176
+ pending: '[ ]',
177
+ in_progress: '[~]',
178
+ done: '[x]',
179
+ cancelled: '[-]',
180
+ };
181
+ const formatted = todos
182
+ .map((t) => {
183
+ const assignee = t.assignee ? ` @${t.assignee}` : '';
184
+ let line = `- ${marker[t.status]} (${t.id})${assignee} ${t.content}`;
185
+ if (t.status === 'done' && t.result?.trim()) {
186
+ let snippet = t.result.trim();
187
+ if (snippet.length > DISPLAY_RESULT_CAP) {
188
+ snippet = `${snippet.slice(0, DISPLAY_RESULT_CAP)}…[truncated]`;
189
+ }
190
+ line += `\n Result: ${snippet}`;
191
+ }
192
+ return line;
193
+ })
194
+ .join('\n');
195
+
196
+ return [
197
+ {
198
+ id: 'todos',
199
+ type: 'todos',
200
+ priority: 92,
201
+ content:
202
+ `## Shared todo plan (thread state)\n` +
203
+ `Orchestrator authors with \`todo_write\`; assignees run one step at a time. ` +
204
+ `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` +
205
+ `${formatted}`,
206
+ },
207
+ ];
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Fetches relevant memories (global + active agent + active channel) and
213
+ * surfaces them at high priority so the LLM treats them as ground truth
214
+ * rather than chat history.
215
+ */
216
+ class MemoryProvider implements ContextProvider {
217
+ name = 'memory';
218
+ async provide(state: OpenBotState, storage?: Storage): Promise<ContextItem[]> {
219
+ if (!storage?.listMemories) return [];
220
+
221
+ try {
222
+ const scopes = ['global', `agent:${state.agentId}`];
223
+ if (state.channelId) scopes.push(`channel:${state.channelId}`);
224
+
225
+ const records = await storage.listMemories({ scopes, limit: 50 });
226
+ if (records.length === 0) return [];
227
+
228
+ const formatted = records
229
+ .map((r) => {
230
+ const tags = r.tags?.length ? ` [${r.tags.join(', ')}]` : '';
231
+ const scopeLabel = r.scope === 'global' ? 'global' : r.scope;
232
+ return `- (${scopeLabel}${tags}) ${r.content}`;
233
+ })
234
+ .join('\n');
235
+
236
+ return [
237
+ {
238
+ id: 'memory',
239
+ type: 'memory',
240
+ priority: 95,
241
+ content: `## Remembered facts\nTrust these unless the user contradicts them. Use \`forget\` to remove stale ones.\n\n${formatted}`,
242
+ },
243
+ ];
244
+ } catch (error) {
245
+ console.warn('[ContextEngine] MemoryProvider failed:', error);
246
+ return [];
247
+ }
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Event types we omit from the recent-events context block. They duplicate
253
+ * information already in the conversation history, are infrastructural
254
+ * noise, or are too large to be useful as a tail summary.
255
+ */
256
+ const NOISY_EVENT_PREFIXES = [
257
+ 'agent:invoke',
258
+ 'agent:output',
259
+ 'agent:run',
260
+ 'agent:active-runs',
261
+ 'client:ui',
262
+ 'stream:',
263
+ 'action:storage:get-',
264
+ 'action:storage:patch-',
265
+ ];
266
+
267
+ const MAX_RECENT_EVENTS = 20;
268
+ const MAX_EVENT_DATA_CHARS = 300;
269
+
270
+ const isNoisyEvent = (event: OpenBotEvent): boolean =>
271
+ NOISY_EVENT_PREFIXES.some((prefix) => event.type.startsWith(prefix));
272
+
273
+ const summarizeEvent = (event: OpenBotEvent): string => {
274
+ const data = (event as { data?: unknown }).data;
275
+ if (data === undefined) return `- ${event.type}`;
276
+ let payload: string;
277
+ try {
278
+ payload = typeof data === 'string' ? data : JSON.stringify(data);
279
+ } catch {
280
+ payload = '[unserialisable]';
281
+ }
282
+ if (payload.length > MAX_EVENT_DATA_CHARS) {
283
+ payload = `${payload.slice(0, MAX_EVENT_DATA_CHARS)}…`;
284
+ }
285
+ return `- ${event.type}: ${payload}`;
286
+ };
287
+
129
288
  class RecentEventsProvider implements ContextProvider {
130
289
  name = 'recent-events';
131
290
  async provide(state: OpenBotState, storage?: Storage): Promise<ContextItem[]> {
132
291
  if (!storage) return [];
133
- const items: ContextItem[] = [];
134
292
 
135
- // Fetch channel events if no thread, otherwise fetch thread events
136
293
  const channelId = state.channelId;
137
294
  const threadId = state.threadId;
138
295
 
139
296
  try {
140
297
  const events = await storage.getEvents({ channelId, threadId });
141
- if (events.length > 0) {
142
- const formattedEvents = events
143
- .slice(-20)
144
- .map((e) => `- ${e.type}: ${JSON.stringify((e as any).data || {})}`)
145
- .join('\n');
146
-
147
- items.push({
298
+ const filtered = events.filter((e) => !isNoisyEvent(e));
299
+ if (filtered.length === 0) return [];
300
+
301
+ const formatted = filtered.slice(-MAX_RECENT_EVENTS).map(summarizeEvent).join('\n');
302
+
303
+ return [
304
+ {
148
305
  id: threadId ? 'thread-events' : 'channel-events',
149
306
  type: 'events',
150
307
  priority: 70,
151
- content: `## ${threadId ? 'THREAD' : 'CHANNEL'} RECENT ACTIVITIES (events)\n${formattedEvents}`
152
- });
153
- }
308
+ content: `## ${threadId ? 'THREAD' : 'CHANNEL'} RECENT ACTIVITIES (events)\n${formatted}`,
309
+ },
310
+ ];
154
311
  } catch (error) {
155
- console.warn(`[ContextEngine] Failed to fetch events:`, error);
312
+ console.warn('[ContextEngine] Failed to fetch events:', error);
313
+ return [];
314
+ }
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Drops the lowest-priority items until the assembled prompt fits within the
320
+ * token budget. The first item with priority >= `keepFloor` is always kept,
321
+ * so the agent's own instructions can never be evicted. Stable on ties:
322
+ * later-emitted items go first.
323
+ */
324
+ export class TokenBudgetProcessor implements ContextProcessor {
325
+ name = 'token-budget';
326
+ /** Soft prompt budget in tokens (matches gpt-4o-mini's reasonable system slice). */
327
+ static DEFAULT_BUDGET = 8000;
328
+ /** Items at or above this priority are never dropped. */
329
+ static KEEP_FLOOR = 100;
330
+
331
+ constructor(
332
+ private budget: number = TokenBudgetProcessor.DEFAULT_BUDGET,
333
+ private keepFloor: number = TokenBudgetProcessor.KEEP_FLOOR,
334
+ ) {}
335
+
336
+ async process(items: ContextItem[]): Promise<ContextItem[]> {
337
+ const sorted = [...items].sort((a, b) => b.priority - a.priority);
338
+ const out: ContextItem[] = [];
339
+ let used = 0;
340
+
341
+ for (const item of sorted) {
342
+ const cost = estimateTokens(item.content);
343
+ if (item.priority >= this.keepFloor) {
344
+ out.push(item);
345
+ used += cost;
346
+ continue;
347
+ }
348
+ if (used + cost <= this.budget) {
349
+ out.push(item);
350
+ used += cost;
351
+ }
156
352
  }
157
353
 
158
- return items;
354
+ return out;
159
355
  }
160
356
  }
@@ -1,22 +1,16 @@
1
1
  import {
2
2
  AgentInvokeEvent,
3
- DelegateResultEvent,
4
- DelegationRequestEvent,
5
3
  HandoffRequestEvent,
6
4
  OpenBotEvent,
7
5
  OpenBotState,
8
6
  } from '../app/types.js';
9
7
  import { ensureEventId } from '../app/utils.js';
10
8
  import { storageService } from '../services/storage.js';
9
+ import { advanceAfterRun } from './todo-advance.js';
11
10
 
12
11
  export interface QueueItem {
13
12
  agentId: string;
14
13
  event: OpenBotEvent;
15
- delegationContext?: {
16
- parentAgentId: string;
17
- toolCallId: string;
18
- delegationWidgetId?: string;
19
- };
20
14
  }
21
15
 
22
16
  export interface QueueProcessorOptions {
@@ -68,12 +62,12 @@ export class QueueProcessor {
68
62
  Array.from(groups.entries()).map(async ([agentId, items]) => {
69
63
  // Run items for the SAME agent sequentially to preserve event order and state consistency.
70
64
  for (const item of items) {
71
- const { event: currentEvent, delegationContext } = item;
65
+ const { event: currentEvent } = item;
72
66
 
73
- // Track delegation/handoff requests queued in this step to avoid accidental duplicates.
67
+ // Track handoff requests queued in this step to avoid accidental duplicates.
74
68
  const queuedRequestKeys = new Set<string>();
75
69
  const queuedItems: QueueItem[] = [];
76
- const runOutputs: string[] = [];
70
+ let lastAgentOutput: string | undefined;
77
71
 
78
72
  const runOnEvent = async (chunk: OpenBotEvent, state: OpenBotState) => {
79
73
  // 0. Filter out echoed input events to prevent duplication in the UI/storage
@@ -81,26 +75,27 @@ export class QueueProcessor {
81
75
  return false;
82
76
  }
83
77
 
78
+ if (chunk.type === 'agent:output') {
79
+ const outMeta = chunk.meta as { agentId?: string } | undefined;
80
+ if (outMeta?.agentId === agentId) {
81
+ const content = chunk.data?.content;
82
+ if (typeof content === 'string' && content.trim()) {
83
+ lastAgentOutput = content.trim();
84
+ }
85
+ }
86
+ }
87
+
84
88
  // 1. Detect if a new thread was created and update the context for the rest of the loop
85
89
  if (chunk.type === 'action:create_thread:result' && chunk.data.success) {
86
90
  this.currentThreadId = chunk.data.threadId || this.currentThreadId;
87
91
  }
88
92
 
89
- // 2. Internal routing (handoff/delegation requests are internal — not forwarded)
90
- if (chunk.type === 'handoff:request' || chunk.type === 'delegation:request') {
91
- const isHandoff = chunk.type === 'handoff:request';
92
- const request = isHandoff
93
- ? (chunk as HandoffRequestEvent)
94
- : (chunk as DelegationRequestEvent);
93
+ // 2. Internal routing (handoff requests are internal — not forwarded)
94
+ if (chunk.type === 'handoff:request') {
95
+ const request = chunk as HandoffRequestEvent;
95
96
  const targetAgentId = request.data?.agentId;
96
- const toolCallId =
97
- typeof request.meta?.toolCallId === 'string'
98
- ? request.meta.toolCallId
99
- : undefined;
100
- const requestKey = isHandoff
101
- ? `handoff:${targetAgentId}:${request.data?.content ?? ''}`
102
- : `delegate:${toolCallId ?? 'missing'}:${targetAgentId}:${request.data?.content ?? ''}`;
103
-
97
+ const requestKey = `handoff:${targetAgentId}`;
98
+
104
99
  if (
105
100
  targetAgentId &&
106
101
  targetAgentId !== agentId &&
@@ -119,62 +114,11 @@ export class QueueProcessor {
119
114
  },
120
115
  } satisfies AgentInvokeEvent) as AgentInvokeEvent;
121
116
 
122
- if (isHandoff) {
123
- queuedItems.push({ agentId: targetAgentId, event: targetEvent });
124
- } else {
125
- if (!toolCallId) {
126
- // Emit error output (this triggers run start if not already started)
127
- await runOnEvent(
128
- ensureEventId({
129
- type: 'agent:output',
130
- data: {
131
- content:
132
- 'Delegation request ignored: missing toolCallId. Please retry delegation.',
133
- },
134
- meta: {
135
- agentId,
136
- threadId: this.currentThreadId,
137
- },
138
- } as OpenBotEvent),
139
- state,
140
- );
141
- return true;
142
- }
143
- const parentAgentId =
144
- typeof request.meta?.parentAgentId === 'string'
145
- ? request.meta.parentAgentId
146
- : agentId;
147
- const delegationWidgetId =
148
- typeof request.meta?.delegationWidgetId === 'string'
149
- ? request.meta.delegationWidgetId
150
- : undefined;
151
- queuedItems.push({
152
- agentId: targetAgentId,
153
- event: targetEvent,
154
- delegationContext: {
155
- parentAgentId,
156
- toolCallId,
157
- delegationWidgetId,
158
- },
159
- });
160
- }
117
+ queuedItems.push({ agentId: targetAgentId, event: targetEvent });
161
118
  }
162
119
  return false;
163
120
  }
164
121
 
165
- if (chunk.type === 'agent:output') {
166
- const content = chunk.data?.content;
167
- if (typeof content === 'string' && content.trim().length > 0) {
168
- runOutputs.push(content.trim());
169
- }
170
- }
171
-
172
- // For delegate mode, child agent execution is internal:
173
- // capture outputs for parent tool result, but don't stream child events to clients/storage.
174
- if (delegationContext) {
175
- return false;
176
- }
177
-
178
122
  // If we get here, the event is accepted and should be emitted.
179
123
  await this.options.onEvent(chunk, state);
180
124
  return true;
@@ -191,11 +135,11 @@ export class QueueProcessor {
191
135
  await this.options.onEvent(
192
136
  {
193
137
  type: 'agent:run:start',
194
- data: {
195
- runId: this.options.runId,
196
- agentId,
197
- channelId: this.options.channelId,
198
- threadId: this.currentThreadId
138
+ data: {
139
+ runId: this.options.runId,
140
+ agentId,
141
+ channelId: this.options.channelId,
142
+ threadId: this.currentThreadId,
199
143
  },
200
144
  },
201
145
  startState,
@@ -221,75 +165,42 @@ export class QueueProcessor {
221
165
  await this.options.onEvent(
222
166
  {
223
167
  type: 'agent:run:end',
224
- data: {
225
- runId: this.options.runId,
226
- agentId,
227
- channelId: this.options.channelId,
228
- threadId: this.currentThreadId
168
+ data: {
169
+ runId: this.options.runId,
170
+ agentId,
171
+ channelId: this.options.channelId,
172
+ threadId: this.currentThreadId,
229
173
  },
230
174
  },
231
175
  endState,
232
176
  );
233
- }
234
177
 
235
- if (delegationContext) {
236
- const summary =
237
- runOutputs.length > 0
238
- ? runOutputs.join('\n\n').slice(0, 4000)
239
- : `Delegated agent "${agentId}" completed with no textual output.`;
240
-
241
- const delegateResultEvent: DelegateResultEvent = ensureEventId({
242
- type: 'action:delegate:result',
243
- data: {
244
- success: true,
245
- agentId,
246
- summary,
247
- },
248
- meta: {
249
- toolCallId: delegationContext.toolCallId,
250
- agentId: delegationContext.parentAgentId,
178
+ // Autonomous todo advance: mark this agent's in_progress todo done
179
+ // and dispatch the next assignee, if any. Single trigger point,
180
+ // no reliance on the LLM remembering to call `todo_update`.
181
+ try {
182
+ const handoff = await advanceAfterRun({
183
+ storage: storageService,
184
+ channelId: this.options.channelId,
251
185
  threadId: this.currentThreadId,
252
- },
253
- } satisfies DelegateResultEvent) as DelegateResultEvent;
254
-
255
- if (delegationContext.delegationWidgetId) {
256
- await this.options.onEvent(
257
- ensureEventId({
258
- type: 'client:ui:widget',
259
- data: {
260
- kind: 'message',
261
- widgetId: delegationContext.delegationWidgetId,
262
- title: `Delegation complete: ${agentId}`,
263
- body:
264
- runOutputs.length > 0
265
- ? 'Delegated task finished. Parent agent is preparing final response.'
266
- : 'Delegated task finished with no textual output. Parent agent will continue.',
267
- state: 'submitted',
268
- metadata: {
269
- type: 'delegation:status',
270
- phase: 'completed',
271
- delegatedAgentId: agentId,
272
- },
273
- },
274
- meta: {
275
- agentId: delegationContext.parentAgentId,
276
- threadId: this.currentThreadId,
277
- },
278
- } as OpenBotEvent),
279
- await storageService.getOpenBotState({
280
- runId: this.options.runId,
281
- agentId: delegationContext.parentAgentId,
282
- channelId: this.options.channelId,
283
- threadId: this.currentThreadId,
284
- event: delegateResultEvent,
285
- }),
286
- );
186
+ endedAgentId: agentId,
187
+ lastAgentOutput,
188
+ });
189
+ if (handoff) {
190
+ const requestKey = `handoff:${handoff.agentId}`;
191
+ if (!queuedRequestKeys.has(requestKey)) {
192
+ queuedRequestKeys.add(requestKey);
193
+ const targetEvent = ensureEventId({
194
+ type: 'agent:invoke',
195
+ data: { role: 'user', content: handoff.content },
196
+ meta: { threadId: this.currentThreadId },
197
+ } satisfies AgentInvokeEvent) as AgentInvokeEvent;
198
+ queuedItems.push({ agentId: handoff.agentId, event: targetEvent });
199
+ }
200
+ }
201
+ } catch (error) {
202
+ console.warn('[queue] todo advance failed', error);
287
203
  }
288
-
289
- nextQueue.push({
290
- agentId: delegationContext.parentAgentId,
291
- event: delegateResultEvent,
292
- });
293
204
  }
294
205
 
295
206
  nextQueue.push(...queuedItems);
@@ -7,8 +7,8 @@ import { busServicesPlugin } from '../bus/services.js';
7
7
 
8
8
  /**
9
9
  * Enhances the agent's instructions with a list of other available agents the
10
- * orchestrator can hand off / delegate to. Agents that include the
11
- * `delegation` plugin will surface peers; agents without it can ignore this.
10
+ * orchestrator can hand off to. Agents that include the `delegation` plugin
11
+ * will surface peers; agents without it can ignore this.
12
12
  */
13
13
  export async function enhanceInstructions(state: OpenBotState) {
14
14
  const { agentId, agentDetails } = state;
@@ -23,12 +23,16 @@ export async function enhanceInstructions(state: OpenBotState) {
23
23
  .map((a) => `- **${a.id}**${a.description ? `: ${a.description}` : ''}`)
24
24
  .join('\n');
25
25
 
26
- const header = '### Available Agents for Handoff/Delegation:';
26
+ const header = '### Available Agents:';
27
27
  if (!agentDetails.instructions.includes(header)) {
28
- agentDetails.instructions +=
29
- `\n\n${header}\n${agentsList}\n\n` +
30
- 'Use `handoff` to transfer control to another agent. ' +
31
- 'Use `delegate` when you need a sub-result from another agent and want to continue after it returns.';
28
+ const hasHandoff = (agentDetails.pluginRefs || []).some((r) => r.id === 'delegation');
29
+ const hasTodo = (agentDetails.pluginRefs || []).some((r) => r.id === 'todo');
30
+ const usage = hasTodo
31
+ ? 'Use these ids as `assignee` when calling `todo_write` to plan multi-agent work.'
32
+ : hasHandoff
33
+ ? 'Use `handoff` to transfer control to another agent in this thread.'
34
+ : '';
35
+ agentDetails.instructions += `\n\n${header}\n${agentsList}${usage ? `\n\n${usage}` : ''}`;
32
36
  }
33
37
  } catch (error) {
34
38
  console.warn('[agent] Failed to enhance instructions', error);