openbot 0.3.6 → 0.4.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 (104) 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 +10 -19
  5. package/dist/app/server.js +208 -17
  6. package/dist/bus/services.js +34 -124
  7. package/dist/harness/agent-invoke-run.js +44 -0
  8. package/dist/harness/agent-turn.js +99 -0
  9. package/dist/harness/channel-participants.js +40 -0
  10. package/dist/harness/constants.js +2 -0
  11. package/dist/harness/context-meter.js +97 -0
  12. package/dist/harness/context.js +95 -47
  13. package/dist/harness/dispatch.js +144 -0
  14. package/dist/harness/dispatcher.js +45 -156
  15. package/dist/harness/history.js +177 -0
  16. package/dist/harness/index.js +109 -0
  17. package/dist/harness/orchestration.js +88 -0
  18. package/dist/harness/participants.js +22 -0
  19. package/dist/harness/run-harness.js +154 -0
  20. package/dist/harness/run.js +98 -0
  21. package/dist/harness/runtime-factory.js +0 -34
  22. package/dist/harness/runtime.js +57 -0
  23. package/dist/harness/todo-dispatch.js +51 -0
  24. package/dist/harness/todos.js +5 -0
  25. package/dist/harness/turn.js +79 -0
  26. package/dist/plugins/approval/index.js +120 -149
  27. package/dist/plugins/bash/index.js +195 -0
  28. package/dist/plugins/delegation/index.js +121 -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 +125 -0
  32. package/dist/plugins/openbot/history.js +144 -0
  33. package/dist/plugins/openbot/index.js +71 -0
  34. package/dist/plugins/openbot/runtime.js +381 -0
  35. package/dist/plugins/openbot/system-prompt.js +25 -0
  36. package/dist/plugins/plugin-manager/index.js +189 -0
  37. package/dist/plugins/shell/index.js +2 -1
  38. package/dist/plugins/storage/files.js +67 -0
  39. package/dist/plugins/storage/index.js +750 -0
  40. package/dist/plugins/storage/service.js +1316 -0
  41. package/dist/plugins/storage-tools/index.js +2 -2
  42. package/dist/plugins/thread-namer/index.js +72 -0
  43. package/dist/plugins/thread-naming/generate-title.js +44 -0
  44. package/dist/plugins/thread-naming/index.js +103 -0
  45. package/dist/plugins/threads/index.js +114 -0
  46. package/dist/plugins/todo/index.js +24 -25
  47. package/dist/plugins/ui/index.js +109 -180
  48. package/dist/registry/plugins.js +3 -9
  49. package/dist/services/abort.js +43 -0
  50. package/dist/services/plugins/domain.js +1 -0
  51. package/dist/services/plugins/plugin-cache.js +9 -0
  52. package/dist/services/plugins/registry.js +112 -0
  53. package/dist/services/plugins/service.js +232 -0
  54. package/dist/services/plugins/types.js +1 -0
  55. package/dist/services/process.js +29 -0
  56. package/dist/services/storage.js +11 -10
  57. package/dist/services/thread-naming.js +81 -0
  58. package/docs/agents.md +15 -12
  59. package/docs/architecture.md +2 -2
  60. package/docs/plugins.md +29 -17
  61. package/docs/templates/AGENT.example.md +8 -14
  62. package/package.json +1 -2
  63. package/src/app/agent-ids.ts +5 -0
  64. package/src/app/cli.ts +1 -1
  65. package/src/app/config.ts +14 -31
  66. package/src/app/server.ts +243 -19
  67. package/src/app/types.ts +331 -187
  68. package/src/harness/index.ts +166 -0
  69. package/src/plugins/approval/index.ts +107 -188
  70. package/src/plugins/bash/index.ts +232 -0
  71. package/src/plugins/delegation/index.ts +139 -39
  72. package/src/plugins/memory/index.ts +112 -15
  73. package/src/{services/memory.ts → plugins/memory/service.ts} +1 -1
  74. package/src/plugins/openbot/context.ts +140 -0
  75. package/src/plugins/openbot/history.ts +158 -0
  76. package/src/plugins/openbot/index.ts +79 -0
  77. package/src/plugins/openbot/runtime.ts +478 -0
  78. package/src/plugins/openbot/system-prompt.ts +27 -0
  79. package/src/plugins/plugin-manager/index.ts +224 -0
  80. package/src/plugins/storage/files.ts +81 -0
  81. package/src/plugins/storage/index.ts +823 -0
  82. package/src/{services/storage.ts → plugins/storage/service.ts} +485 -105
  83. package/src/plugins/ui/index.ts +117 -221
  84. package/src/services/abort.ts +46 -0
  85. package/src/{bus/types.ts → services/plugins/domain.ts} +50 -8
  86. package/src/services/plugins/plugin-cache.ts +13 -0
  87. package/src/{registry/plugins.ts → services/plugins/registry.ts} +28 -28
  88. package/src/services/plugins/service.ts +318 -0
  89. package/src/{bus/plugin.ts → services/plugins/types.ts} +7 -3
  90. package/src/bus/services.ts +0 -954
  91. package/src/harness/context.ts +0 -365
  92. package/src/harness/dispatcher.ts +0 -379
  93. package/src/harness/mcp.ts +0 -78
  94. package/src/harness/runtime-factory.ts +0 -129
  95. package/src/harness/todo-advance.ts +0 -128
  96. package/src/plugins/ai-sdk/index.ts +0 -41
  97. package/src/plugins/ai-sdk/runtime.ts +0 -468
  98. package/src/plugins/ai-sdk/system-prompt.ts +0 -18
  99. package/src/plugins/mcp/index.ts +0 -128
  100. package/src/plugins/shell/index.ts +0 -123
  101. package/src/plugins/storage-tools/index.ts +0 -90
  102. package/src/plugins/todo/index.ts +0 -64
  103. package/src/services/plugins.ts +0 -133
  104. /package/src/{harness → services}/process.ts +0 -0
@@ -1,159 +1,130 @@
1
- import { storageService } from '../../services/storage.js';
2
- export const DEFAULT_APPROVAL_RULES = [
3
- {
4
- action: 'action:shell_exec',
5
- denyEvent: 'action:shell_exec:result',
6
- message: 'The agent wants to run a terminal command.',
7
- detailKeys: ['command', 'cwd', 'shell', 'timeoutMs'],
8
- hiddenKeys: ['env'],
9
- denyData: {
10
- exitCode: null,
11
- stdout: '',
12
- stderr: 'Command execution was denied by the user.',
13
- timedOut: false,
14
- },
15
- },
16
- ];
17
- const asRecord = (value) => value && typeof value === 'object' && !Array.isArray(value)
18
- ? value
19
- : {};
20
- const getApprovalsFromState = (state) => {
21
- const source = state.threadDetails?.state ?? state.channelDetails?.state;
22
- const stateRecord = asRecord(source);
23
- return asRecord(stateRecord.approvals);
24
- };
25
- const persistApprovals = async (state, approvals) => {
26
- if (state.threadId) {
27
- await storageService.patchThreadState({
28
- channelId: state.channelId,
29
- threadId: state.threadId,
30
- state: { approvals },
31
- });
32
- return;
33
- }
34
- await storageService.patchChannelState({
35
- channelId: state.channelId,
36
- state: { approvals },
37
- });
38
- };
39
- const buildApprovalPlugin = (rules) => (builder) => {
40
- for (const rule of rules) {
41
- builder.on(rule.action, async function* (event, context) {
42
- const meta = asRecord(event.meta);
43
- if (meta.approvalStatus === 'approved')
1
+ import { randomUUID } from 'node:crypto';
2
+ /**
3
+ * `approval` — gates protected tool calls behind a UI confirmation widget.
4
+ *
5
+ * This is a simplified version that intercepts specified actions (default: bash)
6
+ * and requires user approval before they are allowed to proceed.
7
+ */
8
+ // In-memory tracking for pending approval IDs with TTL (shared across plugin instances)
9
+ const pendingApprovals = new Map();
10
+ const TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
11
+ export const approvalPlugin = {
12
+ id: 'approval',
13
+ name: 'Approval',
14
+ description: 'Gate protected tool calls behind a UI confirmation widget.',
15
+ factory: ({ config, storage }) => (builder) => {
16
+ // Actions that require approval. Defaults to bash.
17
+ const actionsToApprove = config.actions || ['action:bash'];
18
+ for (const action of actionsToApprove) {
19
+ builder.intercept(action, (event, context) => {
20
+ // If already approved in this flow, let it pass to the actual handler
21
+ if (event.meta?.approvalStatus === 'approved')
22
+ return event;
23
+ // Otherwise, intercept and ask for approval via a UI widget
24
+ const displayData = JSON.stringify(event?.data) || '';
25
+ const widgetId = randomUUID();
26
+ pendingApprovals.set(widgetId, Date.now());
27
+ return {
28
+ type: 'client:ui:widget',
29
+ data: {
30
+ widgetId,
31
+ kind: 'message',
32
+ title: `The agent wants to perform \`${action}\``,
33
+ body: displayData,
34
+ metadata: {
35
+ type: 'approval:request',
36
+ originalEvent: event,
37
+ },
38
+ actions: [
39
+ { id: 'approve', label: 'Approve', variant: 'primary' },
40
+ { id: 'deny', label: 'Deny', variant: 'danger' },
41
+ ],
42
+ },
43
+ meta: { agentId: context.state.agentId, threadId: context.state.threadId },
44
+ };
45
+ });
46
+ }
47
+ // Handle the user's response from the UI widget
48
+ builder.on('client:ui:widget:response', async function* (event, context) {
49
+ const { widgetId, actionId } = event.data;
50
+ const metadata = event.data?.metadata;
51
+ if (metadata?.type !== 'approval:request')
44
52
  return;
45
- const eventData = asRecord(event.data);
46
- const eventMeta = meta;
47
- const approvalId = `approval_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
48
- const widgetId = `widget_${approvalId}`;
49
- const executeEvent = rule.executeEvent || rule.action;
50
- const denyEvent = rule.denyEvent || `${rule.action}:result`;
51
- const denyData = rule.denyData || {};
52
- const hiddenKeys = new Set(rule.hiddenKeys || []);
53
- const detailKeys = rule.detailKeys || Object.keys(eventData);
54
- const details = detailKeys
55
- .filter((key) => !hiddenKeys.has(key))
56
- .map((key) => `- ${key}: ${String(eventData[key] ?? '')}`)
57
- .join('\n');
58
- const pendingApprovals = getApprovalsFromState(context.state);
59
- pendingApprovals[approvalId] = {
60
- id: approvalId,
61
- action: rule.action,
62
- executeEvent,
63
- denyEvent,
64
- denyData,
65
- payload: eventData,
66
- meta: eventMeta,
67
- message: rule.message || `Approval required for ${rule.action}.`,
68
- createdAt: new Date().toISOString(),
69
- status: 'pending',
70
- };
71
- await persistApprovals(context.state, pendingApprovals);
53
+ // Verify the widget is still pending and hasn't expired
54
+ if (!widgetId || !pendingApprovals.has(widgetId)) {
55
+ console.warn(`[approval] Received response for unknown or already handled widget: ${widgetId}`);
56
+ return;
57
+ }
58
+ const timestamp = pendingApprovals.get(widgetId);
59
+ if (Date.now() - timestamp > TTL_MS) {
60
+ pendingApprovals.delete(widgetId);
61
+ console.warn(`[approval] Received response for expired widget: ${widgetId}`);
62
+ return;
63
+ }
64
+ // Mark as handled
65
+ pendingApprovals.delete(widgetId);
66
+ const originalEvent = metadata.originalEvent;
67
+ const approved = actionId === 'approve';
68
+ const displayData = JSON.stringify(event?.data) || '';
69
+ // Yield a "responded" widget update to the UI
72
70
  yield {
73
71
  type: 'client:ui:widget',
74
72
  data: {
75
- kind: 'choice',
76
73
  widgetId,
77
- title: 'Approval Required',
78
- body: `${rule.message || 'A protected action requires approval.'}${details ? `\n\n${details}` : ''}`,
79
- metadata: { type: 'approval:request', approvalId, action: rule.action },
80
- actions: [
81
- { id: 'approve', label: 'Approve', variant: 'primary' },
82
- { id: 'deny', label: 'Deny', variant: 'danger' },
83
- ],
74
+ kind: 'message',
75
+ title: `Action ${approved ? 'Approved' : 'Denied'}`,
76
+ body: displayData,
77
+ state: approved ? 'submitted' : 'cancelled',
78
+ display: 'collapsed',
79
+ disabled: true,
80
+ actions: [], // Clear actions to disable buttons in UI
84
81
  },
85
- meta: { ...(event.meta || {}), agentId: context.state.agentId },
86
- };
87
- yield {
88
- type: 'agent:output',
89
- data: { content: `Waiting for approval before running \`${rule.action}\`.` },
90
- meta: { ...(event.meta || {}), agentId: context.state.agentId },
82
+ meta: { agentId: context.state.agentId, threadId: context.state.threadId },
91
83
  };
92
- context.suspend();
84
+ if (approved) {
85
+ // Re-emit the original event with approved status so the actual handler can run
86
+ yield {
87
+ ...originalEvent,
88
+ meta: {
89
+ ...(originalEvent.meta || {}),
90
+ approvalStatus: 'approved',
91
+ },
92
+ };
93
+ }
94
+ else {
95
+ // Manually store the original event with denied status so it's recorded in history
96
+ // but NOT re-emitted to the pipeline (to avoid actual execution).
97
+ if (storage) {
98
+ await storage.storeEvent({
99
+ channelId: context.state.channelId,
100
+ threadId: context.state.threadId,
101
+ event: {
102
+ ...originalEvent,
103
+ meta: {
104
+ ...(originalEvent.meta || {}),
105
+ approvalStatus: 'denied',
106
+ },
107
+ },
108
+ });
109
+ }
110
+ // Emit a failure result event for the denied action to clear the pending tool batch
111
+ yield {
112
+ type: `${originalEvent.type}:result`,
113
+ data: {
114
+ success: false,
115
+ error: 'Action denied by user.',
116
+ stderr: 'Action denied by user.',
117
+ output: 'Action denied by user.',
118
+ },
119
+ meta: originalEvent.meta,
120
+ };
121
+ yield {
122
+ type: 'agent:output',
123
+ data: { content: `Action \`${originalEvent.type}\` was denied.` },
124
+ meta: { agentId: context.state.agentId },
125
+ };
126
+ }
93
127
  });
94
- }
95
- builder.on('client:ui:widget:response', async function* (event, context) {
96
- const metadata = asRecord(event.data?.metadata);
97
- if (metadata.type !== 'approval:request')
98
- return;
99
- const approvalId = String(metadata.approvalId || '');
100
- if (!approvalId)
101
- return;
102
- const approvals = getApprovalsFromState(context.state);
103
- const approval = approvals[approvalId];
104
- if (!approval || approval.status !== 'pending') {
105
- yield {
106
- type: 'agent:output',
107
- data: { content: 'Approval request not found or already resolved.' },
108
- meta: { ...(event.meta || {}), agentId: context.state.agentId },
109
- };
110
- return;
111
- }
112
- const approved = event.data.actionId === 'approve';
113
- approvals[approvalId] = {
114
- ...approval,
115
- status: approved ? 'approved' : 'denied',
116
- };
117
- await persistApprovals(context.state, approvals);
118
- if (approved) {
119
- yield {
120
- type: approval.executeEvent,
121
- data: approval.payload,
122
- meta: {
123
- ...(approval.meta || {}),
124
- approvalId,
125
- approvalStatus: 'approved',
126
- },
127
- };
128
- return;
129
- }
130
- yield {
131
- type: approval.denyEvent,
132
- data: {
133
- success: false,
134
- approved: false,
135
- error: 'Action denied by user approval.',
136
- ...approval.denyData,
137
- },
138
- meta: { ...(approval.meta || {}), approvalId },
139
- };
140
- yield {
141
- type: 'agent:output',
142
- data: { content: 'Action denied by user approval.' },
143
- meta: { ...(event.meta || {}), agentId: context.state.agentId },
144
- };
145
- });
146
- };
147
- const readRules = (config) => {
148
- const raw = config.rules;
149
- if (!Array.isArray(raw))
150
- return DEFAULT_APPROVAL_RULES;
151
- return raw.filter((entry) => !!entry && typeof entry === 'object' && typeof entry.action === 'string');
152
- };
153
- export const approvalPlugin = {
154
- id: 'approval',
155
- name: 'Approval',
156
- description: 'Gate protected tool calls (e.g. shell_exec) behind a UI confirmation prompt.',
157
- factory: ({ config }) => buildApprovalPlugin(readRules(config)),
128
+ },
158
129
  };
159
130
  export default approvalPlugin;
@@ -0,0 +1,195 @@
1
+ import { z } from 'zod';
2
+ import { spawn } from 'node:child_process';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { resolvePath } from '../../app/config.js';
5
+ const bashToolDefinitions = {
6
+ bash: {
7
+ description: 'Execute a bash command in a stateful session. The working directory and environment variables persist between calls. Use this for all system tasks, file operations, and running development servers.',
8
+ inputSchema: z.object({
9
+ command: z.string().describe('The bash command to execute.'),
10
+ restart: z
11
+ .boolean()
12
+ .optional()
13
+ .describe('Restart the bash session before running the command.'),
14
+ }),
15
+ },
16
+ bash_stop: {
17
+ description: 'Stop the bash session for the current or specified channel.',
18
+ inputSchema: z.object({
19
+ channelId: z.string().optional().describe('The channel ID to stop the session for.'),
20
+ }),
21
+ },
22
+ bash_list_sessions: {
23
+ description: 'List all active bash sessions.',
24
+ inputSchema: z.object({}),
25
+ },
26
+ };
27
+ const sessions = new Map();
28
+ const getSession = (channelId, initialCwd) => {
29
+ let session = sessions.get(channelId);
30
+ if (!session) {
31
+ const childProcess = spawn('bash', ['--login'], {
32
+ cwd: initialCwd,
33
+ env: { ...process.env, PS1: '' },
34
+ stdio: ['pipe', 'pipe', 'pipe'],
35
+ });
36
+ session = {
37
+ process: childProcess,
38
+ cwd: initialCwd,
39
+ lastActivity: Date.now(),
40
+ };
41
+ sessions.set(channelId, session);
42
+ // Basic error handling for the process
43
+ childProcess.on('error', (err) => {
44
+ console.error(`[bash] Session error for channel ${channelId}:`, err);
45
+ sessions.delete(channelId);
46
+ });
47
+ childProcess.on('exit', () => {
48
+ sessions.delete(channelId);
49
+ });
50
+ }
51
+ return session;
52
+ };
53
+ const bashPluginRuntime = () => (builder) => {
54
+ builder.on('action:bash', async function* (event, context) {
55
+ const { command, restart } = event.data;
56
+ const channelId = context.state.channelId;
57
+ const initialCwd = resolvePath(context.state.channelDetails?.cwd || process.cwd());
58
+ if (restart) {
59
+ const oldSession = sessions.get(channelId);
60
+ if (oldSession) {
61
+ oldSession.process.kill();
62
+ sessions.delete(channelId);
63
+ }
64
+ }
65
+ const session = getSession(channelId, initialCwd);
66
+ session.lastActivity = Date.now();
67
+ try {
68
+ const result = await new Promise((resolve) => {
69
+ let stdout = '';
70
+ let stderr = '';
71
+ let timedOut = false;
72
+ const sentinel = `__OPENBOT_BASH_DONE_${Math.random().toString(36).substring(7)}__`;
73
+ const timeoutMs = 60000; // 1 minute timeout for tool calls
74
+ const timer = setTimeout(() => {
75
+ timedOut = true;
76
+ // We don't kill the session on timeout, just return what we have
77
+ resolve({ exitCode: null, stdout, stderr, timedOut });
78
+ }, timeoutMs);
79
+ const onStdout = (data) => {
80
+ const str = data.toString();
81
+ if (str.includes(sentinel)) {
82
+ const parts = str.split(sentinel);
83
+ stdout += parts[0];
84
+ const exitCodeMatch = parts[1].match(/EXIT:(\d+)/);
85
+ const exitCode = exitCodeMatch ? parseInt(exitCodeMatch[1], 10) : 0;
86
+ cleanup();
87
+ resolve({ exitCode, stdout, stderr, timedOut: false });
88
+ }
89
+ else {
90
+ stdout += str;
91
+ }
92
+ };
93
+ const onStderr = (data) => {
94
+ stderr += data.toString();
95
+ };
96
+ const cleanup = () => {
97
+ clearTimeout(timer);
98
+ session.process.stdout?.removeListener('data', onStdout);
99
+ session.process.stderr?.removeListener('data', onStderr);
100
+ };
101
+ session.process.stdout?.on('data', onStdout);
102
+ session.process.stderr?.on('data', onStderr);
103
+ // Execute command and then echo the sentinel with exit code
104
+ session.process.stdin?.write(`${command}\necho "${sentinel}EXIT:$?"\n`);
105
+ });
106
+ yield {
107
+ type: 'action:bash:result',
108
+ data: {
109
+ success: result.exitCode === 0 && !result.timedOut,
110
+ exitCode: result.exitCode,
111
+ stdout: result.stdout.trim(),
112
+ stderr: result.stderr.trim(),
113
+ timedOut: result.timedOut,
114
+ output: result.stderr.trim() ? result.stderr.trim() : result.stdout.trim(),
115
+ },
116
+ meta: event.meta,
117
+ };
118
+ }
119
+ catch (error) {
120
+ const message = error instanceof Error ? error.message : 'Unknown bash error';
121
+ yield {
122
+ type: 'action:bash:result',
123
+ data: {
124
+ success: false,
125
+ exitCode: -1,
126
+ stdout: '',
127
+ stderr: message,
128
+ timedOut: false,
129
+ error: message,
130
+ output: message,
131
+ },
132
+ meta: event.meta,
133
+ };
134
+ }
135
+ });
136
+ // Add a tool to stop/kill the session
137
+ builder.on('action:bash_stop', async function* (event, context) {
138
+ const channelId = event.data?.channelId || context.state.channelId;
139
+ const session = sessions.get(channelId);
140
+ if (session) {
141
+ session.process.kill();
142
+ sessions.delete(channelId);
143
+ }
144
+ yield {
145
+ type: 'action:bash_stop:result',
146
+ data: { success: true, output: `Bash session for channel ${channelId} stopped.` },
147
+ meta: event.meta,
148
+ };
149
+ });
150
+ // Add a tool to list all active sessions
151
+ builder.on('action:bash_list_sessions', async function* (event, context) {
152
+ const activeSessions = Array.from(sessions.entries()).map(([channelId, session]) => ({
153
+ channelId,
154
+ cwd: session.cwd,
155
+ lastActivity: session.lastActivity,
156
+ }));
157
+ yield {
158
+ type: 'client:ui:widget',
159
+ data: {
160
+ widgetId: randomUUID(),
161
+ kind: 'list',
162
+ title: 'Active Bash Sessions',
163
+ description: `Found ${activeSessions.length} active bash session${activeSessions.length === 1 ? '' : 's'}.`,
164
+ items: activeSessions.map((s) => ({
165
+ id: s.channelId,
166
+ label: s.channelId,
167
+ description: `CWD: ${s.cwd}`,
168
+ status: 'done',
169
+ metadata: {
170
+ cwd: s.cwd,
171
+ lastActivity: s.lastActivity,
172
+ },
173
+ })),
174
+ },
175
+ meta: event.meta,
176
+ };
177
+ yield {
178
+ type: 'action:bash_list_sessions:result',
179
+ data: {
180
+ success: true,
181
+ sessions: activeSessions,
182
+ output: JSON.stringify(activeSessions),
183
+ },
184
+ meta: event.meta,
185
+ };
186
+ });
187
+ };
188
+ export const bashPlugin = {
189
+ id: 'bash',
190
+ name: 'Bash',
191
+ description: 'Stateful bash session for the channel.',
192
+ toolDefinitions: bashToolDefinitions,
193
+ factory: () => bashPluginRuntime(),
194
+ };
195
+ export default bashPlugin;
@@ -1,40 +1,129 @@
1
- import z from 'zod';
2
- const handoffToolDefinitions = {
3
- handoff: {
4
- description: 'Transfer control to another agent. The target agent continues the task in this thread.',
1
+ import { z } from 'zod';
2
+ import { generateId } from 'melony';
3
+ /**
4
+ * `delegation` allows agents to delegate tasks to other agents.
5
+ *
6
+ * Only the 'system' agent is allowed to delegate by default.
7
+ * It uses runAgent to execute the delegated agent in its own isolated runtime,
8
+ * bridging events back to the caller's stream.
9
+ */
10
+ const delegationToolDefinitions = {
11
+ delegate_task: {
12
+ description: 'Delegate a specific task or question to another specialized agent.',
5
13
  inputSchema: z.object({
6
- agentId: z.string().describe('The ID of the target agent.'),
7
- content: z.string().describe('The message or task to hand off.'),
14
+ agentId: z.string().describe('The ID of the agent to delegate to (e.g., "researcher", "coder").'),
15
+ prompt: z.string().describe('The instructions or question for the delegated agent.'),
8
16
  }),
9
17
  },
10
18
  };
11
- const handoffPluginRuntime = () => (builder) => {
12
- builder.on('action:handoff', async function* (event, context) {
13
- const { agentId, content } = event.data;
14
- yield {
15
- type: 'agent:output',
16
- data: { content: `Handing off to **${agentId}**: ${content}` },
17
- meta: { ...(event.meta || {}), agentId: context.state.agentId },
18
- };
19
- yield {
20
- type: 'handoff:request',
21
- data: { agentId, content },
22
- meta: { ...(event.meta || {}), agentId: context.state.agentId },
23
- };
24
- if (event.meta?.toolCallId) {
25
- yield {
26
- type: 'action:handoff:result',
27
- data: { success: true, agentId, accepted: true },
28
- meta: { ...(event.meta || {}), agentId: context.state.agentId },
29
- };
30
- }
31
- });
32
- };
33
19
  export const delegationPlugin = {
34
20
  id: 'delegation',
35
- name: 'Handoff',
36
- description: 'Hand off tasks to other agents on the bus.',
37
- toolDefinitions: handoffToolDefinitions,
38
- factory: () => handoffPluginRuntime(),
21
+ name: 'Delegation',
22
+ description: 'Allows agents to call upon other agents to solve sub-tasks.',
23
+ toolDefinitions: delegationToolDefinitions,
24
+ factory: (pluginContext) => (builder) => {
25
+ // Handle the tool execution
26
+ builder.on('action:delegate_task', async function* (event, context) {
27
+ const delegateEvent = event;
28
+ // POLICY: Only the 'system' agent can delegate
29
+ if (context.state.agentId !== 'system') {
30
+ yield {
31
+ type: 'action:delegate_task:result',
32
+ data: {
33
+ success: false,
34
+ error: 'Only the system agent can delegate.'
35
+ },
36
+ meta: delegateEvent.meta,
37
+ };
38
+ return;
39
+ }
40
+ const { agentId, prompt } = delegateEvent.data;
41
+ const toolCallId = delegateEvent.meta?.toolCallId;
42
+ if (!toolCallId)
43
+ return;
44
+ // Break circular dependency by dynamic import
45
+ const { runAgent } = await import('../../harness/index.js');
46
+ const runId = `dg_${generateId()}`;
47
+ let lastAgentOutput = '';
48
+ // Queue to bridge the async onEvent callback to this generator
49
+ const eventQueue = [];
50
+ let resolveNext = null;
51
+ let isFinished = false;
52
+ // Start the delegated agent in its own runtime.
53
+ // We don't await this immediately so we can yield events as they arrive.
54
+ const runPromise = runAgent({
55
+ runId,
56
+ agentId,
57
+ event: {
58
+ type: 'agent:invoke',
59
+ data: {
60
+ role: 'user',
61
+ content: prompt,
62
+ agentId: agentId,
63
+ },
64
+ meta: {
65
+ threadId: context.state.threadId,
66
+ parentAgentId: context.state.agentId,
67
+ parentToolCallId: toolCallId,
68
+ },
69
+ },
70
+ channelId: context.state.channelId,
71
+ threadId: context.state.threadId,
72
+ publicBaseUrl: pluginContext.publicBaseUrl,
73
+ onEvent: async (outEvent) => {
74
+ // Enrich events with parent metadata so the UI can track the hierarchy
75
+ const enrichedEvent = {
76
+ ...outEvent,
77
+ meta: {
78
+ ...outEvent.meta,
79
+ parentAgentId: context.state.agentId,
80
+ parentToolCallId: toolCallId,
81
+ }
82
+ };
83
+ eventQueue.push(enrichedEvent);
84
+ if (outEvent.type === 'agent:output') {
85
+ lastAgentOutput = outEvent.data.content;
86
+ }
87
+ // Wake up the generator loop if it's waiting
88
+ if (resolveNext) {
89
+ resolveNext();
90
+ resolveNext = null;
91
+ }
92
+ }
93
+ }).catch(error => {
94
+ console.error(`[delegation] Error in delegated run ${runId}:`, error);
95
+ }).finally(() => {
96
+ isFinished = true;
97
+ if (resolveNext) {
98
+ resolveNext();
99
+ resolveNext = null;
100
+ }
101
+ });
102
+ // Yield events from the delegated agent as they arrive
103
+ while (!isFinished || eventQueue.length > 0) {
104
+ if (eventQueue.length === 0) {
105
+ await new Promise(r => { resolveNext = r; });
106
+ }
107
+ while (eventQueue.length > 0) {
108
+ yield eventQueue.shift();
109
+ }
110
+ }
111
+ // Ensure the run is fully complete (though isFinished already implies this)
112
+ await runPromise;
113
+ // Yield the result back to our own LLM runtime.
114
+ yield {
115
+ type: 'action:delegate_task:result',
116
+ data: {
117
+ success: true,
118
+ output: lastAgentOutput,
119
+ },
120
+ meta: {
121
+ ...delegateEvent.meta,
122
+ agentId: context.state.agentId,
123
+ toolCallId: toolCallId,
124
+ },
125
+ };
126
+ });
127
+ },
39
128
  };
40
129
  export default delegationPlugin;