openbot 0.3.5 → 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 (98) 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/assets/icon.svg +9 -3
  7. package/dist/bus/services.js +78 -132
  8. package/dist/harness/agent-invoke-run.js +44 -0
  9. package/dist/harness/agent-turn.js +99 -0
  10. package/dist/harness/channel-participants.js +40 -0
  11. package/dist/harness/constants.js +2 -0
  12. package/dist/harness/context-meter.js +97 -0
  13. package/dist/harness/context.js +98 -45
  14. package/dist/harness/dispatch.js +144 -0
  15. package/dist/harness/dispatcher.js +45 -156
  16. package/dist/harness/history.js +177 -0
  17. package/dist/harness/index.js +91 -0
  18. package/dist/harness/orchestration.js +88 -0
  19. package/dist/harness/participants.js +22 -0
  20. package/dist/harness/run-harness.js +154 -0
  21. package/dist/harness/run.js +98 -0
  22. package/dist/harness/runtime-factory.js +0 -34
  23. package/dist/harness/runtime.js +57 -0
  24. package/dist/harness/todo-dispatch.js +51 -0
  25. package/dist/harness/todos.js +5 -0
  26. package/dist/harness/turn.js +79 -0
  27. package/dist/plugins/approval/index.js +105 -149
  28. package/dist/plugins/delegation/index.js +119 -32
  29. package/dist/plugins/memory/index.js +103 -14
  30. package/dist/plugins/memory/service.js +152 -0
  31. package/dist/plugins/openbot/context.js +80 -0
  32. package/dist/plugins/openbot/history.js +98 -0
  33. package/dist/plugins/openbot/index.js +31 -0
  34. package/dist/plugins/openbot/runtime.js +317 -0
  35. package/dist/plugins/openbot/system-prompt.js +5 -0
  36. package/dist/plugins/plugin-manager/index.js +105 -0
  37. package/dist/plugins/storage/index.js +573 -0
  38. package/dist/plugins/storage/service.js +1159 -0
  39. package/dist/plugins/storage-tools/index.js +2 -2
  40. package/dist/plugins/thread-namer/index.js +72 -0
  41. package/dist/plugins/thread-naming/generate-title.js +44 -0
  42. package/dist/plugins/thread-naming/index.js +103 -0
  43. package/dist/plugins/threads/index.js +114 -0
  44. package/dist/plugins/todo/index.js +24 -25
  45. package/dist/plugins/ui/index.js +2 -32
  46. package/dist/registry/plugins.js +3 -9
  47. package/dist/services/plugins/domain.js +1 -0
  48. package/dist/services/plugins/plugin-cache.js +9 -0
  49. package/dist/services/plugins/registry.js +110 -0
  50. package/dist/services/plugins/service.js +177 -0
  51. package/dist/services/plugins/types.js +1 -0
  52. package/dist/services/process.js +29 -0
  53. package/dist/services/storage.js +41 -15
  54. package/dist/services/thread-naming.js +81 -0
  55. package/docs/agents.md +16 -10
  56. package/docs/architecture.md +2 -2
  57. package/docs/plugins.md +6 -15
  58. package/docs/templates/AGENT.example.md +7 -13
  59. package/package.json +1 -2
  60. package/src/app/agent-ids.ts +5 -0
  61. package/src/app/cli.ts +1 -1
  62. package/src/app/config.ts +1 -31
  63. package/src/app/server.ts +8 -16
  64. package/src/app/types.ts +70 -190
  65. package/src/assets/icon.svg +9 -3
  66. package/src/harness/index.ts +145 -0
  67. package/src/plugins/approval/index.ts +91 -189
  68. package/src/plugins/delegation/index.ts +136 -39
  69. package/src/plugins/memory/index.ts +112 -15
  70. package/src/{services/memory.ts → plugins/memory/service.ts} +1 -1
  71. package/src/plugins/openbot/context.ts +91 -0
  72. package/src/plugins/openbot/history.ts +107 -0
  73. package/src/plugins/openbot/index.ts +37 -0
  74. package/src/plugins/openbot/runtime.ts +384 -0
  75. package/src/plugins/openbot/system-prompt.ts +7 -0
  76. package/src/plugins/plugin-manager/index.ts +122 -0
  77. package/src/plugins/shell/index.ts +1 -1
  78. package/src/plugins/storage/index.ts +633 -0
  79. package/src/{services/storage.ts → plugins/storage/service.ts} +257 -72
  80. package/src/{bus/types.ts → services/plugins/domain.ts} +20 -7
  81. package/src/services/plugins/plugin-cache.ts +13 -0
  82. package/src/{registry/plugins.ts → services/plugins/registry.ts} +25 -27
  83. package/src/services/{plugins.ts → plugins/service.ts} +96 -2
  84. package/src/{bus/plugin.ts → services/plugins/types.ts} +3 -3
  85. package/src/bus/services.ts +0 -908
  86. package/src/harness/context.ts +0 -356
  87. package/src/harness/dispatcher.ts +0 -379
  88. package/src/harness/mcp.ts +0 -78
  89. package/src/harness/runtime-factory.ts +0 -129
  90. package/src/harness/todo-advance.ts +0 -128
  91. package/src/plugins/ai-sdk/index.ts +0 -41
  92. package/src/plugins/ai-sdk/runtime.ts +0 -468
  93. package/src/plugins/ai-sdk/system-prompt.ts +0 -18
  94. package/src/plugins/mcp/index.ts +0 -128
  95. package/src/plugins/storage-tools/index.ts +0 -90
  96. package/src/plugins/todo/index.ts +0 -64
  97. package/src/plugins/ui/index.ts +0 -227
  98. /package/src/{harness → services}/process.ts +0 -0
@@ -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,
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Sliding window: max number of messages we replay to the model on each
3
+ * invocation. Older turns stay on disk but are not sent. Keeps both the
4
+ * recent prompts and the prompt token budget bounded.
5
+ */
6
+ const MAX_WINDOW_MESSAGES = 80;
7
+ /**
8
+ * Reconstructs a valid `OpenBotMessage[]` chain from a raw event log.
9
+ * Handles grouping tool calls into assistant messages and matching results.
10
+ *
11
+ * This replaces the old `shortTermMessages` concept by treating the event log
12
+ * as the single source of truth for conversation history.
13
+ */
14
+ export function reconstructHistory(events) {
15
+ const messages = [];
16
+ for (const event of events) {
17
+ switch (event.type) {
18
+ case 'user:input':
19
+ messages.push({ role: 'user', content: event.data.content });
20
+ break;
21
+ case 'agent:output': {
22
+ const last = messages[messages.length - 1];
23
+ if (last && last.role === 'assistant') {
24
+ if (typeof last.content === 'string') {
25
+ last.content += '\n' + event.data.content;
26
+ }
27
+ else if (Array.isArray(last.content)) {
28
+ const textPart = last.content.find((p) => p.type === 'text');
29
+ if (textPart && textPart.type === 'text') {
30
+ textPart.text += '\n' + event.data.content;
31
+ }
32
+ else {
33
+ last.content.unshift({ type: 'text', text: event.data.content });
34
+ }
35
+ }
36
+ }
37
+ else {
38
+ messages.push({ role: 'assistant', content: event.data.content });
39
+ }
40
+ break;
41
+ }
42
+ case 'agent:invoke': {
43
+ const invokeEvent = event;
44
+ // Only treat as a message if it has content and is explicitly from a role
45
+ if (invokeEvent.data?.content && invokeEvent.data?.role) {
46
+ const role = invokeEvent.data.role;
47
+ messages.push({ role, content: invokeEvent.data.content });
48
+ }
49
+ break;
50
+ }
51
+ default:
52
+ // Handle tool calls (action:*)
53
+ if (event.type.startsWith('action:') && !event.type.endsWith(':result')) {
54
+ const toolName = event.type.slice(7);
55
+ const toolCallId = event.meta?.toolCallId;
56
+ if (!toolCallId)
57
+ break;
58
+ const toolCall = {
59
+ type: 'tool-call',
60
+ toolCallId,
61
+ toolName,
62
+ input: event.data,
63
+ };
64
+ const last = messages[messages.length - 1];
65
+ if (last && last.role === 'assistant') {
66
+ if (typeof last.content === 'string') {
67
+ last.content = [
68
+ { type: 'text', text: last.content },
69
+ toolCall,
70
+ ];
71
+ }
72
+ else {
73
+ last.content.push(toolCall);
74
+ }
75
+ }
76
+ else {
77
+ messages.push({
78
+ role: 'assistant',
79
+ content: [toolCall],
80
+ });
81
+ }
82
+ }
83
+ // Handle tool results (action:*:result)
84
+ else if (event.type.startsWith('action:') && event.type.endsWith(':result')) {
85
+ const toolName = event.type.slice(7, -7);
86
+ const toolCallId = event.meta?.toolCallId;
87
+ if (!toolCallId)
88
+ break;
89
+ const last = messages[messages.length - 1];
90
+ if (last && last.role === 'tool' && Array.isArray(last.content)) {
91
+ last.content.push({
92
+ type: 'tool-result',
93
+ toolCallId,
94
+ toolName,
95
+ output: event.data,
96
+ });
97
+ }
98
+ else {
99
+ messages.push({
100
+ role: 'tool',
101
+ content: [
102
+ {
103
+ type: 'tool-result',
104
+ toolCallId,
105
+ toolName,
106
+ output: event.data,
107
+ },
108
+ ],
109
+ });
110
+ }
111
+ }
112
+ break;
113
+ }
114
+ }
115
+ return repairAndWindow(messages);
116
+ }
117
+ /**
118
+ * Self-healing pass: every assistant tool_call must have a matching tool
119
+ * result before the next user/assistant turn. Also applies the sliding window.
120
+ */
121
+ function repairAndWindow(messages) {
122
+ const fulfilled = new Set();
123
+ for (const m of messages) {
124
+ if (m.role === 'tool' && Array.isArray(m.content)) {
125
+ for (const part of m.content) {
126
+ if (part.type === 'tool-result') {
127
+ fulfilled.add(part.toolCallId);
128
+ }
129
+ }
130
+ }
131
+ }
132
+ const repaired = [];
133
+ for (const m of messages) {
134
+ repaired.push(m);
135
+ if (m.role !== 'assistant' || typeof m.content === 'string')
136
+ continue;
137
+ const missingResults = [];
138
+ for (const part of m.content) {
139
+ if (part.type === 'tool-call' && !fulfilled.has(part.toolCallId)) {
140
+ missingResults.push({
141
+ type: 'tool-result',
142
+ toolCallId: part.toolCallId,
143
+ toolName: part.toolName,
144
+ output: {
145
+ success: false,
146
+ error: 'Tool result was lost (handler did not emit a matching :result event).',
147
+ },
148
+ });
149
+ fulfilled.add(part.toolCallId);
150
+ }
151
+ }
152
+ if (missingResults.length > 0) {
153
+ repaired.push({
154
+ role: 'tool',
155
+ content: missingResults,
156
+ });
157
+ }
158
+ }
159
+ if (repaired.length <= MAX_WINDOW_MESSAGES)
160
+ return repaired;
161
+ const tail = repaired.slice(-MAX_WINDOW_MESSAGES);
162
+ // Ensure the tail doesn't start with an orphan tool result
163
+ const knownAssistantCallIds = new Set();
164
+ for (const m of tail) {
165
+ if (m.role === 'assistant' && Array.isArray(m.content)) {
166
+ for (const part of m.content) {
167
+ if (part.type === 'tool-call')
168
+ knownAssistantCallIds.add(part.toolCallId);
169
+ }
170
+ }
171
+ }
172
+ return tail.filter((m) => {
173
+ if (m.role !== 'tool' || typeof m.content === 'string')
174
+ return true;
175
+ return m.content.some((part) => part.type === 'tool-result' && knownAssistantCallIds.has(part.toolCallId));
176
+ });
177
+ }
@@ -0,0 +1,91 @@
1
+ import { melony } from 'melony';
2
+ import { ensureEventId } from '../app/utils.js';
3
+ import { storageService } from '../plugins/storage/service.js';
4
+ import { STATE_AGENT_ID, ORCHESTRATOR_AGENT_ID } from '../app/agent-ids.js';
5
+ import { resolvePlugin } from '../services/plugins/registry.js';
6
+ export { STATE_AGENT_ID, ORCHESTRATOR_AGENT_ID };
7
+ async function emitEvent(chunk, state, { persistEvents, channelId, threadId, onEvent, parentAgentId, parentToolCallId, }) {
8
+ ensureEventId(chunk);
9
+ // Enrich event with parent metadata if not already present
10
+ if (parentAgentId || parentToolCallId) {
11
+ chunk.meta = {
12
+ ...chunk.meta,
13
+ parentAgentId: chunk.meta?.parentAgentId || parentAgentId,
14
+ parentToolCallId: chunk.meta?.parentToolCallId || parentToolCallId,
15
+ };
16
+ }
17
+ if (persistEvents) {
18
+ await storageService.storeEvent({
19
+ channelId: state?.channelId || channelId,
20
+ threadId: state?.threadId || threadId,
21
+ event: chunk,
22
+ });
23
+ }
24
+ await onEvent(chunk, state);
25
+ }
26
+ /**
27
+ * Runs a single agent turn.
28
+ * Fire and forget.
29
+ */
30
+ export async function runAgent(options) {
31
+ const { runId, agentId, event, channelId, threadId, onEvent } = options;
32
+ const persistEvents = options.persistEvents !== false;
33
+ const parentAgentId = event.meta?.parentAgentId;
34
+ const parentToolCallId = event.meta?.parentToolCallId;
35
+ const agentDetails = await storageService.getAgentDetails({ agentId });
36
+ const state = await storageService.getOpenBotState({
37
+ runId,
38
+ agentId,
39
+ channelId,
40
+ threadId,
41
+ event,
42
+ });
43
+ await emitEvent({
44
+ type: 'agent:run:start',
45
+ data: { runId, agentId, channelId, threadId },
46
+ }, state, { persistEvents, channelId, threadId, onEvent, parentAgentId, parentToolCallId });
47
+ try {
48
+ const pluginRefs = agentDetails.pluginRefs ?? [];
49
+ const tools = {};
50
+ for (const ref of pluginRefs) {
51
+ const plugin = await resolvePlugin(ref.id);
52
+ if (plugin?.toolDefinitions) {
53
+ Object.assign(tools, plugin.toolDefinitions);
54
+ }
55
+ }
56
+ const builder = melony().initialState(state);
57
+ for (const ref of pluginRefs) {
58
+ const plugin = await resolvePlugin(ref.id);
59
+ if (!plugin)
60
+ continue;
61
+ builder.use(plugin.factory({
62
+ agentId,
63
+ agentDetails,
64
+ config: ref.config ?? {},
65
+ storage: storageService,
66
+ tools,
67
+ }));
68
+ }
69
+ const runtime = builder.build();
70
+ const generator = runtime.run(event, { runId, state });
71
+ for await (const outputEvent of generator) {
72
+ await emitEvent(outputEvent, state, {
73
+ persistEvents,
74
+ channelId,
75
+ threadId,
76
+ onEvent,
77
+ parentAgentId,
78
+ parentToolCallId,
79
+ });
80
+ }
81
+ }
82
+ catch (error) {
83
+ console.error(`[harness] Error running agent ${agentId}:`, error);
84
+ }
85
+ finally {
86
+ await emitEvent({
87
+ type: 'agent:run:end',
88
+ data: { runId, agentId, channelId, threadId },
89
+ }, state, { persistEvents, channelId, threadId, onEvent, parentAgentId, parentToolCallId });
90
+ }
91
+ }
@@ -0,0 +1,88 @@
1
+ import { ORCHESTRATOR_AGENT_ID } from './context.js';
2
+ const readThreadState = (state) => state.threadDetails?.state ?? {};
3
+ export const readTodosFromState = (state) => {
4
+ const raw = readThreadState(state).todos;
5
+ return Array.isArray(raw) ? raw : [];
6
+ };
7
+ export const readOrchestration = (state) => {
8
+ const raw = readThreadState(state).orchestration;
9
+ if (!raw || typeof raw !== 'object')
10
+ return { active: false };
11
+ return raw;
12
+ };
13
+ export const isOrchestrationActive = (orch) => orch.active === true;
14
+ export const hasActiveTodos = (state) => readTodosFromState(state).some((t) => t.status === 'pending' || t.status === 'in_progress');
15
+ export const readPendingDelegation = (state) => readOrchestration(state).pendingDelegation;
16
+ async function patchOrchestration(storage, state, patch) {
17
+ if (!state.threadId)
18
+ throw new Error('No active thread');
19
+ const current = readOrchestration(state);
20
+ const next = { ...current, ...patch };
21
+ await storage.patchThreadState({
22
+ channelId: state.channelId,
23
+ threadId: state.threadId,
24
+ state: { orchestration: next },
25
+ });
26
+ state.threadDetails = await storage.getThreadDetails({
27
+ channelId: state.channelId,
28
+ threadId: state.threadId,
29
+ });
30
+ }
31
+ export async function activateOrchestration(storage, state) {
32
+ const current = readOrchestration(state);
33
+ if (current.active)
34
+ return;
35
+ await patchOrchestration(storage, state, { active: true, startedAt: Date.now() });
36
+ }
37
+ export async function deactivateOrchestration(storage, state) {
38
+ const current = readOrchestration(state);
39
+ if (!current.active)
40
+ return;
41
+ await patchOrchestration(storage, state, {
42
+ active: false,
43
+ pendingDelegation: undefined,
44
+ });
45
+ }
46
+ export async function queueDelegation(storage, state, delegation) {
47
+ await patchOrchestration(storage, state, {
48
+ active: true,
49
+ startedAt: readOrchestration(state).startedAt ?? Date.now(),
50
+ pendingDelegation: delegation,
51
+ });
52
+ }
53
+ export async function clearPendingDelegation(storage, state) {
54
+ const current = readOrchestration(state);
55
+ if (!current.pendingDelegation)
56
+ return;
57
+ await patchOrchestration(storage, state, { pendingDelegation: undefined });
58
+ }
59
+ export function buildWorkerReviewPrompt(workerAgentId, output) {
60
+ const body = output && output.trim()
61
+ ? output.trim()
62
+ : '(The worker produced no text output.)';
63
+ return [
64
+ '[Orchestrator — worker step completed]',
65
+ '',
66
+ `Worker: @${workerAgentId}`,
67
+ 'Output:',
68
+ '---',
69
+ body,
70
+ '---',
71
+ '',
72
+ 'Review the shared todo plan, update statuses with `todo_write` if needed,',
73
+ 'tell the user if appropriate, then either call `delegate_to_agent` for the next step',
74
+ 'or confirm the goal is complete.',
75
+ ].join('\n');
76
+ }
77
+ export function buildPlanContinuePrompt() {
78
+ return [
79
+ '[Orchestrator — continue plan execution]',
80
+ '',
81
+ 'You have an active todo plan with remaining work.',
82
+ 'Review the plan and either call `delegate_to_agent` for the next step',
83
+ 'or handle the work yourself. Update todos to reflect progress.',
84
+ ].join('\n');
85
+ }
86
+ export function isOrchestratorAgent(agentId) {
87
+ return agentId === ORCHESTRATOR_AGENT_ID;
88
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Channel `participants` (from `state.json`) scope which agents may collaborate
3
+ * in that channel. Used for system-prompt hints and dispatch guards.
4
+ */
5
+ /** Solo DM: exactly one participant and it is the acting agent (no peer bots). */
6
+ export function isDmSoloChannel(participants, actingAgentId) {
7
+ return participants.length === 1 && participants[0] === actingAgentId;
8
+ }
9
+ /**
10
+ * When `participants` is non-empty, dispatch targets must appear in that list.
11
+ * Solo DM forbids targeting any agent other than yourself.
12
+ */
13
+ export function isParticipantDispatchAllowed(participants, actingAgentId, targetAgentId) {
14
+ if (participants.length === 0)
15
+ return true;
16
+ if (!participants.includes(targetAgentId))
17
+ return false;
18
+ if (isDmSoloChannel(participants, actingAgentId) && targetAgentId !== actingAgentId) {
19
+ return false;
20
+ }
21
+ return true;
22
+ }