openbot 0.4.0 → 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 (50) hide show
  1. package/dist/app/cli.js +1 -1
  2. package/dist/app/config.js +10 -0
  3. package/dist/app/server.js +200 -3
  4. package/dist/harness/index.js +18 -0
  5. package/dist/plugins/approval/index.js +35 -20
  6. package/dist/plugins/bash/index.js +195 -0
  7. package/dist/plugins/delegation/index.js +4 -2
  8. package/dist/plugins/openbot/context.js +54 -9
  9. package/dist/plugins/openbot/history.js +47 -1
  10. package/dist/plugins/openbot/index.js +43 -3
  11. package/dist/plugins/openbot/runtime.js +91 -27
  12. package/dist/plugins/openbot/system-prompt.js +21 -1
  13. package/dist/plugins/plugin-manager/index.js +87 -3
  14. package/dist/plugins/shell/index.js +2 -1
  15. package/dist/plugins/storage/files.js +67 -0
  16. package/dist/plugins/storage/index.js +184 -7
  17. package/dist/plugins/storage/service.js +201 -44
  18. package/dist/plugins/ui/index.js +109 -150
  19. package/dist/services/abort.js +43 -0
  20. package/dist/services/plugins/registry.js +5 -3
  21. package/dist/services/plugins/service.js +66 -11
  22. package/docs/agents.md +5 -8
  23. package/docs/architecture.md +1 -1
  24. package/docs/plugins.md +28 -7
  25. package/docs/templates/AGENT.example.md +4 -4
  26. package/package.json +1 -1
  27. package/src/app/cli.ts +1 -1
  28. package/src/app/config.ts +13 -0
  29. package/src/app/server.ts +235 -3
  30. package/src/app/types.ts +284 -14
  31. package/src/harness/index.ts +21 -0
  32. package/src/plugins/approval/index.ts +37 -20
  33. package/src/plugins/bash/index.ts +232 -0
  34. package/src/plugins/delegation/index.ts +5 -2
  35. package/src/plugins/openbot/context.ts +58 -9
  36. package/src/plugins/openbot/history.ts +52 -1
  37. package/src/plugins/openbot/index.ts +45 -3
  38. package/src/plugins/openbot/runtime.ts +121 -27
  39. package/src/plugins/openbot/system-prompt.ts +21 -1
  40. package/src/plugins/plugin-manager/index.ts +105 -3
  41. package/src/plugins/storage/files.ts +81 -0
  42. package/src/plugins/storage/index.ts +198 -8
  43. package/src/plugins/storage/service.ts +267 -44
  44. package/src/plugins/ui/index.ts +123 -0
  45. package/src/services/abort.ts +46 -0
  46. package/src/services/plugins/domain.ts +34 -1
  47. package/src/services/plugins/registry.ts +5 -3
  48. package/src/services/plugins/service.ts +136 -45
  49. package/src/services/plugins/types.ts +5 -1
  50. package/src/plugins/shell/index.ts +0 -123
@@ -8,7 +8,6 @@ import { Storage } from '../../services/plugins/domain.js';
8
8
  import type { ToolDefinition } from '../../services/plugins/types.js';
9
9
  import {
10
10
  ORCHESTRATOR_AGENT_ID,
11
- getContextBudgetForModel,
12
11
  buildContext,
13
12
  } from './context.js';
14
13
  import { saveConfig } from '../../app/config.js';
@@ -20,6 +19,8 @@ export interface OpenBotRuntimeOptions {
20
19
  storage?: Storage;
21
20
  /** Tool definitions merged from all tool plugins attached to this agent. */
22
21
  toolDefinitions?: Record<string, ToolDefinition>;
22
+ /** Fires when the run is stopped; cancels the in-flight LLM call. */
23
+ abortSignal?: AbortSignal;
23
24
  }
24
25
 
25
26
  function resolveModel(modelString: string): LanguageModel {
@@ -44,12 +45,7 @@ async function buildSystemPrompt(
44
45
  ): Promise<string> {
45
46
  const context = await buildContext(state, storage);
46
47
 
47
- const instructions =
48
- state.agentId === ORCHESTRATOR_AGENT_ID
49
- ? (state.agentDetails?.instructions?.trim() || OPENBOT_SYSTEM_PROMPT)
50
- : OPENBOT_SYSTEM_PROMPT;
51
-
52
- const sections = [instructions, '', context];
48
+ const sections = [OPENBOT_SYSTEM_PROMPT, '', context];
53
49
 
54
50
  // Hardcoded naming hint logic
55
51
  const threadState = state.threadDetails?.state as any;
@@ -71,23 +67,44 @@ async function buildSystemPrompt(
71
67
  * a single `generateText` response execute one-by-one. We must wait for every ID
72
68
  * in the batch before calling the LLM again — not after the first result.
73
69
  */
74
- function createToolBatchTracker() {
75
- let pending: Set<string> | null = null;
70
+ function createToolBatchTracker(
71
+ state: OpenBotState,
72
+ storage?: Storage,
73
+ channelId?: string,
74
+ threadId?: string,
75
+ ) {
76
+ const save = async (ids?: string[]) => {
77
+ if (!storage || !channelId || !threadId) return;
78
+ try {
79
+ await storage.patchThreadState({
80
+ channelId,
81
+ threadId,
82
+ state: { pendingToolCallIds: ids },
83
+ });
84
+ } catch (error) {
85
+ console.error('[openbot] Failed to persist pendingToolCallIds:', error);
86
+ }
87
+ };
76
88
 
77
89
  return {
78
- startBatch(toolCallIds: string[]) {
79
- pending = new Set(toolCallIds);
90
+ async startBatch(toolCallIds: string[]) {
91
+ state.pendingToolCallIds = [...toolCallIds];
92
+ await save(state.pendingToolCallIds);
80
93
  },
81
- clear() {
82
- pending = null;
94
+ async clear() {
95
+ state.pendingToolCallIds = undefined;
96
+ await save(undefined);
83
97
  },
84
98
  /** Returns true when this result completes the batch (time to call the LLM again). */
85
- recordResult(toolCallId: string): boolean {
86
- if (!pending?.has(toolCallId)) return false;
87
- pending.delete(toolCallId);
88
- if (pending.size > 0) return false;
89
- pending = null;
90
- return true;
99
+ async recordResult(toolCallId: string): Promise<boolean> {
100
+ if (!state.pendingToolCallIds?.includes(toolCallId)) return false;
101
+ state.pendingToolCallIds = state.pendingToolCallIds.filter((id) => id !== toolCallId);
102
+ const done = state.pendingToolCallIds.length === 0;
103
+ if (done) {
104
+ state.pendingToolCallIds = undefined;
105
+ }
106
+ await save(state.pendingToolCallIds);
107
+ return done;
91
108
  },
92
109
  };
93
110
  }
@@ -106,11 +123,11 @@ export const openbotRuntime =
106
123
  model: modelString = 'openai/gpt-4o-mini',
107
124
  storage,
108
125
  toolDefinitions = {},
126
+ abortSignal,
109
127
  } = options;
110
128
 
111
129
  let currentModelString = modelString;
112
130
  let model = resolveModel(currentModelString);
113
- const toolBatch = createToolBatchTracker();
114
131
 
115
132
  const runLLM = async function* (
116
133
  context: RuntimeContext<OpenBotState, OpenBotEvent>,
@@ -118,6 +135,14 @@ export const openbotRuntime =
118
135
  trigger?: AgentInvokeEvent,
119
136
  ): AsyncGenerator<OpenBotEvent> {
120
137
  if (!storage) return;
138
+ if (abortSignal?.aborted) return;
139
+
140
+ const toolBatch = createToolBatchTracker(
141
+ context.state,
142
+ storage,
143
+ context.state.channelId,
144
+ threadId || context.state.threadId,
145
+ );
121
146
 
122
147
  // Capture parent metadata for event enrichment
123
148
  const triggerEvent = trigger || context.state.triggerEvent;
@@ -136,8 +161,8 @@ export const openbotRuntime =
136
161
  const messages = eventsToModelMessages(events);
137
162
 
138
163
  // console.log('systemPrompt:::::::\n', systemPrompt);
139
- // console.log('messages:::::::\n', JSON.stringify(messages, null, 2));
140
- // console.log('toolDefinitions:::::::\n', JSON.stringify(toolDefinitions, null, 2));
164
+ // console.log('messages:::::::\n', JSON.stringify(messages));
165
+ // console.log('toolDefinitions:::::::\n', JSON.stringify(toolDefinitions));
141
166
 
142
167
  try {
143
168
  // Single LLM request — tool execution happens externally via action:* handlers.
@@ -148,6 +173,7 @@ export const openbotRuntime =
148
173
  tools: toolDefinitions as Record<string, { description: string; inputSchema: any }>,
149
174
  stopWhen: ({ steps }) => steps.length === 1,
150
175
  allowSystemInMessages: true,
176
+ abortSignal,
151
177
  });
152
178
 
153
179
  const toolCalls = result.toolCalls ?? [];
@@ -192,7 +218,7 @@ export const openbotRuntime =
192
218
 
193
219
  if (toolCalls.length > 0) {
194
220
  // when multiple tool calls are made, Melony runtime handles them one by one, thats why we need to start a new batch
195
- toolBatch.startBatch(toolCalls.map((tc) => tc.toolCallId));
221
+ await toolBatch.startBatch(toolCalls.map((tc) => tc.toolCallId));
196
222
 
197
223
  for (const toolCall of toolCalls) {
198
224
  yield {
@@ -206,9 +232,12 @@ export const openbotRuntime =
206
232
  }
207
233
  } else {
208
234
  // clear the tool batch if there are no tool calls
209
- toolBatch.clear();
235
+ await toolBatch.clear();
210
236
  }
211
237
  } catch (error: unknown) {
238
+ // Run was stopped — unwind quietly without surfacing an error.
239
+ if (abortSignal?.aborted) return;
240
+
212
241
  const errorMessage = error instanceof Error ? error.message : String(error);
213
242
  const isApiKeyError =
214
243
  errorMessage.includes('API key') ||
@@ -277,11 +306,67 @@ export const openbotRuntime =
277
306
  return;
278
307
  }
279
308
 
309
+ // Capture user info from meta if available
310
+ if (event.meta?.userName) {
311
+ context.state.currentUser = {
312
+ userName: event.meta.userName,
313
+ };
314
+ }
315
+
316
+ const threadId = event.meta?.threadId || context.state.threadId;
317
+
318
+ // Auto-add participants if tagged in the prompt
319
+ const content = (event as AgentInvokeEvent).data?.content;
320
+ if (content && storage) {
321
+ try {
322
+ const allAgents = await storage.getAgents();
323
+ const tags = content.match(/@([\w-]+)/g);
324
+ if (tags) {
325
+ const taggedAgentIds = tags.map((t) => t.slice(1));
326
+ const validAgentIds = taggedAgentIds.filter((id) =>
327
+ allAgents.some((a) => a.id === id),
328
+ );
329
+
330
+ const currentParticipants = context.state.channelDetails?.participants || [];
331
+ const newParticipants = [...new Set([...currentParticipants, ...validAgentIds])];
332
+
333
+ if (newParticipants.length > currentParticipants.length) {
334
+ // Update storage
335
+ await storage.patchChannelState({
336
+ channelId: context.state.channelId,
337
+ state: { participants: newParticipants },
338
+ });
339
+
340
+ // Refresh local state
341
+ context.state.channelDetails = await storage.getChannelDetails({
342
+ channelId: context.state.channelId,
343
+ });
344
+
345
+ // Notify UI/others about the change
346
+ yield {
347
+ type: 'action:patch_channel_details:result',
348
+ data: { success: true, updatedFields: ['participants'] },
349
+ meta: {
350
+ agentId: context.state.agentId,
351
+ threadId,
352
+ },
353
+ } as OpenBotEvent;
354
+ }
355
+ }
356
+ } catch (error) {
357
+ console.warn('[openbot] Failed to auto-add participants from tags:', error);
358
+ }
359
+ }
360
+
280
361
  // clear the tool batch if the agent is invoked
281
362
  // this is to prevent the tool batch from being used for a new agent invocation
282
- toolBatch.clear();
363
+ await createToolBatchTracker(
364
+ context.state,
365
+ storage,
366
+ context.state.channelId,
367
+ threadId,
368
+ ).clear();
283
369
 
284
- const threadId = event.meta?.threadId || context.state.threadId;
285
370
  yield* runLLM(context, threadId, event as AgentInvokeEvent);
286
371
  });
287
372
 
@@ -293,7 +378,16 @@ export const openbotRuntime =
293
378
 
294
379
  const toolCallId = event.meta?.toolCallId;
295
380
  // record the result of the tool call
296
- if (!toolCallId || !toolBatch.recordResult(toolCallId)) return;
381
+ if (
382
+ !toolCallId ||
383
+ !(await createToolBatchTracker(
384
+ context.state,
385
+ storage,
386
+ context.state.channelId,
387
+ event.meta?.threadId || context.state.threadId,
388
+ ).recordResult(toolCallId))
389
+ )
390
+ return;
297
391
 
298
392
  const threadId = event.meta?.threadId || context.state.threadId;
299
393
  yield* runLLM(context, threadId);
@@ -1,5 +1,25 @@
1
1
  export const OPENBOT_SYSTEM_PROMPT = [
2
- 'You are a helpful AI assistant for your human. Your job is to help the user with their questions and tasks.',
2
+ '# ROLE',
3
+ 'You are an OpenBot, the main coordinator and router agent. Your primary role is to orchestrate specialized agents to help the human achieve their goals.',
4
+ '',
5
+ '# SECURITY POLICY',
6
+ '- **CRITICAL**: Never request API keys, passwords, or sensitive credentials via text or UI widgets; these are managed deterministically via secure forms and must never enter your context.',
7
+ '- **Credential Guidance**: If an agent or tool requires credentials, inform the user they can be managed under "Settings > Variables".',
8
+ '',
9
+ '# CORE MISSION',
10
+ 'You almost never execute tasks yourself. Instead, you delegate tasks to specialized agents (channel participants). You act as a high-level manager, ensuring the right agent is working on the right task.',
11
+ '',
12
+ '# OPERATIONAL GUIDELINES',
13
+ '- **Channel and Threads**: The main and only way to communicate and act is through channels and threads. There might be a channel called "uncategorized" for general purpose communication.',
14
+ '- **Agent Participation**: ONLY add an agent via `patch_channel_details` if the user manually tags them (e.g., `@name`) AND they are missing from the `Participants` list in `ENVIRONMENT`.',
15
+ '- **Delegation**: NEVER delegate to an agent who is not a participant. Only if existing participants clearly cannot handle a task should you suggest relevant agents from the `INSTALLED AGENTS` list.',
16
+ '- **Bash Tool Usage**: You should use the `bash` tool very rarely. Only use it when the user explicitly requests a command to be run or when it is absolutely necessary for a task that no other participant can handle.',
17
+ '- **Context Awareness**: Use the provided ENVIRONMENT, CHANNEL SPECIFICATION, and MEMORIES to maintain continuity. Do not ask for information already present in these sections.',
18
+ '- **Durable Memory**: Use the `remember` tool to store important facts, preferences, or project details that should persist across sessions.',
19
+ '- **Structured Interaction**: Use the `render_widget` tool to collect information via forms, offer choices, or display lists. This is preferred over asking multiple separate questions in plain text.',
20
+ '',
21
+ '# COMMUNICATION STYLE',
22
+ '- Be always concise, professional, and proactive.',
3
23
  ].join('\n');
4
24
 
5
25
  /** Shown in the API key setup form when no provider credentials are configured. */
@@ -3,7 +3,7 @@ import { STATE_AGENT_ID } from '../../app/agent-ids.js';
3
3
  import { OpenBotEvent } from '../../app/types.js';
4
4
  import {
5
5
  pluginService,
6
- resolveMarketplaceAgentList,
6
+ resolveMarketplaceRegistry,
7
7
  } from '../../services/plugins/service.js';
8
8
 
9
9
  /**
@@ -55,13 +55,115 @@ export const pluginManagerPlugin: Plugin = {
55
55
  });
56
56
 
57
57
  builder.on('action:marketplace:list', async function* () {
58
- const agents = await resolveMarketplaceAgentList();
58
+ const { agents, channels } = await resolveMarketplaceRegistry();
59
59
  yield {
60
60
  type: 'action:marketplace:list:result',
61
- data: { success: true, agents },
61
+ data: { success: true, agents, channels },
62
62
  } as OpenBotEvent;
63
63
  });
64
64
 
65
+ builder.on('action:channel:install', async function* (event) {
66
+ try {
67
+ const {
68
+ channelId: instanceId,
69
+ name: templateName,
70
+ participants: customParticipants,
71
+ initialState: customInitialState,
72
+ } = event.data;
73
+ const { agents: marketplaceAgents, channels } = await resolveMarketplaceRegistry();
74
+
75
+ // Try to find the template by ID or Name
76
+ const channelListing =
77
+ channels.find((c) => c.id === instanceId) ||
78
+ channels.find((c) => c.name === templateName);
79
+
80
+ const channelId = instanceId;
81
+ const participants = customParticipants || channelListing?.participants || [];
82
+ const initialState = {
83
+ ...(channelListing?.initialState || {}),
84
+ ...(customInitialState || {}),
85
+ };
86
+ const spec = channelListing?.spec || '';
87
+
88
+ // 1. Auto-install participant agents if missing
89
+ for (const agentId of participants) {
90
+ const existingAgents = await storage.getAgents();
91
+ if (existingAgents.some((a) => a.id === agentId)) {
92
+ continue;
93
+ }
94
+
95
+ // Not found locally, look in marketplace
96
+ const agentListing = marketplaceAgents.find((a) => a.id === agentId);
97
+ if (agentListing) {
98
+ console.log(`[plugin-manager] Auto-installing agent ${agentId} for channel ${channelId}`);
99
+
100
+ // Install plugins for this agent
101
+ for (const ref of agentListing.plugins) {
102
+ const installed = await pluginService.isInstalled(ref.id);
103
+ if (
104
+ !installed &&
105
+ ref.id.includes('/') === false &&
106
+ ref.id.includes('-plugin-') === false
107
+ ) {
108
+ continue;
109
+ }
110
+ if (!installed) {
111
+ try {
112
+ await pluginService.install({ packageName: ref.id });
113
+ } catch (err) {
114
+ console.warn(`[plugins] Failed to pre-install plugin ${ref.id}`, err);
115
+ }
116
+ }
117
+ }
118
+
119
+ // Create the agent
120
+ await storage.createAgent({
121
+ agentId: agentListing.id,
122
+ name: agentListing.name,
123
+ description: agentListing.description,
124
+ image: agentListing.image,
125
+ instructions: agentListing.instructions,
126
+ plugins: agentListing.plugins,
127
+ });
128
+ }
129
+ }
130
+
131
+ // 2. Create the channel
132
+ await storage.createChannel({
133
+ channelId,
134
+ spec,
135
+ initialState: {
136
+ ...initialState,
137
+ participants,
138
+ },
139
+ });
140
+
141
+ const channelUrl = `/channels/${channelId}`;
142
+ yield {
143
+ type: 'action:channel:install:result',
144
+ data: { success: true, channelId, channelUrl },
145
+ } as OpenBotEvent;
146
+
147
+ yield {
148
+ type: 'agent:output',
149
+ data: {
150
+ content: `Successfully installed channel **${
151
+ channelListing?.name || templateName || channelId
152
+ }** and created channel \`${channelId}\`.`,
153
+ },
154
+ meta: { agentId: 'system' },
155
+ } as OpenBotEvent;
156
+ } catch (error) {
157
+ yield {
158
+ type: 'action:channel:install:result',
159
+ data: {
160
+ success: false,
161
+ error: error instanceof Error ? error.message : 'Unknown error',
162
+ },
163
+ } as OpenBotEvent;
164
+ }
165
+ });
166
+
65
167
  builder.on('action:agent:install', async function* (event) {
66
168
  try {
67
169
  const {
@@ -0,0 +1,81 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { resolvePath } from '../../app/config.js';
5
+
6
+ const MIME_BY_EXT: Record<string, string> = {
7
+ '.png': 'image/png',
8
+ '.jpg': 'image/jpeg',
9
+ '.jpeg': 'image/jpeg',
10
+ '.gif': 'image/gif',
11
+ '.webp': 'image/webp',
12
+ '.svg': 'image/svg+xml',
13
+ '.ico': 'image/x-icon',
14
+ '.mp4': 'video/mp4',
15
+ '.webm': 'video/webm',
16
+ '.mov': 'video/quicktime',
17
+ '.mp3': 'audio/mpeg',
18
+ '.wav': 'audio/wav',
19
+ '.ogg': 'audio/ogg',
20
+ '.pdf': 'application/pdf',
21
+ '.json': 'application/json',
22
+ '.txt': 'text/plain',
23
+ '.html': 'text/html',
24
+ '.css': 'text/css',
25
+ '.js': 'text/javascript',
26
+ '.zip': 'application/zip',
27
+ };
28
+
29
+ export function guessMimeType(filePath: string): string {
30
+ return MIME_BY_EXT[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream';
31
+ }
32
+
33
+ /** Resolve a relative path under a channel cwd; rejects directory escape. */
34
+ export function resolveChannelFile(baseCwd: string, relativePath: string): string {
35
+ const resolvedBase = resolvePath(baseCwd);
36
+ const normalized = relativePath.replace(/\\/g, '/').replace(/^\/+/, '');
37
+ const target = path.resolve(resolvedBase, normalized);
38
+ if (target !== resolvedBase && !target.startsWith(resolvedBase + path.sep)) {
39
+ throw new Error('Access denied: directory escape');
40
+ }
41
+ return target;
42
+ }
43
+
44
+ export async function statChannelFile(
45
+ baseCwd: string,
46
+ relativePath: string,
47
+ ): Promise<{ abs: string; size: number }> {
48
+ const abs = resolveChannelFile(baseCwd, relativePath);
49
+ const stat = await fs.stat(abs);
50
+ if (!stat.isFile()) {
51
+ throw new Error('Not a file');
52
+ }
53
+ return { abs, size: stat.size };
54
+ }
55
+
56
+ export function openChannelFileStream(abs: string) {
57
+ return createReadStream(abs);
58
+ }
59
+
60
+ export function buildWorkspaceFileUrl(args: {
61
+ baseUrl: string;
62
+ channelId: string;
63
+ filePath: string;
64
+ }): string {
65
+ const base = args.baseUrl.replace(/\/$/, '');
66
+ const data = encodeURIComponent(JSON.stringify({ path: args.filePath }));
67
+ const channelId = encodeURIComponent(args.channelId);
68
+ return `${base}/api/state?channelId=${channelId}&type=${encodeURIComponent('action:storage:serve-file')}&data=${data}`;
69
+ }
70
+
71
+ export function getPublicBaseUrl(port: number, configPublicUrl?: string): string {
72
+ const fromConfig = configPublicUrl?.trim();
73
+ if (fromConfig) {
74
+ return fromConfig.replace(/\/$/, '');
75
+ }
76
+ const fromEnv = process.env.OPENBOT_PUBLIC_URL?.trim();
77
+ if (fromEnv) {
78
+ return fromEnv.replace(/\/$/, '');
79
+ }
80
+ return `http://localhost:${port}`;
81
+ }