openbot 0.3.6 → 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 (96) 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/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 +91 -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 +105 -149
  27. package/dist/plugins/delegation/index.js +119 -32
  28. package/dist/plugins/memory/index.js +103 -14
  29. package/dist/plugins/memory/service.js +152 -0
  30. package/dist/plugins/openbot/context.js +80 -0
  31. package/dist/plugins/openbot/history.js +98 -0
  32. package/dist/plugins/openbot/index.js +31 -0
  33. package/dist/plugins/openbot/runtime.js +317 -0
  34. package/dist/plugins/openbot/system-prompt.js +5 -0
  35. package/dist/plugins/plugin-manager/index.js +105 -0
  36. package/dist/plugins/storage/index.js +573 -0
  37. package/dist/plugins/storage/service.js +1159 -0
  38. package/dist/plugins/storage-tools/index.js +2 -2
  39. package/dist/plugins/thread-namer/index.js +72 -0
  40. package/dist/plugins/thread-naming/generate-title.js +44 -0
  41. package/dist/plugins/thread-naming/index.js +103 -0
  42. package/dist/plugins/threads/index.js +114 -0
  43. package/dist/plugins/todo/index.js +24 -25
  44. package/dist/plugins/ui/index.js +2 -32
  45. package/dist/registry/plugins.js +3 -9
  46. package/dist/services/plugins/domain.js +1 -0
  47. package/dist/services/plugins/plugin-cache.js +9 -0
  48. package/dist/services/plugins/registry.js +110 -0
  49. package/dist/services/plugins/service.js +177 -0
  50. package/dist/services/plugins/types.js +1 -0
  51. package/dist/services/process.js +29 -0
  52. package/dist/services/storage.js +11 -10
  53. package/dist/services/thread-naming.js +81 -0
  54. package/docs/agents.md +16 -10
  55. package/docs/architecture.md +2 -2
  56. package/docs/plugins.md +6 -15
  57. package/docs/templates/AGENT.example.md +7 -13
  58. package/package.json +1 -2
  59. package/src/app/agent-ids.ts +5 -0
  60. package/src/app/cli.ts +1 -1
  61. package/src/app/config.ts +1 -31
  62. package/src/app/server.ts +8 -16
  63. package/src/app/types.ts +63 -189
  64. package/src/harness/index.ts +145 -0
  65. package/src/plugins/approval/index.ts +91 -189
  66. package/src/plugins/delegation/index.ts +136 -39
  67. package/src/plugins/memory/index.ts +112 -15
  68. package/src/{services/memory.ts → plugins/memory/service.ts} +1 -1
  69. package/src/plugins/openbot/context.ts +91 -0
  70. package/src/plugins/openbot/history.ts +107 -0
  71. package/src/plugins/openbot/index.ts +37 -0
  72. package/src/plugins/openbot/runtime.ts +384 -0
  73. package/src/plugins/openbot/system-prompt.ts +7 -0
  74. package/src/plugins/plugin-manager/index.ts +122 -0
  75. package/src/plugins/shell/index.ts +1 -1
  76. package/src/plugins/storage/index.ts +633 -0
  77. package/src/{services/storage.ts → plugins/storage/service.ts} +224 -67
  78. package/src/{bus/types.ts → services/plugins/domain.ts} +16 -7
  79. package/src/services/plugins/plugin-cache.ts +13 -0
  80. package/src/{registry/plugins.ts → services/plugins/registry.ts} +25 -27
  81. package/src/services/{plugins.ts → plugins/service.ts} +96 -2
  82. package/src/{bus/plugin.ts → services/plugins/types.ts} +3 -3
  83. package/src/bus/services.ts +0 -954
  84. package/src/harness/context.ts +0 -365
  85. package/src/harness/dispatcher.ts +0 -379
  86. package/src/harness/mcp.ts +0 -78
  87. package/src/harness/runtime-factory.ts +0 -129
  88. package/src/harness/todo-advance.ts +0 -128
  89. package/src/plugins/ai-sdk/index.ts +0 -41
  90. package/src/plugins/ai-sdk/runtime.ts +0 -468
  91. package/src/plugins/ai-sdk/system-prompt.ts +0 -18
  92. package/src/plugins/mcp/index.ts +0 -128
  93. package/src/plugins/storage-tools/index.ts +0 -90
  94. package/src/plugins/todo/index.ts +0 -64
  95. package/src/plugins/ui/index.ts +0 -227
  96. /package/src/{harness → services}/process.ts +0 -0
package/README.md CHANGED
@@ -18,7 +18,8 @@ OpenBot is a local-first harness for running AI agents. It is built around a sma
18
18
 
19
19
  - Runs a local agent server.
20
20
  - Stores channels, threads, agents, plugins, config, and variables under `~/.openbot`.
21
- - Ships with a built-in `system` agent named OpenBot.
21
+ - Ships with a built-in `system` agent named OpenBot (orchestrator, includes the LLM runtime).
22
+ - Ships with a built-in `state` agent for deterministic, non-LLM handling (e.g. `/api/state` defaults).
22
23
  - Loads custom agents from `~/.openbot/agents/<agent-id>/AGENT.md`.
23
24
  - Loads shared plugins from `~/.openbot/plugins`.
24
25
  - Streams events to clients with Server-Sent Events.
@@ -50,15 +51,17 @@ npm run dev
50
51
  OpenBot intentionally keeps the public API small:
51
52
 
52
53
  - `GET /api/events` opens an SSE stream for a channel or thread.
53
- - `POST /api/publish` publishes an event into the harness.
54
- - `GET /api/state` runs an event and returns the resulting events without opening a stream.
54
+ - `POST /api/publish` publishes an event into the harness (defaults to the built-in `system` agent with the OpenBot / LLM runtime).
55
+ - `GET /api/state` runs an event and returns the resulting events without opening a stream (defaults to the built-in `state` agent: storage-oriented plugins, no LLM).
56
+
57
+ You can override the agent with `agentId` (header, query, or body where applicable).
55
58
 
56
59
  Example:
57
60
 
58
61
  ```bash
59
62
  curl -X POST http://localhost:4132/api/publish \
60
63
  -H "content-type: application/json" \
61
- -d '{"type":"user:input","data":{"content":"hello"}}'
64
+ -d '{"type":"agent:invoke","data":{"role":"user","content":"hello"}}'
62
65
  ```
63
66
 
64
67
  Useful context can be passed as headers, query params, or body fields:
@@ -76,8 +79,7 @@ OpenBot reads config from `~/.openbot/config.json`.
76
79
  {
77
80
  "port": 4132,
78
81
  "baseDir": "~/.openbot",
79
- "model": "openai/gpt-4o-mini",
80
- "mcpServers": []
82
+ "model": "openai/gpt-4o-mini"
81
83
  }
82
84
  ```
83
85
 
@@ -91,12 +93,11 @@ The built-in `system` agent is always available. Add a custom agent by creating
91
93
  ---
92
94
  name: Researcher
93
95
  description: Helps collect and summarize information.
94
- runtime:
95
- name: ai-sdk
96
- config:
97
- model: openai/gpt-4o-mini
98
96
  plugins:
99
- - name: storage
97
+ - id: openbot
98
+ config:
99
+ model: openai/gpt-4o-mini
100
+ - id: storage
100
101
  ---
101
102
 
102
103
  You are a careful research assistant.
@@ -109,18 +110,16 @@ Agents are discovered from disk when the server starts.
109
110
 
110
111
  Built-in plugins include:
111
112
 
112
- - `storage`
113
+ - `storage-tools`
113
114
  - `delegation`
114
- - `mcp`
115
- - `ui`
116
- - `ai-sdk`
115
+ - `openbot`
117
116
 
118
117
  Shared plugins can be placed in `~/.openbot/plugins` and referenced by agents.
119
118
 
120
119
  ## Project Layout
121
120
 
122
121
  - `src/app`: CLI, server, event types, and app config.
123
- - `src/harness`: orchestration, process, and MCP runtime helpers.
122
+ - `src/harness`: orchestration and process helpers.
124
123
  - `src/plugins`: built-in plugin implementations.
125
124
  - `src/services`: local storage service.
126
125
  - `src/registry`: plugin registry.
@@ -0,0 +1,4 @@
1
+ /** Built-in orchestrator agent id. Optional `agents/system/AGENT.md` overrides code defaults. */
2
+ export const ORCHESTRATOR_AGENT_ID = 'system';
3
+ /** Built-in infra agent for deterministic `/api/state` and marketplace/plugin lifecycle; optional AGENT.md overlay. */
4
+ export const STATE_AGENT_ID = 'state';
package/dist/app/cli.js CHANGED
@@ -16,7 +16,7 @@ function checkNodeVersion() {
16
16
  }
17
17
  }
18
18
  checkNodeVersion();
19
- program.name('openbot').description('OpenBot CLI').version('0.3.6');
19
+ program.name('openbot').description('OpenBot CLI').version('0.4.0');
20
20
  program
21
21
  .command('start')
22
22
  .description('Start the OpenBot harness')
@@ -46,22 +46,3 @@ export function loadVariables() {
46
46
  }
47
47
  return { version: 1, variables: [] };
48
48
  }
49
- export const DEFAULT_AGENT_MD = `---
50
- description: A specialized AI agent
51
- ---
52
-
53
- # Agent Profile
54
-
55
- You are a specialized AI agent within the OpenBot system.
56
- Your role is defined by your configuration and the tools you have access to.
57
-
58
- ## Persona
59
- - Helpful and precise
60
- - Focused on my specific domain
61
- - Professional in all interactions
62
- `;
63
- export const DEFAULT_USER_MD = `# About Me
64
-
65
- <!-- OpenBot reads this file to understand who you are and how you like to work. -->
66
- <!-- Edit it here or just chat — agents can update it with the "remember" tool. -->
67
- `;
@@ -9,10 +9,9 @@ const require = createRequire(import.meta.url);
9
9
  const pkg = require('../../package.json');
10
10
  import { generateId } from 'melony';
11
11
  import { DEFAULT_BASE_DIR, loadConfig, resolvePath } from '../app/config.js';
12
- import { processService } from '../harness/process.js';
13
- import { storageService } from '../services/storage.js';
14
- import { dispatch } from '../harness/dispatcher.js';
15
- import { initPlugins } from '../registry/plugins.js';
12
+ import { processService } from '../services/process.js';
13
+ import { runAgent, STATE_AGENT_ID, ORCHESTRATOR_AGENT_ID } from '../harness/index.js';
14
+ import { initPlugins } from '../services/plugins/registry.js';
16
15
  import { ensureEventId, openBotEventFromQuery } from './utils.js';
17
16
  export async function startServer(options = {}) {
18
17
  const publishEventSchema = z
@@ -171,7 +170,6 @@ export async function startServer(options = {}) {
171
170
  return;
172
171
  }
173
172
  const onEvent = async (chunk, state) => {
174
- ensureEventId(chunk);
175
173
  const targetChannelId = state?.channelId || channelId;
176
174
  const targetThreadId = state?.threadId || threadId;
177
175
  const targetClientKey = getClientKey(targetChannelId, targetThreadId);
@@ -186,11 +184,6 @@ export async function startServer(options = {}) {
186
184
  else if (chunk.type === 'agent:run:end') {
187
185
  activeRuns.delete(getRunKey(chunk.data.runId, chunk.data.agentId, chunk.data.channelId, chunk.data.threadId));
188
186
  }
189
- await storageService.storeEvent({
190
- channelId: targetChannelId,
191
- threadId: targetThreadId,
192
- event: chunk,
193
- });
194
187
  sendToClientKey(targetClientKey, chunk);
195
188
  if (chunk.type === 'agent:run:start' ||
196
189
  chunk.type === 'agent:run:end' ||
@@ -200,9 +193,9 @@ export async function startServer(options = {}) {
200
193
  };
201
194
  try {
202
195
  ensureEventId(event);
203
- await dispatch({
196
+ await runAgent({
204
197
  runId,
205
- agentId: agentId || 'system',
198
+ agentId: agentId || ORCHESTRATOR_AGENT_ID,
206
199
  event,
207
200
  channelId,
208
201
  threadId,
@@ -238,12 +231,13 @@ export async function startServer(options = {}) {
238
231
  };
239
232
  try {
240
233
  ensureEventId(event);
241
- await dispatch({
234
+ await runAgent({
242
235
  runId,
243
- agentId: agentId || 'system',
236
+ agentId: agentId || STATE_AGENT_ID,
244
237
  event,
245
238
  channelId,
246
239
  threadId,
240
+ persistEvents: false,
247
241
  onEvent,
248
242
  });
249
243
  res.json({ events });
@@ -1,25 +1,6 @@
1
1
  import { DEFAULT_MARKETPLACE_REGISTRY_URL, loadConfig } from '../app/config.js';
2
2
  import { storageService } from '../services/storage.js';
3
3
  import { pluginService } from '../services/plugins.js';
4
- const readTodos = (state) => {
5
- const raw = state.threadDetails?.state?.todos;
6
- return Array.isArray(raw) ? raw : [];
7
- };
8
- let todoCounter = 0;
9
- const newTodoId = (now, idx) => `todo_${now.toString(36)}_${(todoCounter++).toString(36)}_${idx}`;
10
- async function persistTodos(storage, state, todos) {
11
- if (!state.threadId)
12
- throw new Error('No active thread');
13
- await storage.patchThreadState({
14
- channelId: state.channelId,
15
- threadId: state.threadId,
16
- state: { todos },
17
- });
18
- state.threadDetails = await storage.getThreadDetails({
19
- channelId: state.channelId,
20
- threadId: state.threadId,
21
- });
22
- }
23
4
  /**
24
5
  * Resolve a scope alias to a concrete scope string. Aliases let tools accept
25
6
  * `agent`/`channel`/`global` without knowing the active ids; the bus rewrites
@@ -44,26 +25,7 @@ function resolveMemoryScopeFilter(alias, state) {
44
25
  }
45
26
  return [resolveMemoryScope(alias, state)];
46
27
  }
47
- const DEFAULT_MARKETPLACE_AGENTS = [
48
- {
49
- id: 'researcher',
50
- name: 'Researcher',
51
- description: 'Specialized in web research and information synthesis.',
52
- instructions: 'You are a research assistant. Use available tools to find information.',
53
- plugins: [
54
- { id: 'ai-sdk', config: { model: 'openai/gpt-4o' } },
55
- { id: 'mcp' },
56
- { id: 'shell' },
57
- ],
58
- },
59
- {
60
- id: 'coder',
61
- name: 'Coder',
62
- description: 'Expert in multiple programming languages and software architecture.',
63
- instructions: 'You are an expert software engineer. Help the user with coding tasks.',
64
- plugins: [{ id: 'claude-code' }],
65
- },
66
- ];
28
+ const DEFAULT_MARKETPLACE_AGENTS = [];
67
29
  function isRecord(value) {
68
30
  return typeof value === 'object' && value !== null && !Array.isArray(value);
69
31
  }
@@ -311,6 +273,15 @@ export const busServicesPlugin = (options) => (builder) => {
311
273
  context.state.channelDetails = await storage.getChannelDetails({
312
274
  channelId: context.state.channelId,
313
275
  });
276
+ yield {
277
+ type: "client:ui:widget",
278
+ data: {
279
+ kind: "message",
280
+ title: "Channel details updated.",
281
+ body: "The channel details have been updated.",
282
+ },
283
+ meta: resultMeta,
284
+ };
314
285
  yield {
315
286
  type: 'action:patch_channel_details:result',
316
287
  data: { success: true, updatedFields },
@@ -358,91 +329,30 @@ export const busServicesPlugin = (options) => (builder) => {
358
329
  };
359
330
  }
360
331
  });
361
- builder.on('action:todo_write', async function* (event, context) {
362
- const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
363
- try {
364
- if (!context.state.threadId) {
365
- throw new Error('todo_write requires an active thread');
366
- }
367
- const existing = readTodos(context.state);
368
- const byId = new Map(existing.map((t) => [t.id, t]));
369
- const now = Date.now();
370
- const author = context.state.agentId || 'system';
371
- const inputs = event.data.todos || [];
372
- const next = inputs.map((raw, idx) => {
373
- const prior = raw.id ? byId.get(raw.id) : undefined;
374
- return {
375
- id: prior?.id || raw.id || newTodoId(now, idx),
376
- content: raw.content,
377
- status: raw.status || prior?.status || 'pending',
378
- assignee: raw.assignee ?? prior?.assignee,
379
- createdBy: prior?.createdBy || author,
380
- createdAt: prior?.createdAt || now,
381
- updatedAt: now,
382
- ...(prior?.result !== undefined ? { result: prior.result } : {}),
383
- };
384
- });
385
- await persistTodos(storage, context.state, next);
386
- yield {
387
- type: 'action:todo_write:result',
388
- data: { success: true, todos: next },
389
- meta: resultMeta,
390
- };
391
- }
392
- catch (error) {
393
- yield {
394
- type: 'action:todo_write:result',
395
- data: {
396
- success: false,
397
- todos: readTodos(context.state),
398
- error: error instanceof Error ? error.message : 'Unknown error',
399
- },
400
- meta: resultMeta,
401
- };
402
- }
403
- });
404
- builder.on('action:todo_update', async function* (event, context) {
405
- const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
406
- const patch = event.data;
407
- try {
408
- if (!context.state.threadId) {
409
- throw new Error('todo_update requires an active thread');
410
- }
411
- const existing = readTodos(context.state);
412
- const idx = existing.findIndex((t) => t.id === patch.id);
413
- if (idx === -1) {
414
- throw new Error(`Todo "${patch.id}" not found`);
415
- }
416
- const now = Date.now();
417
- const updated = {
418
- ...existing[idx],
419
- ...(patch.content !== undefined ? { content: patch.content } : {}),
420
- ...(patch.status !== undefined ? { status: patch.status } : {}),
421
- ...(patch.assignee !== undefined
422
- ? { assignee: patch.assignee === '' ? undefined : patch.assignee }
423
- : {}),
424
- updatedAt: now,
425
- };
426
- const next = [...existing];
427
- next[idx] = updated;
428
- await persistTodos(storage, context.state, next);
429
- yield {
430
- type: 'action:todo_update:result',
431
- data: { success: true, todo: updated, todos: next },
432
- meta: resultMeta,
433
- };
434
- }
435
- catch (error) {
436
- yield {
437
- type: 'action:todo_update:result',
438
- data: {
439
- success: false,
440
- todos: readTodos(context.state),
441
- error: error instanceof Error ? error.message : 'Unknown error',
442
- },
443
- meta: resultMeta,
444
- };
445
- }
332
+ builder.on('agent:usage', async function* (event, context) {
333
+ const { usage } = event.data;
334
+ if (!context.state.threadId)
335
+ return;
336
+ const currentState = context.state.threadDetails?.state || {};
337
+ const currentUsage = currentState.usage || {
338
+ promptTokens: 0,
339
+ completionTokens: 0,
340
+ totalTokens: 0,
341
+ };
342
+ const nextUsage = {
343
+ promptTokens: (currentUsage.promptTokens || 0) + usage.promptTokens,
344
+ completionTokens: (currentUsage.completionTokens || 0) + usage.completionTokens,
345
+ totalTokens: (currentUsage.totalTokens || 0) + usage.totalTokens,
346
+ };
347
+ await storage.patchThreadState({
348
+ channelId: context.state.channelId,
349
+ threadId: context.state.threadId,
350
+ state: { usage: nextUsage },
351
+ });
352
+ context.state.threadDetails = await storage.getThreadDetails({
353
+ channelId: context.state.channelId,
354
+ threadId: context.state.threadId,
355
+ });
446
356
  });
447
357
  builder.on('action:storage:get-channels', async function* () {
448
358
  const channels = await storage.getChannels();
@@ -0,0 +1,44 @@
1
+ import { createAgentRuntime } from './runtime-factory.js';
2
+ /**
3
+ * Shared `runtime.run(agent:invoke)` loop: tags chunks with `agentId`, skips echo of the
4
+ * trigger event, optionally polls for stop, tracks last `agent:output` text.
5
+ *
6
+ * Does not emit `agent:run:start` / `agent:run:end` — callers bracket those.
7
+ */
8
+ export async function* streamTaggedAgentInvokeRuntime(options) {
9
+ const { target, event, state, pollInterrupt } = options;
10
+ const { runId, agentId } = target;
11
+ let lastAgentOutput;
12
+ const runtime = await createAgentRuntime(state);
13
+ for await (const chunk of runtime.run(event, { state, runId })) {
14
+ const interrupt = pollInterrupt?.();
15
+ if (interrupt) {
16
+ yield interrupt;
17
+ break;
18
+ }
19
+ if (chunk.id === event.id && chunk.type === event.type)
20
+ continue;
21
+ if (chunk.type === 'agent:output' &&
22
+ chunk.meta?.agentId === agentId) {
23
+ const content = chunk.data?.content;
24
+ if (typeof content === 'string' && content.trim()) {
25
+ lastAgentOutput = content.trim();
26
+ }
27
+ }
28
+ chunk.meta = { ...chunk.meta, agentId };
29
+ yield chunk;
30
+ }
31
+ return { lastAgentOutput };
32
+ }
33
+ /**
34
+ * Drive {@link streamTaggedAgentInvokeRuntime} with a callback per chunk (dispatcher path).
35
+ */
36
+ export async function consumeAgentInvokeStream(options, onChunk) {
37
+ const gen = streamTaggedAgentInvokeRuntime(options);
38
+ let step = await gen.next();
39
+ while (!step.done) {
40
+ await onChunk(step.value);
41
+ step = await gen.next();
42
+ }
43
+ return step.value;
44
+ }
@@ -0,0 +1,99 @@
1
+ import { ensureEventId } from '../app/utils.js';
2
+ import { storageService } from '../services/storage.js';
3
+ import { createAgentRuntime } from './runtime-factory.js';
4
+ const TODO_RESULT_MAX_CHARS = 12000;
5
+ const readThreadState = (state) => state.threadDetails?.state ?? {};
6
+ const readTodos = (state) => {
7
+ const raw = readThreadState(state).todos;
8
+ return Array.isArray(raw) ? raw : [];
9
+ };
10
+ function truncateTodoResult(text, maxChars = TODO_RESULT_MAX_CHARS) {
11
+ const trimmed = text.trim();
12
+ if (!trimmed)
13
+ return undefined;
14
+ if (trimmed.length <= maxChars)
15
+ return trimmed;
16
+ return `${trimmed.slice(0, maxChars)}\n…[truncated]`;
17
+ }
18
+ function resolveTodoIdForWorker(todos, workerId, delegationTodoId) {
19
+ if (delegationTodoId && todos.some((t) => t.id === delegationTodoId)) {
20
+ return delegationTodoId;
21
+ }
22
+ const inProgress = todos.find((t) => t.status === 'in_progress' && t.assignee === workerId);
23
+ if (inProgress)
24
+ return inProgress.id;
25
+ const assigned = todos.find((t) => (t.status === 'pending' || t.status === 'in_progress') && t.assignee === workerId);
26
+ return assigned?.id;
27
+ }
28
+ export async function recordWorkerTodoResult(state, workerId, output, delegationTodoId) {
29
+ if (!state.threadId)
30
+ return;
31
+ const result = truncateTodoResult(output ?? '');
32
+ if (!result)
33
+ return;
34
+ const todos = readTodos(state);
35
+ if (todos.length === 0)
36
+ return;
37
+ const todoId = resolveTodoIdForWorker(todos, workerId, delegationTodoId);
38
+ if (!todoId)
39
+ return;
40
+ const prior = todos.find((t) => t.id === todoId);
41
+ if (prior?.result === result)
42
+ return;
43
+ const now = Date.now();
44
+ const next = todos.map((t) => (t.id === todoId ? { ...t, result, updatedAt: now } : t));
45
+ await storageService.patchThreadState({
46
+ channelId: state.channelId,
47
+ threadId: state.threadId,
48
+ state: { todos: next },
49
+ });
50
+ }
51
+ export function makeInternalInvoke(content, threadId) {
52
+ return ensureEventId({
53
+ type: 'agent:invoke',
54
+ data: { role: 'user', content },
55
+ meta: { threadId, internal: true },
56
+ });
57
+ }
58
+ /**
59
+ * Run one agent turn (no dispatcher chaining). Yields all runtime events for
60
+ * persistence/streaming; returns the last non-empty `agent:output` text.
61
+ */
62
+ export async function* runAgentTurn(options) {
63
+ const { runId, channelId, threadId, agentId, event, delegationTodoId } = options;
64
+ const target = { runId, agentId, channelId, threadId };
65
+ let state;
66
+ try {
67
+ state = await storageService.getOpenBotState({ ...target, event });
68
+ }
69
+ catch (error) {
70
+ if (error.code === 'AGENT_NOT_FOUND') {
71
+ return undefined;
72
+ }
73
+ throw error;
74
+ }
75
+ yield { type: 'agent:run:start', data: { ...target } };
76
+ let lastAgentOutput;
77
+ try {
78
+ const runtime = await createAgentRuntime(state);
79
+ for await (const chunk of runtime.run(event, { state, runId })) {
80
+ if (chunk.id === event.id && chunk.type === event.type)
81
+ continue;
82
+ if (chunk.type === 'agent:output' &&
83
+ chunk.meta?.agentId === agentId) {
84
+ const content = chunk.data?.content;
85
+ if (typeof content === 'string' && content.trim()) {
86
+ lastAgentOutput = content.trim();
87
+ }
88
+ }
89
+ chunk.meta = { ...chunk.meta, agentId };
90
+ yield chunk;
91
+ }
92
+ }
93
+ finally {
94
+ const stateAfterRun = await storageService.getOpenBotState({ ...target, event });
95
+ yield { type: 'agent:run:end', data: { ...target } };
96
+ await recordWorkerTodoResult(stateAfterRun, agentId, lastAgentOutput, delegationTodoId);
97
+ }
98
+ return lastAgentOutput;
99
+ }
@@ -0,0 +1,40 @@
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
+ /** Multi-participant channel: user messages always route to the orchestrator. */
6
+ export function isMultiAgentChannel(participants) {
7
+ return participants.length > 1;
8
+ }
9
+ /** Solo DM: exactly one participant and it is the acting agent (no peer bots). */
10
+ export function isDmSoloChannel(participants, actingAgentId) {
11
+ return participants.length === 1 && participants[0] === actingAgentId;
12
+ }
13
+ /**
14
+ * Resolve which agent handles an incoming user message.
15
+ * Multi-participant channels always route to the orchestrator (hub-and-spoke).
16
+ */
17
+ export function resolveMessageTargetAgent(participants, orchestratorAgentId, requestedAgentId) {
18
+ if (isMultiAgentChannel(participants)) {
19
+ return orchestratorAgentId;
20
+ }
21
+ if (participants.length === 1) {
22
+ return requestedAgentId || participants[0];
23
+ }
24
+ return requestedAgentId || orchestratorAgentId;
25
+ }
26
+ /**
27
+ * When `participants` is non-empty, todo dispatch targets must appear
28
+ * in that list. Solo DM forbids targeting any agent other than yourself (for
29
+ * chained steps); there are no peer bots.
30
+ */
31
+ export function isParticipantDispatchAllowed(participants, actingAgentId, targetAgentId) {
32
+ if (participants.length === 0)
33
+ return true;
34
+ if (!participants.includes(targetAgentId))
35
+ return false;
36
+ if (isDmSoloChannel(participants, actingAgentId) && targetAgentId !== actingAgentId) {
37
+ return false;
38
+ }
39
+ return true;
40
+ }
@@ -0,0 +1,2 @@
1
+ /** Built-in orchestrator agent id (`~/.openbot/agents/system/AGENT.md` overrides instructions). */
2
+ export const ORCHESTRATOR_AGENT_ID = 'system';
@@ -0,0 +1,97 @@
1
+ import { OPENBOT_SYSTEM_PROMPT } from '../plugins/openbot/system-prompt.js';
2
+ import { ORCHESTRATOR_AGENT_ID, estimateTokens, } from './context.js';
3
+ import { reconstructHistory } from './history.js';
4
+ /** Reserved headroom for model output when computing fill percentage. */
5
+ export const CONTEXT_METER_OUTPUT_RESERVE = 4096;
6
+ const DEFAULT_CONTEXT_LIMIT = 128000;
7
+ const MODEL_CONTEXT_LIMITS = {
8
+ 'gpt-4o': 128000,
9
+ 'gpt-4o-mini': 128000,
10
+ 'gpt-4-turbo': 128000,
11
+ 'gpt-4': 128000,
12
+ 'gpt-3.5-turbo': 16385,
13
+ 'claude-3-5-sonnet-20240620': 200000,
14
+ 'claude-3-5-sonnet-20241022': 200000,
15
+ 'claude-3-opus-20240229': 200000,
16
+ 'claude-3-haiku-20240307': 200000,
17
+ };
18
+ export function getModelContextLimit(modelString) {
19
+ const modelId = modelString.split('/').slice(1).join('/');
20
+ if (MODEL_CONTEXT_LIMITS[modelId])
21
+ return MODEL_CONTEXT_LIMITS[modelId];
22
+ if (modelId.includes('claude'))
23
+ return 200000;
24
+ return DEFAULT_CONTEXT_LIMIT;
25
+ }
26
+ function buildInstructions(state) {
27
+ if (state.agentId === ORCHESTRATOR_AGENT_ID) {
28
+ return state.agentDetails?.instructions?.trim() || OPENBOT_SYSTEM_PROMPT;
29
+ }
30
+ return OPENBOT_SYSTEM_PROMPT;
31
+ }
32
+ function estimateMessagesTokens(messages) {
33
+ if (messages.length === 0)
34
+ return 0;
35
+ return estimateTokens(JSON.stringify(messages));
36
+ }
37
+ function estimateToolsTokens(tools) {
38
+ const names = Object.keys(tools);
39
+ if (names.length === 0)
40
+ return 0;
41
+ return estimateTokens(JSON.stringify(tools));
42
+ }
43
+ function applyTrigger(messages, trigger) {
44
+ const triggerContent = trigger?.data?.content?.trim();
45
+ if (!triggerContent || !trigger)
46
+ return messages;
47
+ const role = (trigger.data?.role || 'user');
48
+ const last = messages[messages.length - 1];
49
+ const alreadyLast = last &&
50
+ last.role === role &&
51
+ typeof last.content === 'string' &&
52
+ last.content.trim() === triggerContent;
53
+ if (alreadyLast)
54
+ return messages;
55
+ return [...messages, { role, content: triggerContent }];
56
+ }
57
+ function computePercent(used, limit) {
58
+ const budget = Math.max(limit - CONTEXT_METER_OUTPUT_RESERVE, 1);
59
+ return Math.min(100, Math.round((used / budget) * 100));
60
+ }
61
+ export async function computeContextMeter(options) {
62
+ const { state, storage, modelString, contextEngine, toolDefinitions = {}, trigger, lastUsage, } = options;
63
+ const limit = getModelContextLimit(modelString);
64
+ const instructions = buildInstructions(state);
65
+ const contextBlock = await contextEngine.buildContext(state, storage);
66
+ const systemTokens = estimateTokens([instructions, contextBlock].filter(Boolean).join('\n\n'));
67
+ const events = await storage.getEvents({
68
+ channelId: state.channelId,
69
+ threadId: state.threadId,
70
+ });
71
+ const messages = applyTrigger(reconstructHistory(events), trigger);
72
+ const historyTokens = estimateMessagesTokens(messages);
73
+ const toolsTokens = estimateToolsTokens(toolDefinitions);
74
+ let used = systemTokens + historyTokens + toolsTokens;
75
+ let estimated = true;
76
+ if (lastUsage?.input && lastUsage.input > 0) {
77
+ used = lastUsage.input;
78
+ estimated = false;
79
+ }
80
+ return {
81
+ model: modelString,
82
+ limit,
83
+ used,
84
+ percent: computePercent(used, limit),
85
+ estimated,
86
+ breakdown: {
87
+ system: systemTokens,
88
+ history: historyTokens,
89
+ tools: toolsTokens,
90
+ },
91
+ messageCount: messages.length,
92
+ ...(lastUsage ? { lastUsage } : {}),
93
+ };
94
+ }
95
+ export function contextMeterEvent(snapshot, meta) {
96
+ return { type: 'client:ui:context-meter', data: snapshot, meta };
97
+ }