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
@@ -0,0 +1,152 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { DEFAULT_BASE_DIR, loadConfig, resolvePath } from '../../app/config.js';
5
+ const DEFAULT_LIMIT = 50;
6
+ const MAX_LIMIT = 500;
7
+ const getMemoryDir = () => {
8
+ const config = loadConfig();
9
+ return path.join(resolvePath(config.baseDir || DEFAULT_BASE_DIR), 'memory');
10
+ };
11
+ const getLogPath = () => path.join(getMemoryDir(), 'log.jsonl');
12
+ const ensureDir = async () => {
13
+ await fs.mkdir(getMemoryDir(), { recursive: true });
14
+ };
15
+ const readLog = async () => {
16
+ try {
17
+ const raw = await fs.readFile(getLogPath(), 'utf-8');
18
+ return raw
19
+ .split(/\r?\n/)
20
+ .map((line) => line.trim())
21
+ .filter(Boolean)
22
+ .map((line) => {
23
+ try {
24
+ return JSON.parse(line);
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ })
30
+ .filter((e) => !!e);
31
+ }
32
+ catch (e) {
33
+ if (e?.code === 'ENOENT')
34
+ return [];
35
+ throw e;
36
+ }
37
+ };
38
+ const replay = (entries) => {
39
+ const out = new Map();
40
+ for (const entry of entries) {
41
+ if (entry.op === 'add') {
42
+ out.set(entry.record.id, entry.record);
43
+ }
44
+ else if (entry.op === 'delete') {
45
+ out.delete(entry.id);
46
+ }
47
+ else if (entry.op === 'update') {
48
+ const existing = out.get(entry.id);
49
+ if (!existing)
50
+ continue;
51
+ out.set(entry.id, {
52
+ ...existing,
53
+ ...entry.patch,
54
+ id: existing.id,
55
+ updatedAt: entry.at,
56
+ });
57
+ }
58
+ }
59
+ return out;
60
+ };
61
+ const appendEntry = async (entry) => {
62
+ await ensureDir();
63
+ await fs.appendFile(getLogPath(), `${JSON.stringify(entry)}\n`, 'utf-8');
64
+ };
65
+ const matchesQuery = (record, query, tag) => {
66
+ if (tag) {
67
+ if (!record.tags || !record.tags.includes(tag))
68
+ return false;
69
+ }
70
+ if (query) {
71
+ const q = query.toLowerCase();
72
+ if (!record.content.toLowerCase().includes(q))
73
+ return false;
74
+ }
75
+ return true;
76
+ };
77
+ export const memoryService = {
78
+ appendMemory: async (args) => {
79
+ const now = new Date().toISOString();
80
+ const record = {
81
+ id: crypto.randomUUID(),
82
+ scope: args.scope,
83
+ content: args.content,
84
+ tags: args.tags?.length ? args.tags : undefined,
85
+ createdAt: now,
86
+ updatedAt: now,
87
+ };
88
+ await appendEntry({ op: 'add', record });
89
+ return record;
90
+ },
91
+ updateMemory: async (args) => {
92
+ const entries = await readLog();
93
+ const map = replay(entries);
94
+ if (!map.has(args.id))
95
+ return false;
96
+ const at = new Date().toISOString();
97
+ const patch = {};
98
+ if (args.content !== undefined)
99
+ patch.content = args.content;
100
+ if (args.tags !== undefined)
101
+ patch.tags = args.tags.length ? args.tags : undefined;
102
+ if (Object.keys(patch).length === 0)
103
+ return true;
104
+ await appendEntry({ op: 'update', id: args.id, patch, at });
105
+ return true;
106
+ },
107
+ deleteMemory: async (args) => {
108
+ const entries = await readLog();
109
+ const map = replay(entries);
110
+ if (!map.has(args.id))
111
+ return false;
112
+ await appendEntry({ op: 'delete', id: args.id, at: new Date().toISOString() });
113
+ return true;
114
+ },
115
+ listMemories: async (args = {}) => {
116
+ const entries = await readLog();
117
+ const map = replay(entries);
118
+ const limit = Math.min(Math.max(args.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT);
119
+ const scopeSet = (() => {
120
+ if (args.scope)
121
+ return new Set([args.scope]);
122
+ if (args.scopes && args.scopes.length > 0)
123
+ return new Set(args.scopes);
124
+ return null;
125
+ })();
126
+ const filtered = [];
127
+ for (const record of map.values()) {
128
+ if (scopeSet && !scopeSet.has(record.scope))
129
+ continue;
130
+ if (!matchesQuery(record, args.query, args.tag))
131
+ continue;
132
+ filtered.push(record);
133
+ }
134
+ filtered.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
135
+ return filtered.slice(0, limit);
136
+ },
137
+ /**
138
+ * Compact the log into a single `add` per surviving record. Cheap to call
139
+ * occasionally; not required for correctness.
140
+ */
141
+ compact: async () => {
142
+ const entries = await readLog();
143
+ const map = replay(entries);
144
+ const surviving = Array.from(map.values());
145
+ await ensureDir();
146
+ const tmp = `${getLogPath()}.tmp`;
147
+ const body = surviving.map((record) => JSON.stringify({ op: 'add', record })).join('\n');
148
+ await fs.writeFile(tmp, body ? `${body}\n` : '', 'utf-8');
149
+ await fs.rename(tmp, getLogPath());
150
+ return surviving.length;
151
+ },
152
+ };
@@ -0,0 +1,80 @@
1
+ export const DEFAULT_CONTEXT_BUDGET = 8000;
2
+ /**
3
+ * Returns the known context window budget (in tokens) for a given model string.
4
+ */
5
+ export const getContextBudgetForModel = (modelString) => {
6
+ const budgets = {
7
+ 'openai/gpt-4o': 128000,
8
+ 'openai/gpt-4o-mini': 128000,
9
+ 'openai/o1-preview': 128000,
10
+ 'openai/o1-mini': 128000,
11
+ 'anthropic/claude-3-5-sonnet-20240620': 200000,
12
+ 'anthropic/claude-3-5-sonnet-latest': 200000,
13
+ 'anthropic/claude-3-opus-20240229': 200000,
14
+ 'anthropic/claude-3-sonnet-20240229': 200000,
15
+ 'anthropic/claude-3-haiku-20240307': 200000,
16
+ };
17
+ return budgets[modelString] || DEFAULT_CONTEXT_BUDGET;
18
+ };
19
+ /** Built-in orchestrator agent id. */
20
+ export const ORCHESTRATOR_AGENT_ID = 'system';
21
+ /**
22
+ * Check if a channel is a solo DM (only the agent is present).
23
+ */
24
+ export function isDmSoloChannel(participants, agentId) {
25
+ return participants.length === 0 || (participants.length === 1 && participants[0] === agentId);
26
+ }
27
+ /**
28
+ * Simplified context builder for MVP.
29
+ */
30
+ export async function buildContext(state, storage) {
31
+ const { channelId, threadId, channelDetails, agentId, threadDetails, agentDetails } = state;
32
+ const participants = channelDetails?.participants || [];
33
+ const isDm = isDmSoloChannel(participants, agentId);
34
+ const sections = [];
35
+ // 1. Environment
36
+ let env = '## ENVIRONMENT\n';
37
+ if (isDm) {
38
+ env += '- Mode: Direct Message (Solo)\n';
39
+ }
40
+ else {
41
+ const channelName = channelDetails?.name || channelId;
42
+ env += `- Mode: Channel (#${channelName})\n`;
43
+ if (threadId) {
44
+ env += `- Thread: ${threadDetails?.name || threadId}\n`;
45
+ }
46
+ const peerIds = participants.filter((id) => id !== agentId);
47
+ if (peerIds.length > 0) {
48
+ env += `- Participants: ${peerIds.join(', ')}\n`;
49
+ }
50
+ }
51
+ sections.push(env);
52
+ // 2. Channel Spec
53
+ const spec = channelDetails?.spec?.trim();
54
+ if (spec) {
55
+ sections.push(`## CHANNEL SPECIFICATION\n${spec}`);
56
+ }
57
+ // 3. Agent Instructions
58
+ if (agentDetails?.instructions) {
59
+ sections.push(`## AGENT: ${agentDetails?.name}\n${agentDetails.instructions}`);
60
+ }
61
+ // 4. Memories
62
+ if (storage?.listMemories) {
63
+ try {
64
+ const scopes = ['global', `agent:${agentId}`];
65
+ if (channelId)
66
+ scopes.push(`channel:${channelId}`);
67
+ const records = await storage.listMemories({ scopes, limit: 20 });
68
+ if (records.length > 0) {
69
+ const formatted = records
70
+ .map((r) => `- (${r.scope}) ${r.content}`)
71
+ .join('\n');
72
+ sections.push(`## MEMORIES\n${formatted}`);
73
+ }
74
+ }
75
+ catch (error) {
76
+ console.warn('[context] Failed to fetch memories:', error);
77
+ }
78
+ }
79
+ return sections.join('\n\n');
80
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Converts a raw event log into a valid chain of ModelMessages for the AI SDK.
3
+ *
4
+ * This is a basic implementation that maps events to messages and filters out
5
+ * events from sub-processes (delegation) to avoid duplication in history.
6
+ */
7
+ export function eventsToModelMessages(events) {
8
+ const messages = [];
9
+ for (const event of events) {
10
+ // Skip events that belong to a sub-process (like delegation)
11
+ // so they don't pollute the main conversation history.
12
+ if (event.meta?.parentToolCallId) {
13
+ continue;
14
+ }
15
+ switch (event.type) {
16
+ case 'agent:output': {
17
+ const last = messages[messages.length - 1];
18
+ if (last && last.role === 'assistant' && typeof last.content === 'string') {
19
+ last.content += event.data.content;
20
+ }
21
+ else {
22
+ messages.push({ role: 'assistant', content: event.data.content });
23
+ }
24
+ break;
25
+ }
26
+ case 'agent:invoke': {
27
+ const invokeEvent = event;
28
+ if (invokeEvent.data?.content && invokeEvent.data?.role) {
29
+ messages.push({
30
+ role: invokeEvent.data.role,
31
+ content: invokeEvent.data.content
32
+ });
33
+ }
34
+ break;
35
+ }
36
+ default:
37
+ // Handle tool calls (action:*)
38
+ if (event.type.startsWith('action:') && !event.type.endsWith(':result')) {
39
+ const toolName = event.type.slice(7);
40
+ const toolCallId = event.meta?.toolCallId;
41
+ if (!toolCallId)
42
+ break;
43
+ const toolCall = {
44
+ type: 'tool-call',
45
+ toolCallId,
46
+ toolName,
47
+ input: event.data,
48
+ };
49
+ const last = messages[messages.length - 1];
50
+ if (last && last.role === 'assistant') {
51
+ if (typeof last.content === 'string') {
52
+ last.content = [
53
+ { type: 'text', text: last.content },
54
+ toolCall,
55
+ ];
56
+ }
57
+ else if (Array.isArray(last.content)) {
58
+ last.content.push(toolCall);
59
+ }
60
+ }
61
+ else {
62
+ messages.push({
63
+ role: 'assistant',
64
+ content: [toolCall],
65
+ });
66
+ }
67
+ }
68
+ // Handle tool results (action:*:result)
69
+ else if (event.type.startsWith('action:') && event.type.endsWith(':result')) {
70
+ const toolName = event.type.slice(7, -7);
71
+ const toolCallId = event.meta?.toolCallId;
72
+ if (!toolCallId)
73
+ break;
74
+ const toolResult = {
75
+ type: 'tool-result',
76
+ toolCallId,
77
+ toolName,
78
+ output: {
79
+ type: 'text',
80
+ value: event?.data?.output || "No output", // ?.output is from delegation result
81
+ },
82
+ };
83
+ const last = messages[messages.length - 1];
84
+ if (last && last.role === 'tool' && Array.isArray(last.content)) {
85
+ last.content.push(toolResult);
86
+ }
87
+ else {
88
+ messages.push({
89
+ role: 'tool',
90
+ content: [toolResult],
91
+ });
92
+ }
93
+ }
94
+ break;
95
+ }
96
+ }
97
+ return messages;
98
+ }
@@ -0,0 +1,31 @@
1
+ import { openbotRuntime } from './runtime.js';
2
+ /**
3
+ * `openbot` — the standard, opinionated OpenBot agent runtime.
4
+ *
5
+ * This is the canonical execution loop for OpenBot agents. It handles
6
+ * `agent:invoke`, manages short-term memory, assembles context, and
7
+ * orchestrates tool calls.
8
+ */
9
+ export const openbotPlugin = {
10
+ id: 'openbot',
11
+ name: 'OpenBot Agent',
12
+ description: 'The standard, opinionated OpenBot agent runtime. Handles the core execution loop and tool orchestration.',
13
+ configSchema: {
14
+ type: 'object',
15
+ properties: {
16
+ model: {
17
+ type: 'string',
18
+ description: 'Provider model string, e.g. openai/gpt-4o-mini, anthropic/claude-3-5-sonnet-20240620',
19
+ default: 'openai/gpt-4o-mini',
20
+ },
21
+ },
22
+ },
23
+ factory: ({ config, storage, tools }) => {
24
+ return openbotRuntime({
25
+ model: config?.model,
26
+ storage,
27
+ toolDefinitions: tools,
28
+ });
29
+ },
30
+ };
31
+ export default openbotPlugin;
@@ -0,0 +1,317 @@
1
+ import { generateText } from 'ai';
2
+ import { openai } from '@ai-sdk/openai';
3
+ import { anthropic } from '@ai-sdk/anthropic';
4
+ import { eventsToModelMessages } from './history.js';
5
+ import { ORCHESTRATOR_AGENT_ID, buildContext, } from './context.js';
6
+ import { saveConfig } from '../../app/config.js';
7
+ import { API_KEY_SETUP_MESSAGE, OPENBOT_SYSTEM_PROMPT } from './system-prompt.js';
8
+ function resolveModel(modelString) {
9
+ const [provider, ...rest] = modelString.split('/');
10
+ const modelId = rest.join('/');
11
+ if (!modelId) {
12
+ throw new Error(`Invalid model string: "${modelString}". Expected "provider/model-id".`);
13
+ }
14
+ switch (provider) {
15
+ case 'openai':
16
+ return openai(modelId);
17
+ case 'anthropic':
18
+ return anthropic(modelId);
19
+ default:
20
+ throw new Error(`Unsupported AI provider: "${provider}"`);
21
+ }
22
+ }
23
+ async function buildSystemPrompt(state, storage) {
24
+ const context = await buildContext(state, storage);
25
+ const instructions = state.agentId === ORCHESTRATOR_AGENT_ID
26
+ ? (state.agentDetails?.instructions?.trim() || OPENBOT_SYSTEM_PROMPT)
27
+ : OPENBOT_SYSTEM_PROMPT;
28
+ const sections = [instructions, '', context];
29
+ // Hardcoded naming hint logic
30
+ const threadState = state.threadDetails?.state;
31
+ if (!threadState?.isSmartNamed) {
32
+ sections.push('', '## SYSTEM HINT', 'This thread is unnamed. Please use the `patch_thread_details` tool to set a concise, descriptive, and regular `name` (e.g., "Project Brainstorming" instead of "project-brainstorm") in the thread state and set `isSmartNamed: true` in the same patch. Only do this once.');
33
+ }
34
+ return sections.join('\n');
35
+ }
36
+ /**
37
+ * Tracks tool-call IDs from one LLM turn until matching `:result` events arrive.
38
+ *
39
+ * Melony runs yielded `action:*` events depth-first, so parallel tool calls from
40
+ * a single `generateText` response execute one-by-one. We must wait for every ID
41
+ * in the batch before calling the LLM again — not after the first result.
42
+ */
43
+ function createToolBatchTracker() {
44
+ let pending = null;
45
+ return {
46
+ startBatch(toolCallIds) {
47
+ pending = new Set(toolCallIds);
48
+ },
49
+ clear() {
50
+ pending = null;
51
+ },
52
+ /** Returns true when this result completes the batch (time to call the LLM again). */
53
+ recordResult(toolCallId) {
54
+ if (!pending?.has(toolCallId))
55
+ return false;
56
+ pending.delete(toolCallId);
57
+ if (pending.size > 0)
58
+ return false;
59
+ pending = null;
60
+ return true;
61
+ },
62
+ };
63
+ }
64
+ /**
65
+ * OpenBot agent runtime.
66
+ *
67
+ * - One `generateText` call per `runLLM` (tools have no `execute`; SDK stops at 1 step).
68
+ * - Tool calls become `action:*` events; plugins emit `:result` when done.
69
+ * - When a full batch of results is in, `runLLM` runs again with updated history.
70
+ */
71
+ export const openbotRuntime = (options) => (builder) => {
72
+ const { model: modelString = 'openai/gpt-4o-mini', storage, toolDefinitions = {}, } = options;
73
+ let currentModelString = modelString;
74
+ let model = resolveModel(currentModelString);
75
+ const toolBatch = createToolBatchTracker();
76
+ const runLLM = async function* (context, threadId, trigger) {
77
+ if (!storage)
78
+ return;
79
+ // Capture parent metadata for event enrichment
80
+ const triggerEvent = trigger || context.state.triggerEvent;
81
+ const parentAgentId = triggerEvent?.meta?.parentAgentId;
82
+ const parentToolCallId = triggerEvent?.meta?.parentToolCallId;
83
+ context.state.model = currentModelString;
84
+ const systemPrompt = await buildSystemPrompt(context.state, storage);
85
+ const events = await storage.getEvents({
86
+ channelId: context.state.channelId,
87
+ threadId: context.state.threadId,
88
+ });
89
+ const messages = eventsToModelMessages(events);
90
+ // console.log('systemPrompt:::::::\n', systemPrompt);
91
+ // console.log('messages:::::::\n', JSON.stringify(messages, null, 2));
92
+ // console.log('toolDefinitions:::::::\n', JSON.stringify(toolDefinitions, null, 2));
93
+ try {
94
+ // Single LLM request — tool execution happens externally via action:* handlers.
95
+ const result = await generateText({
96
+ model,
97
+ system: systemPrompt,
98
+ messages,
99
+ tools: toolDefinitions,
100
+ stopWhen: ({ steps }) => steps.length === 1,
101
+ allowSystemInMessages: true,
102
+ });
103
+ const toolCalls = result.toolCalls ?? [];
104
+ // if (result.usage) {
105
+ // const usage = result.usage;
106
+ // yield {
107
+ // type: 'agent:usage',
108
+ // data: {
109
+ // usage: {
110
+ // promptTokens: usage.inputTokens,
111
+ // completionTokens: usage.outputTokens,
112
+ // totalTokens: usage.totalTokens,
113
+ // currentContextTokens: usage.inputTokens,
114
+ // contextBudget: getContextBudgetForModel(currentModelString),
115
+ // },
116
+ // model: currentModelString,
117
+ // },
118
+ // meta: {
119
+ // agentId: context.state.agentId,
120
+ // threadId,
121
+ // runId: context.state.runId,
122
+ // },
123
+ // } as OpenBotEvent;
124
+ // }
125
+ const outputMeta = {
126
+ agentId: context.state.agentId,
127
+ threadId,
128
+ parentAgentId,
129
+ parentToolCallId,
130
+ };
131
+ // Text before actions so history/UI show the model's intent first.
132
+ if (result.text) {
133
+ yield {
134
+ type: 'agent:output',
135
+ data: { content: result.text },
136
+ meta: outputMeta,
137
+ };
138
+ }
139
+ if (toolCalls.length > 0) {
140
+ // when multiple tool calls are made, Melony runtime handles them one by one, thats why we need to start a new batch
141
+ toolBatch.startBatch(toolCalls.map((tc) => tc.toolCallId));
142
+ for (const toolCall of toolCalls) {
143
+ yield {
144
+ type: `action:${toolCall.toolName}`,
145
+ data: toolCall.input,
146
+ meta: {
147
+ toolCallId: toolCall.toolCallId,
148
+ ...outputMeta,
149
+ },
150
+ };
151
+ }
152
+ }
153
+ else {
154
+ // clear the tool batch if there are no tool calls
155
+ toolBatch.clear();
156
+ }
157
+ }
158
+ catch (error) {
159
+ const errorMessage = error instanceof Error ? error.message : String(error);
160
+ const isApiKeyError = errorMessage.includes('API key') ||
161
+ errorMessage.includes('401') ||
162
+ errorMessage.includes('Unauthorized') ||
163
+ errorMessage.includes('authentication');
164
+ if (isApiKeyError) {
165
+ const [currentProvider, ...rest] = currentModelString.split('/');
166
+ const currentModelId = rest.join('/');
167
+ yield {
168
+ type: 'client:ui:widget',
169
+ data: {
170
+ kind: 'form',
171
+ widgetId: `api_key_request_${Date.now()}`,
172
+ title: `AI Provider API Key Required`,
173
+ description: API_KEY_SETUP_MESSAGE,
174
+ fields: [
175
+ {
176
+ id: 'provider',
177
+ label: 'Provider',
178
+ type: 'select',
179
+ required: true,
180
+ options: [
181
+ { label: 'OpenAI', value: 'openai' },
182
+ { label: 'Anthropic', value: 'anthropic' },
183
+ ],
184
+ defaultValue: currentProvider === 'anthropic' ? 'anthropic' : 'openai',
185
+ },
186
+ {
187
+ id: 'model',
188
+ label: 'Model',
189
+ type: 'text',
190
+ description: 'Model name without the provider prefix (e.g. `gpt-4o-mini` or `claude-3-5-sonnet-20240620`).',
191
+ placeholder: 'gpt-4o-mini',
192
+ required: true,
193
+ defaultValue: currentModelId,
194
+ },
195
+ {
196
+ id: 'apiKey',
197
+ label: 'API Key',
198
+ type: 'text',
199
+ placeholder: `sk-...`,
200
+ required: true,
201
+ },
202
+ ],
203
+ submitLabel: 'Save & Continue',
204
+ metadata: {
205
+ type: 'api_key_request',
206
+ },
207
+ },
208
+ meta: { agentId: context.state.agentId, threadId },
209
+ };
210
+ return;
211
+ }
212
+ throw error;
213
+ }
214
+ };
215
+ builder.on('agent:invoke', async function* (event, context) {
216
+ const routedTo = event.data?.agentId;
217
+ if (typeof routedTo === 'string' && routedTo && routedTo !== context.state.agentId) {
218
+ return;
219
+ }
220
+ // clear the tool batch if the agent is invoked
221
+ // this is to prevent the tool batch from being used for a new agent invocation
222
+ toolBatch.clear();
223
+ const threadId = event.meta?.threadId || context.state.threadId;
224
+ yield* runLLM(context, threadId, event);
225
+ });
226
+ // this is to handle the tool results from the tool calls
227
+ // because Melony runtime handles them one by one, thats why we need to record the result
228
+ builder.on('*', async function* (event, context) {
229
+ if (!event.type.endsWith(':result'))
230
+ return;
231
+ if (event.meta?.agentId !== context.state.agentId)
232
+ return;
233
+ const toolCallId = event.meta?.toolCallId;
234
+ // record the result of the tool call
235
+ if (!toolCallId || !toolBatch.recordResult(toolCallId))
236
+ return;
237
+ const threadId = event.meta?.threadId || context.state.threadId;
238
+ yield* runLLM(context, threadId);
239
+ });
240
+ builder.on('client:ui:widget:response', async function* (event, context) {
241
+ const { metadata, values } = event.data;
242
+ if (metadata?.type !== 'api_key_request')
243
+ return;
244
+ if (!values?.apiKey || !values?.provider || !values?.model)
245
+ return;
246
+ const provider = String(values.provider);
247
+ const modelId = String(values.model).trim();
248
+ const apiKey = String(values.apiKey);
249
+ if (provider !== 'openai' && provider !== 'anthropic') {
250
+ yield {
251
+ type: 'agent:output',
252
+ data: { content: `Unsupported provider: ${provider}` },
253
+ meta: { agentId: context.state.agentId },
254
+ };
255
+ return;
256
+ }
257
+ const envVar = provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY';
258
+ const newModelString = `${provider}/${modelId}`;
259
+ if (!storage)
260
+ return;
261
+ try {
262
+ await storage.createVariable({ key: envVar, value: apiKey, secret: true });
263
+ process.env[envVar] = apiKey;
264
+ currentModelString = newModelString;
265
+ model = resolveModel(currentModelString);
266
+ try {
267
+ saveConfig({ model: currentModelString });
268
+ // Also update the agent's AGENT.md if it has an openbot plugin config
269
+ const details = await storage.getAgentDetails({ agentId: context.state.agentId });
270
+ const updatedPlugins = details.pluginRefs.map((ref) => {
271
+ if (ref.id === 'openbot') {
272
+ return {
273
+ ...ref,
274
+ config: { ...ref.config, model: currentModelString },
275
+ };
276
+ }
277
+ return ref;
278
+ });
279
+ await storage.updateAgent({
280
+ agentId: context.state.agentId,
281
+ plugins: updatedPlugins,
282
+ });
283
+ }
284
+ catch {
285
+ // best-effort: config persistence failure shouldn't block the conversation
286
+ }
287
+ yield {
288
+ type: 'agent:output',
289
+ data: {
290
+ content: `Saved ${provider} API key and set model to \`${newModelString}\`.`,
291
+ },
292
+ meta: { agentId: context.state.agentId },
293
+ };
294
+ yield {
295
+ type: 'client:ui:widget',
296
+ data: {
297
+ widgetId: event.data.widgetId,
298
+ kind: 'message',
299
+ title: 'API Key Saved',
300
+ body: `Successfully saved ${provider} API key and selected model \`${newModelString}\`. You can now continue your conversation.`,
301
+ state: 'submitted',
302
+ actions: [{ id: 'ok', label: 'Got it', variant: 'primary' }],
303
+ },
304
+ meta: { agentId: context.state.agentId },
305
+ };
306
+ }
307
+ catch (error) {
308
+ yield {
309
+ type: 'agent:output',
310
+ data: {
311
+ content: `Failed to save API key: ${error instanceof Error ? error.message : 'Unknown error'}`,
312
+ },
313
+ meta: { agentId: context.state.agentId },
314
+ };
315
+ }
316
+ });
317
+ };
@@ -0,0 +1,5 @@
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.',
3
+ ].join('\n');
4
+ /** Shown in the API key setup form when no provider credentials are configured. */
5
+ export const API_KEY_SETUP_MESSAGE = 'OpenBot runs AI agents locally with tools, memory, and delegation. Bring your own OpenAI or Anthropic key — it stays on your machine. Use the form below to get started.';