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.
- package/README.md +15 -16
- package/dist/app/agent-ids.js +4 -0
- package/dist/app/cli.js +1 -1
- package/dist/app/config.js +0 -19
- package/dist/app/server.js +8 -14
- package/dist/bus/services.js +34 -124
- package/dist/harness/agent-invoke-run.js +44 -0
- package/dist/harness/agent-turn.js +99 -0
- package/dist/harness/channel-participants.js +40 -0
- package/dist/harness/constants.js +2 -0
- package/dist/harness/context-meter.js +97 -0
- package/dist/harness/context.js +95 -47
- package/dist/harness/dispatch.js +144 -0
- package/dist/harness/dispatcher.js +45 -156
- package/dist/harness/history.js +177 -0
- package/dist/harness/index.js +91 -0
- package/dist/harness/orchestration.js +88 -0
- package/dist/harness/participants.js +22 -0
- package/dist/harness/run-harness.js +154 -0
- package/dist/harness/run.js +98 -0
- package/dist/harness/runtime-factory.js +0 -34
- package/dist/harness/runtime.js +57 -0
- package/dist/harness/todo-dispatch.js +51 -0
- package/dist/harness/todos.js +5 -0
- package/dist/harness/turn.js +79 -0
- package/dist/plugins/approval/index.js +105 -149
- package/dist/plugins/delegation/index.js +119 -32
- package/dist/plugins/memory/index.js +103 -14
- package/dist/plugins/memory/service.js +152 -0
- package/dist/plugins/openbot/context.js +80 -0
- package/dist/plugins/openbot/history.js +98 -0
- package/dist/plugins/openbot/index.js +31 -0
- package/dist/plugins/openbot/runtime.js +317 -0
- package/dist/plugins/openbot/system-prompt.js +5 -0
- package/dist/plugins/plugin-manager/index.js +105 -0
- package/dist/plugins/storage/index.js +573 -0
- package/dist/plugins/storage/service.js +1159 -0
- package/dist/plugins/storage-tools/index.js +2 -2
- package/dist/plugins/thread-namer/index.js +72 -0
- package/dist/plugins/thread-naming/generate-title.js +44 -0
- package/dist/plugins/thread-naming/index.js +103 -0
- package/dist/plugins/threads/index.js +114 -0
- package/dist/plugins/todo/index.js +24 -25
- package/dist/plugins/ui/index.js +2 -32
- package/dist/registry/plugins.js +3 -9
- package/dist/services/plugins/domain.js +1 -0
- package/dist/services/plugins/plugin-cache.js +9 -0
- package/dist/services/plugins/registry.js +110 -0
- package/dist/services/plugins/service.js +177 -0
- package/dist/services/plugins/types.js +1 -0
- package/dist/services/process.js +29 -0
- package/dist/services/storage.js +11 -10
- package/dist/services/thread-naming.js +81 -0
- package/docs/agents.md +16 -10
- package/docs/architecture.md +2 -2
- package/docs/plugins.md +6 -15
- package/docs/templates/AGENT.example.md +7 -13
- package/package.json +1 -2
- package/src/app/agent-ids.ts +5 -0
- package/src/app/cli.ts +1 -1
- package/src/app/config.ts +1 -31
- package/src/app/server.ts +8 -16
- package/src/app/types.ts +63 -189
- package/src/harness/index.ts +145 -0
- package/src/plugins/approval/index.ts +91 -189
- package/src/plugins/delegation/index.ts +136 -39
- package/src/plugins/memory/index.ts +112 -15
- package/src/{services/memory.ts → plugins/memory/service.ts} +1 -1
- package/src/plugins/openbot/context.ts +91 -0
- package/src/plugins/openbot/history.ts +107 -0
- package/src/plugins/openbot/index.ts +37 -0
- package/src/plugins/openbot/runtime.ts +384 -0
- package/src/plugins/openbot/system-prompt.ts +7 -0
- package/src/plugins/plugin-manager/index.ts +122 -0
- package/src/plugins/shell/index.ts +1 -1
- package/src/plugins/storage/index.ts +633 -0
- package/src/{services/storage.ts → plugins/storage/service.ts} +224 -67
- package/src/{bus/types.ts → services/plugins/domain.ts} +16 -7
- package/src/services/plugins/plugin-cache.ts +13 -0
- package/src/{registry/plugins.ts → services/plugins/registry.ts} +25 -27
- package/src/services/{plugins.ts → plugins/service.ts} +96 -2
- package/src/{bus/plugin.ts → services/plugins/types.ts} +3 -3
- package/src/bus/services.ts +0 -954
- package/src/harness/context.ts +0 -365
- package/src/harness/dispatcher.ts +0 -379
- package/src/harness/mcp.ts +0 -78
- package/src/harness/runtime-factory.ts +0 -129
- package/src/harness/todo-advance.ts +0 -128
- package/src/plugins/ai-sdk/index.ts +0 -41
- package/src/plugins/ai-sdk/runtime.ts +0 -468
- package/src/plugins/ai-sdk/system-prompt.ts +0 -18
- package/src/plugins/mcp/index.ts +0 -128
- package/src/plugins/storage-tools/index.ts +0 -90
- package/src/plugins/todo/index.ts +0 -64
- package/src/plugins/ui/index.ts +0 -227
- /package/src/{harness → services}/process.ts +0 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sliding window: max number of messages we replay to the model on each
|
|
3
|
+
* invocation. Older turns stay on disk but are not sent. Keeps both the
|
|
4
|
+
* recent prompts and the prompt token budget bounded.
|
|
5
|
+
*/
|
|
6
|
+
const MAX_WINDOW_MESSAGES = 80;
|
|
7
|
+
/**
|
|
8
|
+
* Reconstructs a valid `OpenBotMessage[]` chain from a raw event log.
|
|
9
|
+
* Handles grouping tool calls into assistant messages and matching results.
|
|
10
|
+
*
|
|
11
|
+
* This replaces the old `shortTermMessages` concept by treating the event log
|
|
12
|
+
* as the single source of truth for conversation history.
|
|
13
|
+
*/
|
|
14
|
+
export function reconstructHistory(events) {
|
|
15
|
+
const messages = [];
|
|
16
|
+
for (const event of events) {
|
|
17
|
+
switch (event.type) {
|
|
18
|
+
case 'user:input':
|
|
19
|
+
messages.push({ role: 'user', content: event.data.content });
|
|
20
|
+
break;
|
|
21
|
+
case 'agent:output': {
|
|
22
|
+
const last = messages[messages.length - 1];
|
|
23
|
+
if (last && last.role === 'assistant') {
|
|
24
|
+
if (typeof last.content === 'string') {
|
|
25
|
+
last.content += '\n' + event.data.content;
|
|
26
|
+
}
|
|
27
|
+
else if (Array.isArray(last.content)) {
|
|
28
|
+
const textPart = last.content.find((p) => p.type === 'text');
|
|
29
|
+
if (textPart && textPart.type === 'text') {
|
|
30
|
+
textPart.text += '\n' + event.data.content;
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
last.content.unshift({ type: 'text', text: event.data.content });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
messages.push({ role: 'assistant', content: event.data.content });
|
|
39
|
+
}
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
case 'agent:invoke': {
|
|
43
|
+
const invokeEvent = event;
|
|
44
|
+
// Only treat as a message if it has content and is explicitly from a role
|
|
45
|
+
if (invokeEvent.data?.content && invokeEvent.data?.role) {
|
|
46
|
+
const role = invokeEvent.data.role;
|
|
47
|
+
messages.push({ role, content: invokeEvent.data.content });
|
|
48
|
+
}
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
default:
|
|
52
|
+
// Handle tool calls (action:*)
|
|
53
|
+
if (event.type.startsWith('action:') && !event.type.endsWith(':result')) {
|
|
54
|
+
const toolName = event.type.slice(7);
|
|
55
|
+
const toolCallId = event.meta?.toolCallId;
|
|
56
|
+
if (!toolCallId)
|
|
57
|
+
break;
|
|
58
|
+
const toolCall = {
|
|
59
|
+
type: 'tool-call',
|
|
60
|
+
toolCallId,
|
|
61
|
+
toolName,
|
|
62
|
+
input: event.data,
|
|
63
|
+
};
|
|
64
|
+
const last = messages[messages.length - 1];
|
|
65
|
+
if (last && last.role === 'assistant') {
|
|
66
|
+
if (typeof last.content === 'string') {
|
|
67
|
+
last.content = [
|
|
68
|
+
{ type: 'text', text: last.content },
|
|
69
|
+
toolCall,
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
last.content.push(toolCall);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
messages.push({
|
|
78
|
+
role: 'assistant',
|
|
79
|
+
content: [toolCall],
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Handle tool results (action:*:result)
|
|
84
|
+
else if (event.type.startsWith('action:') && event.type.endsWith(':result')) {
|
|
85
|
+
const toolName = event.type.slice(7, -7);
|
|
86
|
+
const toolCallId = event.meta?.toolCallId;
|
|
87
|
+
if (!toolCallId)
|
|
88
|
+
break;
|
|
89
|
+
const last = messages[messages.length - 1];
|
|
90
|
+
if (last && last.role === 'tool' && Array.isArray(last.content)) {
|
|
91
|
+
last.content.push({
|
|
92
|
+
type: 'tool-result',
|
|
93
|
+
toolCallId,
|
|
94
|
+
toolName,
|
|
95
|
+
output: event.data,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
messages.push({
|
|
100
|
+
role: 'tool',
|
|
101
|
+
content: [
|
|
102
|
+
{
|
|
103
|
+
type: 'tool-result',
|
|
104
|
+
toolCallId,
|
|
105
|
+
toolName,
|
|
106
|
+
output: event.data,
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return repairAndWindow(messages);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Self-healing pass: every assistant tool_call must have a matching tool
|
|
119
|
+
* result before the next user/assistant turn. Also applies the sliding window.
|
|
120
|
+
*/
|
|
121
|
+
function repairAndWindow(messages) {
|
|
122
|
+
const fulfilled = new Set();
|
|
123
|
+
for (const m of messages) {
|
|
124
|
+
if (m.role === 'tool' && Array.isArray(m.content)) {
|
|
125
|
+
for (const part of m.content) {
|
|
126
|
+
if (part.type === 'tool-result') {
|
|
127
|
+
fulfilled.add(part.toolCallId);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const repaired = [];
|
|
133
|
+
for (const m of messages) {
|
|
134
|
+
repaired.push(m);
|
|
135
|
+
if (m.role !== 'assistant' || typeof m.content === 'string')
|
|
136
|
+
continue;
|
|
137
|
+
const missingResults = [];
|
|
138
|
+
for (const part of m.content) {
|
|
139
|
+
if (part.type === 'tool-call' && !fulfilled.has(part.toolCallId)) {
|
|
140
|
+
missingResults.push({
|
|
141
|
+
type: 'tool-result',
|
|
142
|
+
toolCallId: part.toolCallId,
|
|
143
|
+
toolName: part.toolName,
|
|
144
|
+
output: {
|
|
145
|
+
success: false,
|
|
146
|
+
error: 'Tool result was lost (handler did not emit a matching :result event).',
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
fulfilled.add(part.toolCallId);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (missingResults.length > 0) {
|
|
153
|
+
repaired.push({
|
|
154
|
+
role: 'tool',
|
|
155
|
+
content: missingResults,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (repaired.length <= MAX_WINDOW_MESSAGES)
|
|
160
|
+
return repaired;
|
|
161
|
+
const tail = repaired.slice(-MAX_WINDOW_MESSAGES);
|
|
162
|
+
// Ensure the tail doesn't start with an orphan tool result
|
|
163
|
+
const knownAssistantCallIds = new Set();
|
|
164
|
+
for (const m of tail) {
|
|
165
|
+
if (m.role === 'assistant' && Array.isArray(m.content)) {
|
|
166
|
+
for (const part of m.content) {
|
|
167
|
+
if (part.type === 'tool-call')
|
|
168
|
+
knownAssistantCallIds.add(part.toolCallId);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return tail.filter((m) => {
|
|
173
|
+
if (m.role !== 'tool' || typeof m.content === 'string')
|
|
174
|
+
return true;
|
|
175
|
+
return m.content.some((part) => part.type === 'tool-result' && knownAssistantCallIds.has(part.toolCallId));
|
|
176
|
+
});
|
|
177
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { melony } from 'melony';
|
|
2
|
+
import { ensureEventId } from '../app/utils.js';
|
|
3
|
+
import { storageService } from '../plugins/storage/service.js';
|
|
4
|
+
import { STATE_AGENT_ID, ORCHESTRATOR_AGENT_ID } from '../app/agent-ids.js';
|
|
5
|
+
import { resolvePlugin } from '../services/plugins/registry.js';
|
|
6
|
+
export { STATE_AGENT_ID, ORCHESTRATOR_AGENT_ID };
|
|
7
|
+
async function emitEvent(chunk, state, { persistEvents, channelId, threadId, onEvent, parentAgentId, parentToolCallId, }) {
|
|
8
|
+
ensureEventId(chunk);
|
|
9
|
+
// Enrich event with parent metadata if not already present
|
|
10
|
+
if (parentAgentId || parentToolCallId) {
|
|
11
|
+
chunk.meta = {
|
|
12
|
+
...chunk.meta,
|
|
13
|
+
parentAgentId: chunk.meta?.parentAgentId || parentAgentId,
|
|
14
|
+
parentToolCallId: chunk.meta?.parentToolCallId || parentToolCallId,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
if (persistEvents) {
|
|
18
|
+
await storageService.storeEvent({
|
|
19
|
+
channelId: state?.channelId || channelId,
|
|
20
|
+
threadId: state?.threadId || threadId,
|
|
21
|
+
event: chunk,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
await onEvent(chunk, state);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Runs a single agent turn.
|
|
28
|
+
* Fire and forget.
|
|
29
|
+
*/
|
|
30
|
+
export async function runAgent(options) {
|
|
31
|
+
const { runId, agentId, event, channelId, threadId, onEvent } = options;
|
|
32
|
+
const persistEvents = options.persistEvents !== false;
|
|
33
|
+
const parentAgentId = event.meta?.parentAgentId;
|
|
34
|
+
const parentToolCallId = event.meta?.parentToolCallId;
|
|
35
|
+
const agentDetails = await storageService.getAgentDetails({ agentId });
|
|
36
|
+
const state = await storageService.getOpenBotState({
|
|
37
|
+
runId,
|
|
38
|
+
agentId,
|
|
39
|
+
channelId,
|
|
40
|
+
threadId,
|
|
41
|
+
event,
|
|
42
|
+
});
|
|
43
|
+
await emitEvent({
|
|
44
|
+
type: 'agent:run:start',
|
|
45
|
+
data: { runId, agentId, channelId, threadId },
|
|
46
|
+
}, state, { persistEvents, channelId, threadId, onEvent, parentAgentId, parentToolCallId });
|
|
47
|
+
try {
|
|
48
|
+
const pluginRefs = agentDetails.pluginRefs ?? [];
|
|
49
|
+
const tools = {};
|
|
50
|
+
for (const ref of pluginRefs) {
|
|
51
|
+
const plugin = await resolvePlugin(ref.id);
|
|
52
|
+
if (plugin?.toolDefinitions) {
|
|
53
|
+
Object.assign(tools, plugin.toolDefinitions);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const builder = melony().initialState(state);
|
|
57
|
+
for (const ref of pluginRefs) {
|
|
58
|
+
const plugin = await resolvePlugin(ref.id);
|
|
59
|
+
if (!plugin)
|
|
60
|
+
continue;
|
|
61
|
+
builder.use(plugin.factory({
|
|
62
|
+
agentId,
|
|
63
|
+
agentDetails,
|
|
64
|
+
config: ref.config ?? {},
|
|
65
|
+
storage: storageService,
|
|
66
|
+
tools,
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
const runtime = builder.build();
|
|
70
|
+
const generator = runtime.run(event, { runId, state });
|
|
71
|
+
for await (const outputEvent of generator) {
|
|
72
|
+
await emitEvent(outputEvent, state, {
|
|
73
|
+
persistEvents,
|
|
74
|
+
channelId,
|
|
75
|
+
threadId,
|
|
76
|
+
onEvent,
|
|
77
|
+
parentAgentId,
|
|
78
|
+
parentToolCallId,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
console.error(`[harness] Error running agent ${agentId}:`, error);
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await emitEvent({
|
|
87
|
+
type: 'agent:run:end',
|
|
88
|
+
data: { runId, agentId, channelId, threadId },
|
|
89
|
+
}, state, { persistEvents, channelId, threadId, onEvent, parentAgentId, parentToolCallId });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { ORCHESTRATOR_AGENT_ID } from './context.js';
|
|
2
|
+
const readThreadState = (state) => state.threadDetails?.state ?? {};
|
|
3
|
+
export const readTodosFromState = (state) => {
|
|
4
|
+
const raw = readThreadState(state).todos;
|
|
5
|
+
return Array.isArray(raw) ? raw : [];
|
|
6
|
+
};
|
|
7
|
+
export const readOrchestration = (state) => {
|
|
8
|
+
const raw = readThreadState(state).orchestration;
|
|
9
|
+
if (!raw || typeof raw !== 'object')
|
|
10
|
+
return { active: false };
|
|
11
|
+
return raw;
|
|
12
|
+
};
|
|
13
|
+
export const isOrchestrationActive = (orch) => orch.active === true;
|
|
14
|
+
export const hasActiveTodos = (state) => readTodosFromState(state).some((t) => t.status === 'pending' || t.status === 'in_progress');
|
|
15
|
+
export const readPendingDelegation = (state) => readOrchestration(state).pendingDelegation;
|
|
16
|
+
async function patchOrchestration(storage, state, patch) {
|
|
17
|
+
if (!state.threadId)
|
|
18
|
+
throw new Error('No active thread');
|
|
19
|
+
const current = readOrchestration(state);
|
|
20
|
+
const next = { ...current, ...patch };
|
|
21
|
+
await storage.patchThreadState({
|
|
22
|
+
channelId: state.channelId,
|
|
23
|
+
threadId: state.threadId,
|
|
24
|
+
state: { orchestration: next },
|
|
25
|
+
});
|
|
26
|
+
state.threadDetails = await storage.getThreadDetails({
|
|
27
|
+
channelId: state.channelId,
|
|
28
|
+
threadId: state.threadId,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
export async function activateOrchestration(storage, state) {
|
|
32
|
+
const current = readOrchestration(state);
|
|
33
|
+
if (current.active)
|
|
34
|
+
return;
|
|
35
|
+
await patchOrchestration(storage, state, { active: true, startedAt: Date.now() });
|
|
36
|
+
}
|
|
37
|
+
export async function deactivateOrchestration(storage, state) {
|
|
38
|
+
const current = readOrchestration(state);
|
|
39
|
+
if (!current.active)
|
|
40
|
+
return;
|
|
41
|
+
await patchOrchestration(storage, state, {
|
|
42
|
+
active: false,
|
|
43
|
+
pendingDelegation: undefined,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
export async function queueDelegation(storage, state, delegation) {
|
|
47
|
+
await patchOrchestration(storage, state, {
|
|
48
|
+
active: true,
|
|
49
|
+
startedAt: readOrchestration(state).startedAt ?? Date.now(),
|
|
50
|
+
pendingDelegation: delegation,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
export async function clearPendingDelegation(storage, state) {
|
|
54
|
+
const current = readOrchestration(state);
|
|
55
|
+
if (!current.pendingDelegation)
|
|
56
|
+
return;
|
|
57
|
+
await patchOrchestration(storage, state, { pendingDelegation: undefined });
|
|
58
|
+
}
|
|
59
|
+
export function buildWorkerReviewPrompt(workerAgentId, output) {
|
|
60
|
+
const body = output && output.trim()
|
|
61
|
+
? output.trim()
|
|
62
|
+
: '(The worker produced no text output.)';
|
|
63
|
+
return [
|
|
64
|
+
'[Orchestrator — worker step completed]',
|
|
65
|
+
'',
|
|
66
|
+
`Worker: @${workerAgentId}`,
|
|
67
|
+
'Output:',
|
|
68
|
+
'---',
|
|
69
|
+
body,
|
|
70
|
+
'---',
|
|
71
|
+
'',
|
|
72
|
+
'Review the shared todo plan, update statuses with `todo_write` if needed,',
|
|
73
|
+
'tell the user if appropriate, then either call `delegate_to_agent` for the next step',
|
|
74
|
+
'or confirm the goal is complete.',
|
|
75
|
+
].join('\n');
|
|
76
|
+
}
|
|
77
|
+
export function buildPlanContinuePrompt() {
|
|
78
|
+
return [
|
|
79
|
+
'[Orchestrator — continue plan execution]',
|
|
80
|
+
'',
|
|
81
|
+
'You have an active todo plan with remaining work.',
|
|
82
|
+
'Review the plan and either call `delegate_to_agent` for the next step',
|
|
83
|
+
'or handle the work yourself. Update todos to reflect progress.',
|
|
84
|
+
].join('\n');
|
|
85
|
+
}
|
|
86
|
+
export function isOrchestratorAgent(agentId) {
|
|
87
|
+
return agentId === ORCHESTRATOR_AGENT_ID;
|
|
88
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
/** Solo DM: exactly one participant and it is the acting agent (no peer bots). */
|
|
6
|
+
export function isDmSoloChannel(participants, actingAgentId) {
|
|
7
|
+
return participants.length === 1 && participants[0] === actingAgentId;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* When `participants` is non-empty, dispatch targets must appear in that list.
|
|
11
|
+
* Solo DM forbids targeting any agent other than yourself.
|
|
12
|
+
*/
|
|
13
|
+
export function isParticipantDispatchAllowed(participants, actingAgentId, targetAgentId) {
|
|
14
|
+
if (participants.length === 0)
|
|
15
|
+
return true;
|
|
16
|
+
if (!participants.includes(targetAgentId))
|
|
17
|
+
return false;
|
|
18
|
+
if (isDmSoloChannel(participants, actingAgentId) && targetAgentId !== actingAgentId) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { ensureEventId } from '../app/utils.js';
|
|
2
|
+
import { storageService } from '../services/storage.js';
|
|
3
|
+
import { createAgentRuntime } from './runtime-factory.js';
|
|
4
|
+
const stopRequests = [];
|
|
5
|
+
const STOP_TTL_MS = 30 * 60 * 1000;
|
|
6
|
+
const pruneStops = () => {
|
|
7
|
+
const cutoff = Date.now() - STOP_TTL_MS;
|
|
8
|
+
for (let i = stopRequests.length - 1; i >= 0; i -= 1) {
|
|
9
|
+
if (stopRequests[i].requestedAt < cutoff)
|
|
10
|
+
stopRequests.splice(i, 1);
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
const findStop = (target) => {
|
|
14
|
+
pruneStops();
|
|
15
|
+
return stopRequests.find((r) => r.runId === target.runId &&
|
|
16
|
+
(!r.agentId || r.agentId === target.agentId) &&
|
|
17
|
+
(!r.channelId || r.channelId === target.channelId) &&
|
|
18
|
+
(!r.threadId || r.threadId === target.threadId));
|
|
19
|
+
};
|
|
20
|
+
const target = (ctx, agentId) => ({ ...ctx, agentId });
|
|
21
|
+
const loadState = (t, event) => storageService.getOpenBotState({ ...t, event });
|
|
22
|
+
export async function runHarness(options) {
|
|
23
|
+
const { event } = options;
|
|
24
|
+
ensureEventId(event);
|
|
25
|
+
if (event.type === 'action:agent_run_stop') {
|
|
26
|
+
await handleStop(event, options);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const ctx = {
|
|
30
|
+
runId: options.runId,
|
|
31
|
+
channelId: options.channelId,
|
|
32
|
+
threadId: options.threadId,
|
|
33
|
+
onEvent: options.onEvent,
|
|
34
|
+
};
|
|
35
|
+
const agentId = options.agentId || 'system';
|
|
36
|
+
if (event.type === 'user:input' || event.type === 'agent:invoke') {
|
|
37
|
+
await runStep(ctx, agentId, await prepareInvoke(event, ctx));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
await runPassThrough(ctx, agentId, event);
|
|
41
|
+
}
|
|
42
|
+
async function runStep(ctx, agentId, event) {
|
|
43
|
+
const t = target(ctx, agentId);
|
|
44
|
+
const early = findStop(t);
|
|
45
|
+
if (early) {
|
|
46
|
+
const state = await loadState(t, event);
|
|
47
|
+
await ctx.onEvent({ type: 'agent:run:stopped', data: { ...t, reason: early.reason } }, state);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
let state;
|
|
51
|
+
try {
|
|
52
|
+
state = await loadState(t, event);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error.code !== 'AGENT_NOT_FOUND')
|
|
56
|
+
throw error;
|
|
57
|
+
const fallback = await loadState(target(ctx, 'system'), event);
|
|
58
|
+
await ctx.onEvent({
|
|
59
|
+
type: 'agent:output',
|
|
60
|
+
data: {
|
|
61
|
+
content: `⚠️ Agent **${agentId}** does not exist. Please check the agent ID and try again.`,
|
|
62
|
+
},
|
|
63
|
+
meta: { agentId: 'system', threadId: ctx.threadId },
|
|
64
|
+
}, fallback);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
await ctx.onEvent({ type: 'agent:run:start', data: { ...t } }, state);
|
|
68
|
+
try {
|
|
69
|
+
await streamEvents(ctx, t, event, state, true);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
console.error(`[run-harness] Agent run failed: ${agentId}`, error);
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
const after = await loadState(t, event);
|
|
76
|
+
await ctx.onEvent({ type: 'agent:run:end', data: { ...t } }, after);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async function runPassThrough(ctx, agentId, event) {
|
|
80
|
+
const t = target(ctx, agentId);
|
|
81
|
+
let state;
|
|
82
|
+
try {
|
|
83
|
+
state = await loadState(t, event);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error.code === 'AGENT_NOT_FOUND')
|
|
87
|
+
return;
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
await streamEvents(ctx, t, event, state, false);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
console.error(`[run-harness] Event failed: ${event.type} (${agentId})`, error);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function streamEvents(ctx, t, event, state, step) {
|
|
98
|
+
const runtime = await createAgentRuntime(state);
|
|
99
|
+
for await (const chunk of runtime.run(event, { state, runId: ctx.runId })) {
|
|
100
|
+
if (step) {
|
|
101
|
+
const stop = findStop(t);
|
|
102
|
+
if (stop) {
|
|
103
|
+
await ctx.onEvent({ type: 'agent:run:stopped', data: { ...t, reason: stop.reason } }, state);
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (chunk.id === event.id && chunk.type === event.type)
|
|
108
|
+
continue;
|
|
109
|
+
if (step && chunk.type === 'action:create_thread:result' && chunk.data.success) {
|
|
110
|
+
ctx.threadId = chunk.data.threadId || ctx.threadId;
|
|
111
|
+
}
|
|
112
|
+
chunk.meta = { ...chunk.meta, agentId: t.agentId };
|
|
113
|
+
await ctx.onEvent(chunk, state);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async function prepareInvoke(event, ctx) {
|
|
117
|
+
const rawContent = event.data?.content || '';
|
|
118
|
+
const userFacing = {
|
|
119
|
+
type: 'agent:invoke',
|
|
120
|
+
id: event.id,
|
|
121
|
+
data: { content: rawContent, role: 'user' },
|
|
122
|
+
meta: {
|
|
123
|
+
agentId: 'system',
|
|
124
|
+
userId: event.meta?.userId,
|
|
125
|
+
userName: event.meta?.userName,
|
|
126
|
+
userAvatarUrl: event.meta?.userAvatarUrl,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
const initialState = await loadState(target(ctx, 'system'), userFacing);
|
|
130
|
+
await ctx.onEvent(userFacing, initialState);
|
|
131
|
+
return {
|
|
132
|
+
...event,
|
|
133
|
+
type: 'agent:invoke',
|
|
134
|
+
data: { ...(event.data || {}), content: rawContent, role: 'user' },
|
|
135
|
+
meta: { ...(event.meta || {}), threadId: ctx.threadId || event.id },
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
async function handleStop(stopEvent, options) {
|
|
139
|
+
const { runId, channelId, threadId, onEvent } = options;
|
|
140
|
+
stopRequests.push({
|
|
141
|
+
runId: stopEvent.data.runId,
|
|
142
|
+
agentId: stopEvent.data.agentId,
|
|
143
|
+
channelId: stopEvent.data.channelId || channelId,
|
|
144
|
+
threadId: stopEvent.data.threadId || threadId,
|
|
145
|
+
reason: stopEvent.data.reason,
|
|
146
|
+
requestedAt: Date.now(),
|
|
147
|
+
});
|
|
148
|
+
const state = await loadState({ runId, channelId, threadId, onEvent, agentId: options.agentId || 'system' }, stopEvent);
|
|
149
|
+
await onEvent({
|
|
150
|
+
type: 'action:agent_run_stop:result',
|
|
151
|
+
data: { success: true, message: `Stop requested for run ${stopEvent.data.runId}.` },
|
|
152
|
+
meta: stopEvent.meta,
|
|
153
|
+
}, state);
|
|
154
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { ensureEventId } from '../app/utils.js';
|
|
2
|
+
import { storageService } from '../services/storage.js';
|
|
3
|
+
import { createAgentRuntime } from './runtime.js';
|
|
4
|
+
const TODO_RESULT_MAX_CHARS = 12000;
|
|
5
|
+
const readTodos = (state) => {
|
|
6
|
+
const raw = state.threadDetails?.state?.todos;
|
|
7
|
+
return Array.isArray(raw) ? raw : [];
|
|
8
|
+
};
|
|
9
|
+
function truncateTodoResult(text, maxChars = TODO_RESULT_MAX_CHARS) {
|
|
10
|
+
const trimmed = text.trim();
|
|
11
|
+
if (!trimmed)
|
|
12
|
+
return undefined;
|
|
13
|
+
if (trimmed.length <= maxChars)
|
|
14
|
+
return trimmed;
|
|
15
|
+
return `${trimmed.slice(0, maxChars)}\n…[truncated]`;
|
|
16
|
+
}
|
|
17
|
+
function resolveTodoIdForWorker(todos, workerId, delegationTodoId) {
|
|
18
|
+
if (delegationTodoId && todos.some((t) => t.id === delegationTodoId)) {
|
|
19
|
+
return delegationTodoId;
|
|
20
|
+
}
|
|
21
|
+
const inProgress = todos.find((t) => t.status === 'in_progress' && t.assignee === workerId);
|
|
22
|
+
if (inProgress)
|
|
23
|
+
return inProgress.id;
|
|
24
|
+
const assigned = todos.find((t) => (t.status === 'pending' || t.status === 'in_progress') && t.assignee === workerId);
|
|
25
|
+
return assigned?.id;
|
|
26
|
+
}
|
|
27
|
+
async function recordWorkerTodoResult(state, workerId, output, delegationTodoId) {
|
|
28
|
+
if (!state.threadId)
|
|
29
|
+
return;
|
|
30
|
+
const result = truncateTodoResult(output ?? '');
|
|
31
|
+
if (!result)
|
|
32
|
+
return;
|
|
33
|
+
const todos = readTodos(state);
|
|
34
|
+
if (todos.length === 0)
|
|
35
|
+
return;
|
|
36
|
+
const todoId = resolveTodoIdForWorker(todos, workerId, delegationTodoId);
|
|
37
|
+
if (!todoId)
|
|
38
|
+
return;
|
|
39
|
+
const prior = todos.find((t) => t.id === todoId);
|
|
40
|
+
if (prior?.result === result)
|
|
41
|
+
return;
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
const next = todos.map((t) => (t.id === todoId ? { ...t, result, updatedAt: now } : t));
|
|
44
|
+
await storageService.patchThreadState({
|
|
45
|
+
channelId: state.channelId,
|
|
46
|
+
threadId: state.threadId,
|
|
47
|
+
state: { todos: next },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
export function makeInternalInvoke(content, threadId) {
|
|
51
|
+
return ensureEventId({
|
|
52
|
+
type: 'agent:invoke',
|
|
53
|
+
data: { role: 'user', content },
|
|
54
|
+
meta: { threadId, internal: true },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Run one agent turn. Yields runtime events for persistence/streaming;
|
|
59
|
+
* returns the last non-empty `agent:output` text when used as a worker.
|
|
60
|
+
*/
|
|
61
|
+
export async function* runTurn(options) {
|
|
62
|
+
const { runId, channelId, threadId, agentId, event, delegationTodoId } = options;
|
|
63
|
+
const target = { runId, agentId, channelId, threadId };
|
|
64
|
+
let state;
|
|
65
|
+
try {
|
|
66
|
+
state = await storageService.getOpenBotState({ ...target, event });
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (error.code === 'AGENT_NOT_FOUND') {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
yield { type: 'agent:run:start', data: { ...target } };
|
|
75
|
+
let lastAgentOutput;
|
|
76
|
+
try {
|
|
77
|
+
const runtime = await createAgentRuntime(state);
|
|
78
|
+
for await (const chunk of runtime.run(event, { state, runId })) {
|
|
79
|
+
if (chunk.id === event.id && chunk.type === event.type)
|
|
80
|
+
continue;
|
|
81
|
+
if (chunk.type === 'agent:output' &&
|
|
82
|
+
chunk.meta?.agentId === agentId) {
|
|
83
|
+
const content = chunk.data?.content;
|
|
84
|
+
if (typeof content === 'string' && content.trim()) {
|
|
85
|
+
lastAgentOutput = content.trim();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
chunk.meta = { ...chunk.meta, agentId };
|
|
89
|
+
yield chunk;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
const stateAfterRun = await storageService.getOpenBotState({ ...target, event });
|
|
94
|
+
yield { type: 'agent:run:end', data: { ...target } };
|
|
95
|
+
await recordWorkerTodoResult(stateAfterRun, agentId, lastAgentOutput, delegationTodoId);
|
|
96
|
+
}
|
|
97
|
+
return lastAgentOutput;
|
|
98
|
+
}
|
|
@@ -2,39 +2,6 @@ import { melony } from 'melony';
|
|
|
2
2
|
import { resolvePlugin } from '../registry/plugins.js';
|
|
3
3
|
import { storageService } from '../services/storage.js';
|
|
4
4
|
import { busServicesPlugin } from '../bus/services.js';
|
|
5
|
-
/**
|
|
6
|
-
* Enhances the agent's instructions with a list of other available agents the
|
|
7
|
-
* orchestrator can hand off to. Agents that include the `delegation` plugin
|
|
8
|
-
* will surface peers; agents without it can ignore this.
|
|
9
|
-
*/
|
|
10
|
-
export async function enhanceInstructions(state) {
|
|
11
|
-
const { agentId, agentDetails } = state;
|
|
12
|
-
if (!agentDetails)
|
|
13
|
-
return;
|
|
14
|
-
try {
|
|
15
|
-
const agents = await storageService.getAgents();
|
|
16
|
-
const otherAgents = agents.filter((a) => a.id !== agentId);
|
|
17
|
-
if (otherAgents.length === 0)
|
|
18
|
-
return;
|
|
19
|
-
const agentsList = otherAgents
|
|
20
|
-
.map((a) => `- **${a.id}**${a.description ? `: ${a.description}` : ''}`)
|
|
21
|
-
.join('\n');
|
|
22
|
-
const header = '### Available Agents:';
|
|
23
|
-
if (!agentDetails.instructions.includes(header)) {
|
|
24
|
-
const hasHandoff = (agentDetails.pluginRefs || []).some((r) => r.id === 'delegation');
|
|
25
|
-
const hasTodo = (agentDetails.pluginRefs || []).some((r) => r.id === 'todo');
|
|
26
|
-
const usage = hasTodo
|
|
27
|
-
? 'Use these ids as `assignee` when calling `todo_write` to plan multi-agent work.'
|
|
28
|
-
: hasHandoff
|
|
29
|
-
? 'Use `handoff` to transfer control to another agent in this thread.'
|
|
30
|
-
: '';
|
|
31
|
-
agentDetails.instructions += `\n\n${header}\n${agentsList}${usage ? `\n\n${usage}` : ''}`;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
catch (error) {
|
|
35
|
-
console.warn('[agent] Failed to enhance instructions', error);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
5
|
const composeMelonyPlugin = (...plugins) => {
|
|
39
6
|
return (builder) => {
|
|
40
7
|
for (const plugin of plugins) {
|
|
@@ -57,7 +24,6 @@ const composeMelonyPlugin = (...plugins) => {
|
|
|
57
24
|
* Tool name collisions across plugins log a warning; the first plugin wins.
|
|
58
25
|
*/
|
|
59
26
|
export async function createAgentRuntime(state) {
|
|
60
|
-
await enhanceInstructions(state);
|
|
61
27
|
const runtime = melony({
|
|
62
28
|
initialState: state,
|
|
63
29
|
});
|