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
@@ -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
+ }
@@ -1,3 +1,26 @@
1
+ import { isDmSoloChannel } from './channel-participants.js';
2
+ export const DEFAULT_CONTEXT_BUDGET = 8000;
3
+ /**
4
+ * Returns the known context window budget (in tokens) for a given model string.
5
+ * This is used to drive the context usage ring in the UI and to configure
6
+ * the prompt pruning budget.
7
+ */
8
+ export const getContextBudgetForModel = (modelString) => {
9
+ const budgets = {
10
+ 'openai/gpt-4o': 128000,
11
+ 'openai/gpt-4o-mini': 128000,
12
+ 'openai/o1-preview': 128000,
13
+ 'openai/o1-mini': 128000,
14
+ 'anthropic/claude-3-5-sonnet-20240620': 200000,
15
+ 'anthropic/claude-3-5-sonnet-latest': 200000,
16
+ 'anthropic/claude-3-opus-20240229': 200000,
17
+ 'anthropic/claude-3-sonnet-20240229': 200000,
18
+ 'anthropic/claude-3-haiku-20240307': 200000,
19
+ };
20
+ return budgets[modelString] || DEFAULT_CONTEXT_BUDGET;
21
+ };
22
+ /** Built-in orchestrator agent id (`~/.openbot/agents/system/AGENT.md` overrides instructions). */
23
+ export const ORCHESTRATOR_AGENT_ID = 'system';
1
24
  /**
2
25
  * Cheap, dependency-free token estimator. Roughly char/4 — fine for budget
3
26
  * enforcement; can be swapped for a tokenizer-backed implementation later
@@ -55,71 +78,95 @@ export class ContextEngine {
55
78
  */
56
79
  export function createDefaultContextEngine() {
57
80
  const engine = new ContextEngine();
81
+ engine.registerProvider(new EnvironmentProvider());
82
+ engine.registerProvider(new ChannelSpecProvider());
58
83
  engine.registerProvider(new AgentDetailsProvider());
59
- engine.registerProvider(new ChannelDetailsProvider());
60
- engine.registerProvider(new ThreadDetailsProvider());
61
84
  engine.registerProvider(new TodoProvider());
62
85
  engine.registerProvider(new MemoryProvider());
63
- engine.registerProvider(new RecentEventsProvider());
86
+ // engine.registerProvider(new RecentEventsProvider());
64
87
  engine.registerProcessor(new TokenBudgetProcessor());
65
88
  return engine;
66
89
  }
67
- class AgentDetailsProvider {
90
+ class EnvironmentProvider {
68
91
  constructor() {
69
- this.name = 'agent-details';
92
+ this.name = 'environment';
70
93
  }
71
94
  async provide(state) {
72
- if (!state.agentDetails)
73
- return [];
74
- const instructions = state.agentDetails.instructions?.trim();
75
- if (!instructions)
76
- return [];
77
- return [{
78
- id: 'agent-details',
79
- type: 'agent',
80
- priority: 100,
81
- content: `# ${state.agentDetails.name}\n\n${instructions}`,
82
- }];
95
+ const { channelId, threadId, channelDetails, agentId, threadDetails } = state;
96
+ const participants = channelDetails?.participants || [];
97
+ const isDm = isDmSoloChannel(participants, agentId);
98
+ let content = '## ENVIRONMENT\n';
99
+ if (isDm) {
100
+ content += '- Mode: Direct Message (Solo)\n';
101
+ content += '- Context: You are in a private conversation. No other agents are present.\n';
102
+ }
103
+ else {
104
+ const channelName = channelDetails?.name || channelId;
105
+ content += `- Mode: Channel (#${channelName})\n`;
106
+ if (threadId) {
107
+ content += `- Thread: ${threadDetails?.name || threadId}\n`;
108
+ }
109
+ const peerIds = participants.filter((id) => id !== agentId);
110
+ if (peerIds.length > 0) {
111
+ content += `- Participants: ${peerIds.join(', ')}\n`;
112
+ content += ` (Use these plain ids for todo assignees and delegate_to_agent — no @ prefix.)\n`;
113
+ }
114
+ }
115
+ return [
116
+ {
117
+ id: 'environment',
118
+ type: 'environment',
119
+ priority: 110,
120
+ content,
121
+ },
122
+ ];
83
123
  }
84
124
  }
85
- class ChannelDetailsProvider {
125
+ /**
126
+ * Injects SPEC.md (`channelDetails.spec`). Kept distinct from EnvironmentProvider
127
+ * so each block gets its own truncate budget and channel rules survive long
128
+ * participant lists under {@link ITEM_HARD_CHAR_CAP}.
129
+ */
130
+ class ChannelSpecProvider {
86
131
  constructor() {
87
- this.name = 'channel-details';
132
+ this.name = 'channel-spec';
88
133
  }
89
134
  async provide(state) {
90
- if (!state.channelDetails)
135
+ const raw = state.channelDetails?.spec;
136
+ const spec = typeof raw === 'string' ? raw.trim() : '';
137
+ if (!spec)
91
138
  return [];
92
- const participants = state.channelDetails.participants;
93
- if (!participants?.length)
94
- return [];
95
- const channelLabel = state.channelDetails.name?.trim() || state.channelDetails.id;
96
- const lines = participants.map((id) => `- \`${id}\``).join('\n');
97
139
  return [
98
140
  {
99
- id: 'channel-details',
100
- type: 'channel',
101
- priority: 80,
102
- content: `## Channel participants (${channelLabel})\n` +
103
- `Agent ids collaborating in this channel:\n${lines}`,
141
+ id: 'channel-spec',
142
+ type: 'channel-spec',
143
+ /** Below environment (110), above agent / {@link TokenBudgetProcessor.KEEP_FLOOR}. */
144
+ priority: 108,
145
+ content: `## CHANNEL SPECIFICATION (SPEC.md)\n` +
146
+ `Channel-level goals and constraints. Prefer these unless the user contradicts them.\n\n` +
147
+ `${spec}`,
104
148
  },
105
149
  ];
106
150
  }
107
151
  }
108
- class ThreadDetailsProvider {
152
+ class AgentDetailsProvider {
109
153
  constructor() {
110
- this.name = 'thread-details';
154
+ this.name = 'agent-details';
111
155
  }
112
156
  async provide(state) {
113
- if (!state.threadDetails)
157
+ if (!state.agentDetails)
158
+ return [];
159
+ if (state.agentId === ORCHESTRATOR_AGENT_ID)
160
+ return [];
161
+ const instructions = state.agentDetails.instructions?.trim();
162
+ if (!instructions)
114
163
  return [];
115
- // For now, this provider is a placeholder for future state-based assembly.
116
- // It currently only surfaces the thread name to provide basic context.
117
164
  return [
118
165
  {
119
- id: 'thread-details',
120
- type: 'thread',
121
- priority: 90,
122
- content: `# Thread you are in: ${state.threadDetails.name}`,
166
+ id: 'agent-details',
167
+ type: 'agent',
168
+ priority: 100,
169
+ content: `## AGENT: ${state.agentDetails.name}\n\n${instructions}`,
123
170
  },
124
171
  ];
125
172
  }
@@ -135,6 +182,8 @@ class TodoProvider {
135
182
  this.name = 'todos';
136
183
  }
137
184
  async provide(state) {
185
+ if (state.agentId !== ORCHESTRATOR_AGENT_ID)
186
+ return [];
138
187
  const raw = state.threadDetails?.state?.todos;
139
188
  const todos = Array.isArray(raw) ? raw : [];
140
189
  if (todos.length === 0)
@@ -165,9 +214,7 @@ class TodoProvider {
165
214
  id: 'todos',
166
215
  type: 'todos',
167
216
  priority: 92,
168
- content: `## Shared todo plan (thread state)\n` +
169
- `Orchestrator authors with \`todo_write\`; assignees run one step at a time. ` +
170
- `When an item is \`done\`, its captured output appears below so every agent can see prior steps without relying on merged chat history.\n\n` +
217
+ content: `## SHARED TODO PLAN (thread state)\n` +
171
218
  `${formatted}`,
172
219
  },
173
220
  ];
@@ -204,7 +251,7 @@ class MemoryProvider {
204
251
  id: 'memory',
205
252
  type: 'memory',
206
253
  priority: 95,
207
- content: `## Remembered facts\nTrust these unless the user contradicts them. Use \`forget\` to remove stale ones.\n\n${formatted}`,
254
+ content: `## Remembered global facts\nTrust these unless the user contradicts them. Use \`forget\` to remove stale ones.\n\n${formatted}`,
208
255
  },
209
256
  ];
210
257
  }
@@ -280,20 +327,21 @@ class RecentEventsProvider {
280
327
  }
281
328
  /**
282
329
  * Drops the lowest-priority items until the assembled prompt fits within the
283
- * token budget. The first item with priority >= `keepFloor` is always kept,
330
+ * token budget. The first item with priority >= \`keepFloor\` is always kept,
284
331
  * so the agent's own instructions can never be evicted. Stable on ties:
285
332
  * later-emitted items go first.
286
333
  */
287
334
  export class TokenBudgetProcessor {
288
- constructor(budget = TokenBudgetProcessor.DEFAULT_BUDGET, keepFloor = TokenBudgetProcessor.KEEP_FLOOR) {
335
+ constructor(budget = undefined, keepFloor = TokenBudgetProcessor.KEEP_FLOOR) {
289
336
  this.budget = budget;
290
337
  this.keepFloor = keepFloor;
291
338
  this.name = 'token-budget';
292
339
  }
293
- async process(items) {
340
+ async process(items, state) {
294
341
  const sorted = [...items].sort((a, b) => b.priority - a.priority);
295
342
  const out = [];
296
343
  let used = 0;
344
+ const activeBudget = this.budget ?? (state.model ? getContextBudgetForModel(state.model) : TokenBudgetProcessor.DEFAULT_BUDGET);
297
345
  for (const item of sorted) {
298
346
  const cost = estimateTokens(item.content);
299
347
  if (item.priority >= this.keepFloor) {
@@ -301,7 +349,7 @@ export class TokenBudgetProcessor {
301
349
  used += cost;
302
350
  continue;
303
351
  }
304
- if (used + cost <= this.budget) {
352
+ if (used + cost <= activeBudget) {
305
353
  out.push(item);
306
354
  used += cost;
307
355
  }
@@ -310,6 +358,6 @@ export class TokenBudgetProcessor {
310
358
  }
311
359
  }
312
360
  /** Soft prompt budget in tokens (matches gpt-4o-mini's reasonable system slice). */
313
- TokenBudgetProcessor.DEFAULT_BUDGET = 8000;
361
+ TokenBudgetProcessor.DEFAULT_BUDGET = DEFAULT_CONTEXT_BUDGET;
314
362
  /** Items at or above this priority are never dropped. */
315
363
  TokenBudgetProcessor.KEEP_FLOOR = 100;
@@ -0,0 +1,144 @@
1
+ import { ensureEventId } from '../app/utils.js';
2
+ import { storageService } from '../services/storage.js';
3
+ import { ORCHESTRATOR_AGENT_ID } from './constants.js';
4
+ import { runTurn } from './run.js';
5
+ const stopRequests = [];
6
+ const STOP_REQUEST_TTL_MS = 30 * 60 * 1000;
7
+ const pruneStopRequests = () => {
8
+ const now = Date.now();
9
+ for (let i = stopRequests.length - 1; i >= 0; i -= 1) {
10
+ if (now - stopRequests[i].requestedAt > STOP_REQUEST_TTL_MS) {
11
+ stopRequests.splice(i, 1);
12
+ }
13
+ }
14
+ };
15
+ const findStopRequest = (target) => {
16
+ pruneStopRequests();
17
+ return stopRequests.find((r) => {
18
+ if (r.runId !== target.runId)
19
+ return false;
20
+ if (r.agentId && r.agentId !== target.agentId)
21
+ return false;
22
+ if (r.channelId && r.channelId !== target.channelId)
23
+ return false;
24
+ if (r.threadId && r.threadId !== target.threadId)
25
+ return false;
26
+ return true;
27
+ });
28
+ };
29
+ export async function dispatch(options) {
30
+ const { runId, channelId, onEvent } = options;
31
+ let { threadId } = options;
32
+ const { event } = options;
33
+ ensureEventId(event);
34
+ if (event.type === 'action:agent_run_stop') {
35
+ await handleStop(event, options);
36
+ return;
37
+ }
38
+ const agentId = options.agentId || ORCHESTRATOR_AGENT_ID;
39
+ let turnEvent = event;
40
+ if (event.type === 'user:input' || event.type === 'agent:invoke') {
41
+ turnEvent = await normalizeUserInput(event, { runId, channelId, threadId, onEvent });
42
+ if (event.type === 'user:input') {
43
+ threadId = turnEvent.meta?.threadId || threadId || event.id;
44
+ }
45
+ }
46
+ const target = { runId, agentId, channelId, threadId };
47
+ const preStop = findStopRequest(target);
48
+ if (preStop) {
49
+ const state = await storageService.getOpenBotState({ ...target, event: turnEvent });
50
+ await onEvent({ type: 'agent:run:stopped', data: { ...target, reason: preStop.reason } }, state);
51
+ return;
52
+ }
53
+ let state;
54
+ try {
55
+ state = await storageService.getOpenBotState({ ...target, event: turnEvent });
56
+ }
57
+ catch (error) {
58
+ if (error.code === 'AGENT_NOT_FOUND') {
59
+ const fallback = await storageService.getOpenBotState({
60
+ ...target,
61
+ agentId: ORCHESTRATOR_AGENT_ID,
62
+ event: turnEvent,
63
+ });
64
+ await onEvent({
65
+ type: 'agent:output',
66
+ data: {
67
+ content: `⚠️ Agent **${agentId}** does not exist. Use participant ids without an @ prefix.`,
68
+ },
69
+ meta: { agentId: ORCHESTRATOR_AGENT_ID, threadId },
70
+ }, fallback);
71
+ return;
72
+ }
73
+ throw error;
74
+ }
75
+ const turn = runTurn({ runId, channelId, threadId, agentId, event: turnEvent });
76
+ let next = await turn.next();
77
+ while (!next.done) {
78
+ const chunk = next.value;
79
+ const stop = findStopRequest(target);
80
+ if (stop) {
81
+ await onEvent({ type: 'agent:run:stopped', data: { ...target, reason: stop.reason } }, state);
82
+ break;
83
+ }
84
+ if (chunk.type === 'action:create_thread:result' && chunk.data.success) {
85
+ threadId = chunk.data.threadId || threadId;
86
+ }
87
+ await onEvent(chunk, state);
88
+ next = await turn.next();
89
+ }
90
+ }
91
+ async function normalizeUserInput(event, ctx) {
92
+ const rawContent = event.data?.content || '';
93
+ const userFacing = {
94
+ type: 'agent:invoke',
95
+ id: event.id,
96
+ data: { content: rawContent, role: 'user' },
97
+ meta: {
98
+ agentId: ORCHESTRATOR_AGENT_ID,
99
+ userId: event.meta?.userId,
100
+ userName: event.meta?.userName,
101
+ userAvatarUrl: event.meta?.userAvatarUrl,
102
+ },
103
+ };
104
+ const initialState = await storageService.getOpenBotState({
105
+ runId: ctx.runId,
106
+ agentId: ORCHESTRATOR_AGENT_ID,
107
+ channelId: ctx.channelId,
108
+ threadId: ctx.threadId,
109
+ event: userFacing,
110
+ });
111
+ await ctx.onEvent(userFacing, initialState);
112
+ return {
113
+ ...event,
114
+ type: 'agent:invoke',
115
+ data: { ...(event.data || {}), content: rawContent, role: 'user' },
116
+ meta: {
117
+ ...(event.meta || {}),
118
+ threadId: ctx.threadId || event.id,
119
+ },
120
+ };
121
+ }
122
+ async function handleStop(stopEvent, options) {
123
+ const { runId, channelId, threadId, onEvent } = options;
124
+ stopRequests.push({
125
+ runId: stopEvent.data.runId,
126
+ agentId: stopEvent.data.agentId,
127
+ channelId: stopEvent.data.channelId || channelId,
128
+ threadId: stopEvent.data.threadId || threadId,
129
+ reason: stopEvent.data.reason,
130
+ requestedAt: Date.now(),
131
+ });
132
+ const state = await storageService.getOpenBotState({
133
+ runId,
134
+ agentId: options.agentId || ORCHESTRATOR_AGENT_ID,
135
+ channelId,
136
+ threadId,
137
+ event: stopEvent,
138
+ });
139
+ await onEvent({
140
+ type: 'action:agent_run_stop:result',
141
+ data: { success: true, message: `Stop requested for run ${stopEvent.data.runId}.` },
142
+ meta: stopEvent.meta,
143
+ }, state);
144
+ }