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