openbot 0.3.1 → 0.3.3

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 (38) hide show
  1. package/dist/app/cli.js +1 -1
  2. package/dist/app/server.js +1 -4
  3. package/dist/bus/services.js +106 -10
  4. package/dist/harness/context.js +66 -6
  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 +4 -11
  10. package/dist/plugins/ai-sdk/system-prompt.js +18 -3
  11. package/dist/plugins/delegation/index.js +7 -46
  12. package/dist/plugins/storage-tools/index.js +2 -11
  13. package/dist/plugins/todo/index.js +54 -0
  14. package/dist/plugins/workflow/index.js +65 -0
  15. package/dist/registry/plugins.js +2 -2
  16. package/dist/services/storage.js +3 -31
  17. package/dist/workflow/service.js +106 -0
  18. package/dist/workflow/types.js +3 -0
  19. package/docs/plugins.md +0 -1
  20. package/package.json +1 -1
  21. package/src/app/cli.ts +1 -1
  22. package/src/app/server.ts +3 -4
  23. package/src/app/types.ts +80 -45
  24. package/src/bus/plugin.ts +0 -2
  25. package/src/bus/services.ts +133 -12
  26. package/src/bus/types.ts +0 -4
  27. package/src/harness/context.ts +73 -10
  28. package/src/harness/queue-processor.ts +54 -143
  29. package/src/harness/runtime-factory.ts +11 -7
  30. package/src/harness/todo-advance.ts +128 -0
  31. package/src/plugins/ai-sdk/index.ts +0 -3
  32. package/src/plugins/ai-sdk/runtime.ts +284 -300
  33. package/src/plugins/ai-sdk/system-prompt.ts +18 -4
  34. package/src/plugins/delegation/index.ts +7 -50
  35. package/src/plugins/storage-tools/index.ts +8 -19
  36. package/src/plugins/todo/index.ts +64 -0
  37. package/src/registry/plugins.ts +2 -3
  38. package/src/services/storage.ts +2 -49
@@ -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);
@@ -0,0 +1,128 @@
1
+ import { TodoItem, TodoStatus } from '../app/types.js';
2
+ import type { Storage } from '../bus/types.js';
3
+
4
+ /** Stored on each todo and inlined into the next assignee's invoke payload. */
5
+ const RESULT_MAX_CHARS = 12000;
6
+
7
+ /**
8
+ * Shared helpers that drive the autonomous todo loop. The queue processor
9
+ * calls `advanceAfterRun` once per `agent:run:end`; that is the only place
10
+ * todos are completed and dispatched, which keeps the autonomous flow
11
+ * single-threaded and easy to reason about.
12
+ */
13
+
14
+ export const readTodosFromState = (state: unknown): TodoItem[] => {
15
+ const raw = (state as Record<string, unknown> | undefined)?.todos;
16
+ return Array.isArray(raw) ? (raw as TodoItem[]) : [];
17
+ };
18
+
19
+ export function truncateTodoResult(text: string, maxChars = RESULT_MAX_CHARS): string | undefined {
20
+ const trimmed = text.trim();
21
+ if (!trimmed) return undefined;
22
+ if (trimmed.length <= maxChars) return trimmed;
23
+ return `${trimmed.slice(0, maxChars)}\n…[truncated]`;
24
+ }
25
+
26
+ export interface AdvanceResult {
27
+ /** Updated todo list (after marking finished + flipping next to in_progress). */
28
+ todos: TodoItem[];
29
+ /** Next agent to invoke, if any. */
30
+ handoff: { agentId: string; content: string; todoId: string } | null;
31
+ }
32
+
33
+ /**
34
+ * Apply a single advance step:
35
+ * 1. If a todo is `in_progress` and `assignee` matches the agent whose run
36
+ * just ended, mark it `done` and attach `result` from `lastOutput` when present.
37
+ * 2. Pick the next `pending` todo with an `assignee` and flip it to
38
+ * `in_progress`. That assignee gets handed off to; `invoke content` includes
39
+ * the previous step output when available so agents without short-term
40
+ * history still see prior work.
41
+ *
42
+ * If a todo is already `in_progress` and the just-ended agent wasn't its
43
+ * assignee, leave it alone — someone else is working.
44
+ */
45
+ export function advanceTodos(
46
+ todos: TodoItem[],
47
+ endedAgentId: string,
48
+ lastOutput?: string,
49
+ ): AdvanceResult {
50
+ const now = Date.now();
51
+ const truncated = truncateTodoResult(lastOutput ?? '');
52
+
53
+ let completedOutput: string | undefined;
54
+
55
+ let working = todos.map((t) => {
56
+ if (t.status === 'in_progress' && t.assignee === endedAgentId) {
57
+ completedOutput = truncated;
58
+ return {
59
+ ...t,
60
+ status: 'done' as TodoStatus,
61
+ updatedAt: now,
62
+ ...(truncated !== undefined ? { result: truncated } : {}),
63
+ };
64
+ }
65
+ return t;
66
+ });
67
+
68
+ if (working.some((t) => t.status === 'in_progress')) {
69
+ return { todos: working, handoff: null };
70
+ }
71
+
72
+ const idx = working.findIndex((t) => t.status === 'pending' && t.assignee);
73
+ if (idx === -1) return { todos: working, handoff: null };
74
+
75
+ const picked = working[idx];
76
+ working = working.map((t, i) =>
77
+ i === idx ? { ...t, status: 'in_progress' as TodoStatus, updatedAt: now } : t,
78
+ );
79
+
80
+ const invokeContent =
81
+ completedOutput !== undefined && completedOutput !== ''
82
+ ? `${picked.content}\n\n--- Output from previous step ---\n${completedOutput}`
83
+ : picked.content;
84
+
85
+ return {
86
+ todos: working,
87
+ handoff: {
88
+ agentId: picked.assignee!,
89
+ content: invokeContent,
90
+ todoId: picked.id,
91
+ },
92
+ };
93
+ }
94
+
95
+ export async function advanceAfterRun(options: {
96
+ storage: Storage;
97
+ channelId: string;
98
+ threadId?: string;
99
+ endedAgentId: string;
100
+ lastAgentOutput?: string;
101
+ }): Promise<AdvanceResult['handoff']> {
102
+ const { storage, channelId, threadId, endedAgentId, lastAgentOutput } = options;
103
+ if (!threadId) return null;
104
+
105
+ const details = await storage.getThreadDetails({ channelId, threadId });
106
+ const todos = readTodosFromState(details?.state);
107
+ if (todos.length === 0) return null;
108
+
109
+ const { todos: nextList, handoff } = advanceTodos(todos, endedAgentId, lastAgentOutput);
110
+
111
+ const changed =
112
+ nextList.length !== todos.length ||
113
+ nextList.some((t, i) => {
114
+ const u = todos[i];
115
+ if (!u) return true;
116
+ return (
117
+ t.status !== u.status ||
118
+ t.updatedAt !== u.updatedAt ||
119
+ t.result !== u.result ||
120
+ t.assignee !== u.assignee ||
121
+ t.content !== u.content
122
+ );
123
+ });
124
+ if (changed) {
125
+ await storage.patchThreadState({ channelId, threadId, state: { todos: nextList } });
126
+ }
127
+ return handoff;
128
+ }
@@ -1,6 +1,5 @@
1
1
  import type { Plugin } from '../../bus/plugin.js';
2
2
  import { aiSdkRuntime } from './runtime.js';
3
- import { AI_SDK_SYSTEM_PROMPT } from './system-prompt.js';
4
3
 
5
4
  /**
6
5
  * `ai-sdk` — generic LLM runtime plugin built on the Vercel AI SDK.
@@ -14,7 +13,6 @@ export const aiSdkPlugin: Plugin = {
14
13
  name: 'AI SDK Runtime',
15
14
  description:
16
15
  'Generic LLM runtime built on the Vercel AI SDK. Consumes tools contributed by other plugins.',
17
- defaultInstructions: AI_SDK_SYSTEM_PROMPT,
18
16
  configSchema: {
19
17
  type: 'object',
20
18
  properties: {
@@ -34,7 +32,6 @@ export const aiSdkPlugin: Plugin = {
34
32
 
35
33
  return aiSdkRuntime({
36
34
  model,
37
- system: agentDetails.instructions || AI_SDK_SYSTEM_PROMPT,
38
35
  storage,
39
36
  toolDefinitions: tools,
40
37
  });