openbot 0.3.0 → 0.3.1
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/dist/app/cli.js +1 -1
- package/dist/bus/services.js +116 -5
- package/dist/harness/context.js +140 -21
- package/dist/plugins/ai-sdk/runtime.js +74 -2
- package/dist/plugins/memory/index.js +71 -0
- package/dist/registry/plugins.js +2 -0
- package/dist/services/memory.js +152 -0
- package/dist/services/storage.js +6 -0
- package/docs/agents.md +15 -1
- package/package.json +1 -1
- package/src/app/cli.ts +1 -1
- package/src/app/types.ts +61 -1
- package/src/bus/services.ts +126 -6
- package/src/bus/types.ts +13 -0
- package/src/harness/context.ts +162 -29
- package/src/plugins/ai-sdk/runtime.ts +76 -2
- package/src/plugins/memory/index.ts +85 -0
- package/src/registry/plugins.ts +2 -0
- package/src/services/memory.ts +213 -0
- package/src/services/storage.ts +7 -0
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.
|
|
19
|
+
program.name('openbot').description('OpenBot CLI').version('0.3.1');
|
|
20
20
|
program
|
|
21
21
|
.command('start')
|
|
22
22
|
.description('Start the OpenBot harness')
|
package/dist/bus/services.js
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
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
|
+
/**
|
|
5
|
+
* Resolve a scope alias to a concrete scope string. Aliases let tools accept
|
|
6
|
+
* `agent`/`channel`/`global` without knowing the active ids; the bus rewrites
|
|
7
|
+
* them using `context.state`.
|
|
8
|
+
*/
|
|
9
|
+
function resolveMemoryScope(alias, state) {
|
|
10
|
+
switch (alias) {
|
|
11
|
+
case 'agent':
|
|
12
|
+
return `agent:${state.agentId}`;
|
|
13
|
+
case 'channel':
|
|
14
|
+
return `channel:${state.channelId}`;
|
|
15
|
+
case 'global':
|
|
16
|
+
case undefined:
|
|
17
|
+
return 'global';
|
|
18
|
+
default:
|
|
19
|
+
return 'global';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function resolveMemoryScopeFilter(alias, state) {
|
|
23
|
+
if (alias === 'all' || alias === undefined) {
|
|
24
|
+
return ['global', `agent:${state.agentId}`, `channel:${state.channelId}`];
|
|
25
|
+
}
|
|
26
|
+
return [resolveMemoryScope(alias, state)];
|
|
27
|
+
}
|
|
4
28
|
const DEFAULT_MARKETPLACE_AGENTS = [
|
|
5
29
|
{
|
|
6
30
|
id: 'researcher',
|
|
@@ -117,17 +141,19 @@ export const busServicesPlugin = (options) => (builder) => {
|
|
|
117
141
|
yield {
|
|
118
142
|
type: 'action:create_thread:result',
|
|
119
143
|
data: { success: true, threadId, threadTitle },
|
|
120
|
-
meta: { threadId },
|
|
144
|
+
meta: { ...(event.meta || {}), threadId, agentId: context.state.agentId },
|
|
121
145
|
};
|
|
122
146
|
});
|
|
123
147
|
builder.on('action:create_channel', async function* (event, context) {
|
|
124
148
|
const { channelId, spec, initialState, cwd } = event.data;
|
|
125
149
|
const rawChannelId = (channelId || '').trim();
|
|
126
150
|
const channelSpec = typeof spec === 'string' ? spec : '';
|
|
151
|
+
const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
|
|
127
152
|
if (!rawChannelId) {
|
|
128
153
|
yield {
|
|
129
154
|
type: 'action:create_channel:result',
|
|
130
155
|
data: { success: false, channelId: '', channelUrl: '' },
|
|
156
|
+
meta: resultMeta,
|
|
131
157
|
};
|
|
132
158
|
return;
|
|
133
159
|
}
|
|
@@ -142,30 +168,31 @@ export const busServicesPlugin = (options) => (builder) => {
|
|
|
142
168
|
yield {
|
|
143
169
|
type: 'action:create_channel:result',
|
|
144
170
|
data: { success: true, channelId: rawChannelId, channelUrl },
|
|
171
|
+
meta: resultMeta,
|
|
145
172
|
};
|
|
146
173
|
yield {
|
|
147
174
|
type: 'agent:output',
|
|
148
175
|
data: { content: `Created channel \`${rawChannelId}\`.` },
|
|
149
|
-
meta:
|
|
150
|
-
...(event.meta || {}),
|
|
151
|
-
agentId: context.state.agentId,
|
|
152
|
-
},
|
|
176
|
+
meta: resultMeta,
|
|
153
177
|
};
|
|
154
178
|
}
|
|
155
179
|
catch {
|
|
156
180
|
yield {
|
|
157
181
|
type: 'action:create_channel:result',
|
|
158
182
|
data: { success: false, channelId: rawChannelId, channelUrl },
|
|
183
|
+
meta: resultMeta,
|
|
159
184
|
};
|
|
160
185
|
}
|
|
161
186
|
});
|
|
162
187
|
builder.on('action:update_channel', async function* (event, context) {
|
|
163
188
|
const data = (event.data || {});
|
|
164
189
|
const targetChannelId = (data.channelId || context.state.channelId || '').trim();
|
|
190
|
+
const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
|
|
165
191
|
if (!targetChannelId) {
|
|
166
192
|
yield {
|
|
167
193
|
type: 'action:update_channel:result',
|
|
168
194
|
data: { success: false, channelId: '', updatedFields: [] },
|
|
195
|
+
meta: resultMeta,
|
|
169
196
|
};
|
|
170
197
|
return;
|
|
171
198
|
}
|
|
@@ -191,17 +218,20 @@ export const busServicesPlugin = (options) => (builder) => {
|
|
|
191
218
|
yield {
|
|
192
219
|
type: 'action:update_channel:result',
|
|
193
220
|
data: { success: true, channelId: targetChannelId, updatedFields },
|
|
221
|
+
meta: resultMeta,
|
|
194
222
|
};
|
|
195
223
|
}
|
|
196
224
|
catch {
|
|
197
225
|
yield {
|
|
198
226
|
type: 'action:update_channel:result',
|
|
199
227
|
data: { success: false, channelId: targetChannelId, updatedFields },
|
|
228
|
+
meta: resultMeta,
|
|
200
229
|
};
|
|
201
230
|
}
|
|
202
231
|
});
|
|
203
232
|
builder.on('action:patch_channel_details', async function* (event, context) {
|
|
204
233
|
const updatedFields = [];
|
|
234
|
+
const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
|
|
205
235
|
try {
|
|
206
236
|
if (event.data.state !== undefined) {
|
|
207
237
|
await storage.patchChannelState({
|
|
@@ -230,17 +260,20 @@ export const busServicesPlugin = (options) => (builder) => {
|
|
|
230
260
|
yield {
|
|
231
261
|
type: 'action:patch_channel_details:result',
|
|
232
262
|
data: { success: true, updatedFields },
|
|
263
|
+
meta: resultMeta,
|
|
233
264
|
};
|
|
234
265
|
}
|
|
235
266
|
catch {
|
|
236
267
|
yield {
|
|
237
268
|
type: 'action:patch_channel_details:result',
|
|
238
269
|
data: { success: false, updatedFields },
|
|
270
|
+
meta: resultMeta,
|
|
239
271
|
};
|
|
240
272
|
}
|
|
241
273
|
});
|
|
242
274
|
builder.on('action:patch_thread_details', async function* (event, context) {
|
|
243
275
|
const updatedFields = [];
|
|
276
|
+
const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
|
|
244
277
|
try {
|
|
245
278
|
if (!context.state.threadId) {
|
|
246
279
|
throw new Error('Missing threadId in state for patch_thread_details');
|
|
@@ -268,12 +301,14 @@ export const busServicesPlugin = (options) => (builder) => {
|
|
|
268
301
|
yield {
|
|
269
302
|
type: 'action:patch_thread_details:result',
|
|
270
303
|
data: { success: true, updatedFields },
|
|
304
|
+
meta: resultMeta,
|
|
271
305
|
};
|
|
272
306
|
}
|
|
273
307
|
catch {
|
|
274
308
|
yield {
|
|
275
309
|
type: 'action:patch_thread_details:result',
|
|
276
310
|
data: { success: false, updatedFields },
|
|
311
|
+
meta: resultMeta,
|
|
277
312
|
};
|
|
278
313
|
}
|
|
279
314
|
});
|
|
@@ -549,6 +584,82 @@ export const busServicesPlugin = (options) => (builder) => {
|
|
|
549
584
|
data: { success: true, agents },
|
|
550
585
|
};
|
|
551
586
|
});
|
|
587
|
+
builder.on('action:remember', async function* (event, context) {
|
|
588
|
+
const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
|
|
589
|
+
try {
|
|
590
|
+
const { content, scope, tags } = event.data;
|
|
591
|
+
const record = await storage.appendMemory({
|
|
592
|
+
scope: resolveMemoryScope(scope, context.state),
|
|
593
|
+
content,
|
|
594
|
+
tags,
|
|
595
|
+
});
|
|
596
|
+
yield {
|
|
597
|
+
type: 'action:remember:result',
|
|
598
|
+
data: { success: true, record },
|
|
599
|
+
meta: resultMeta,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
catch (error) {
|
|
603
|
+
yield {
|
|
604
|
+
type: 'action:remember:result',
|
|
605
|
+
data: {
|
|
606
|
+
success: false,
|
|
607
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
608
|
+
},
|
|
609
|
+
meta: resultMeta,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
builder.on('action:recall', async function* (event, context) {
|
|
614
|
+
const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
|
|
615
|
+
try {
|
|
616
|
+
const { query, tag, scope, limit } = event.data;
|
|
617
|
+
const records = await storage.listMemories({
|
|
618
|
+
scopes: resolveMemoryScopeFilter(scope, context.state),
|
|
619
|
+
query,
|
|
620
|
+
tag,
|
|
621
|
+
limit,
|
|
622
|
+
});
|
|
623
|
+
yield {
|
|
624
|
+
type: 'action:recall:result',
|
|
625
|
+
data: { success: true, records },
|
|
626
|
+
meta: resultMeta,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
catch (error) {
|
|
630
|
+
yield {
|
|
631
|
+
type: 'action:recall:result',
|
|
632
|
+
data: {
|
|
633
|
+
success: false,
|
|
634
|
+
records: [],
|
|
635
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
636
|
+
},
|
|
637
|
+
meta: resultMeta,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
builder.on('action:forget', async function* (event, context) {
|
|
642
|
+
const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
|
|
643
|
+
try {
|
|
644
|
+
const deleted = await storage.deleteMemory({ id: event.data.id });
|
|
645
|
+
yield {
|
|
646
|
+
type: 'action:forget:result',
|
|
647
|
+
data: { success: true, deleted },
|
|
648
|
+
meta: resultMeta,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
catch (error) {
|
|
652
|
+
yield {
|
|
653
|
+
type: 'action:forget:result',
|
|
654
|
+
data: {
|
|
655
|
+
success: false,
|
|
656
|
+
deleted: false,
|
|
657
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
658
|
+
},
|
|
659
|
+
meta: resultMeta,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
});
|
|
552
663
|
builder.on('action:agent:install', async function* (event) {
|
|
553
664
|
try {
|
|
554
665
|
const { agentId, name, description, instructions, plugins } = event.data;
|
package/dist/harness/context.js
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Cheap, dependency-free token estimator. Roughly char/4 — fine for budget
|
|
3
|
+
* enforcement; can be swapped for a tokenizer-backed implementation later
|
|
4
|
+
* without touching providers.
|
|
3
5
|
*/
|
|
6
|
+
export const estimateTokens = (text) => Math.ceil((text?.length ?? 0) / 4);
|
|
7
|
+
/**
|
|
8
|
+
* Hard cap (in characters) on a single context item. Keeps any one provider
|
|
9
|
+
* — typically the recent-events feed — from monopolising the prompt budget.
|
|
10
|
+
*/
|
|
11
|
+
const ITEM_HARD_CHAR_CAP = 6000;
|
|
12
|
+
const truncate = (text, maxChars) => text.length <= maxChars ? text : `${text.slice(0, maxChars)}\n…[truncated]`;
|
|
4
13
|
export class ContextEngine {
|
|
5
14
|
constructor() {
|
|
6
15
|
this.providers = [];
|
|
@@ -13,18 +22,18 @@ export class ContextEngine {
|
|
|
13
22
|
this.processors.push(processor);
|
|
14
23
|
}
|
|
15
24
|
async buildContext(state, storage) {
|
|
16
|
-
// 1. Collect context from all providers
|
|
17
25
|
let items = [];
|
|
18
26
|
for (const provider of this.providers) {
|
|
19
27
|
try {
|
|
20
28
|
const providedItems = await provider.provide(state, storage);
|
|
21
|
-
|
|
29
|
+
for (const item of providedItems) {
|
|
30
|
+
items.push({ ...item, content: truncate(item.content, ITEM_HARD_CHAR_CAP) });
|
|
31
|
+
}
|
|
22
32
|
}
|
|
23
33
|
catch (error) {
|
|
24
34
|
console.warn(`[ContextEngine] Provider ${provider.name} failed:`, error);
|
|
25
35
|
}
|
|
26
36
|
}
|
|
27
|
-
// 2. Run through processors
|
|
28
37
|
for (const processor of this.processors) {
|
|
29
38
|
try {
|
|
30
39
|
items = await processor.process(items, state);
|
|
@@ -33,23 +42,25 @@ export class ContextEngine {
|
|
|
33
42
|
console.warn(`[ContextEngine] Processor ${processor.name} failed:`, error);
|
|
34
43
|
}
|
|
35
44
|
}
|
|
36
|
-
// 3. Format items into a single string
|
|
37
45
|
return items
|
|
38
46
|
.sort((a, b) => b.priority - a.priority)
|
|
39
|
-
.map(item => item.content)
|
|
47
|
+
.map((item) => item.content)
|
|
40
48
|
.join('\n\n');
|
|
41
49
|
}
|
|
42
50
|
}
|
|
43
51
|
/**
|
|
44
|
-
* Default
|
|
52
|
+
* Default context engine. Order of providers is by emit order; final ordering
|
|
53
|
+
* in the prompt is determined by `priority`. The token-budget processor runs
|
|
54
|
+
* last so dropping happens after every provider has contributed.
|
|
45
55
|
*/
|
|
46
56
|
export function createDefaultContextEngine() {
|
|
47
57
|
const engine = new ContextEngine();
|
|
48
|
-
// Basic Providers
|
|
49
58
|
engine.registerProvider(new AgentDetailsProvider());
|
|
50
59
|
engine.registerProvider(new ChannelDetailsProvider());
|
|
51
60
|
engine.registerProvider(new ThreadDetailsProvider());
|
|
61
|
+
engine.registerProvider(new MemoryProvider());
|
|
52
62
|
engine.registerProvider(new RecentEventsProvider());
|
|
63
|
+
engine.registerProcessor(new TokenBudgetProcessor());
|
|
53
64
|
return engine;
|
|
54
65
|
}
|
|
55
66
|
class AgentDetailsProvider {
|
|
@@ -97,6 +108,81 @@ class ThreadDetailsProvider {
|
|
|
97
108
|
}];
|
|
98
109
|
}
|
|
99
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Fetches relevant memories (global + active agent + active channel) and
|
|
113
|
+
* surfaces them at high priority so the LLM treats them as ground truth
|
|
114
|
+
* rather than chat history.
|
|
115
|
+
*/
|
|
116
|
+
class MemoryProvider {
|
|
117
|
+
constructor() {
|
|
118
|
+
this.name = 'memory';
|
|
119
|
+
}
|
|
120
|
+
async provide(state, storage) {
|
|
121
|
+
if (!storage?.listMemories)
|
|
122
|
+
return [];
|
|
123
|
+
try {
|
|
124
|
+
const scopes = ['global', `agent:${state.agentId}`];
|
|
125
|
+
if (state.channelId)
|
|
126
|
+
scopes.push(`channel:${state.channelId}`);
|
|
127
|
+
const records = await storage.listMemories({ scopes, limit: 50 });
|
|
128
|
+
if (records.length === 0)
|
|
129
|
+
return [];
|
|
130
|
+
const formatted = records
|
|
131
|
+
.map((r) => {
|
|
132
|
+
const tags = r.tags?.length ? ` [${r.tags.join(', ')}]` : '';
|
|
133
|
+
const scopeLabel = r.scope === 'global' ? 'global' : r.scope;
|
|
134
|
+
return `- (${scopeLabel}${tags}) ${r.content}`;
|
|
135
|
+
})
|
|
136
|
+
.join('\n');
|
|
137
|
+
return [
|
|
138
|
+
{
|
|
139
|
+
id: 'memory',
|
|
140
|
+
type: 'memory',
|
|
141
|
+
priority: 95,
|
|
142
|
+
content: `## REMEMBERED FACTS\nThese are durable facts you previously stored with the \`remember\` tool. Trust them unless contradicted by the user. Use \`forget\` to remove ones that are stale.\n\n${formatted}`,
|
|
143
|
+
},
|
|
144
|
+
];
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
console.warn('[ContextEngine] MemoryProvider failed:', error);
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Event types we omit from the recent-events context block. They duplicate
|
|
154
|
+
* information already in the conversation history, are infrastructural
|
|
155
|
+
* noise, or are too large to be useful as a tail summary.
|
|
156
|
+
*/
|
|
157
|
+
const NOISY_EVENT_PREFIXES = [
|
|
158
|
+
'agent:invoke',
|
|
159
|
+
'agent:output',
|
|
160
|
+
'agent:run',
|
|
161
|
+
'agent:active-runs',
|
|
162
|
+
'client:ui',
|
|
163
|
+
'stream:',
|
|
164
|
+
'action:storage:get-',
|
|
165
|
+
'action:storage:patch-',
|
|
166
|
+
];
|
|
167
|
+
const MAX_RECENT_EVENTS = 20;
|
|
168
|
+
const MAX_EVENT_DATA_CHARS = 300;
|
|
169
|
+
const isNoisyEvent = (event) => NOISY_EVENT_PREFIXES.some((prefix) => event.type.startsWith(prefix));
|
|
170
|
+
const summarizeEvent = (event) => {
|
|
171
|
+
const data = event.data;
|
|
172
|
+
if (data === undefined)
|
|
173
|
+
return `- ${event.type}`;
|
|
174
|
+
let payload;
|
|
175
|
+
try {
|
|
176
|
+
payload = typeof data === 'string' ? data : JSON.stringify(data);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
payload = '[unserialisable]';
|
|
180
|
+
}
|
|
181
|
+
if (payload.length > MAX_EVENT_DATA_CHARS) {
|
|
182
|
+
payload = `${payload.slice(0, MAX_EVENT_DATA_CHARS)}…`;
|
|
183
|
+
}
|
|
184
|
+
return `- ${event.type}: ${payload}`;
|
|
185
|
+
};
|
|
100
186
|
class RecentEventsProvider {
|
|
101
187
|
constructor() {
|
|
102
188
|
this.name = 'recent-events';
|
|
@@ -104,28 +190,61 @@ class RecentEventsProvider {
|
|
|
104
190
|
async provide(state, storage) {
|
|
105
191
|
if (!storage)
|
|
106
192
|
return [];
|
|
107
|
-
const items = [];
|
|
108
|
-
// Fetch channel events if no thread, otherwise fetch thread events
|
|
109
193
|
const channelId = state.channelId;
|
|
110
194
|
const threadId = state.threadId;
|
|
111
195
|
try {
|
|
112
196
|
const events = await storage.getEvents({ channelId, threadId });
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
197
|
+
const filtered = events.filter((e) => !isNoisyEvent(e));
|
|
198
|
+
if (filtered.length === 0)
|
|
199
|
+
return [];
|
|
200
|
+
const formatted = filtered.slice(-MAX_RECENT_EVENTS).map(summarizeEvent).join('\n');
|
|
201
|
+
return [
|
|
202
|
+
{
|
|
119
203
|
id: threadId ? 'thread-events' : 'channel-events',
|
|
120
204
|
type: 'events',
|
|
121
205
|
priority: 70,
|
|
122
|
-
content: `## ${threadId ? 'THREAD' : 'CHANNEL'} RECENT ACTIVITIES (events)\n${
|
|
123
|
-
}
|
|
124
|
-
|
|
206
|
+
content: `## ${threadId ? 'THREAD' : 'CHANNEL'} RECENT ACTIVITIES (events)\n${formatted}`,
|
|
207
|
+
},
|
|
208
|
+
];
|
|
125
209
|
}
|
|
126
210
|
catch (error) {
|
|
127
|
-
console.warn(
|
|
211
|
+
console.warn('[ContextEngine] Failed to fetch events:', error);
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Drops the lowest-priority items until the assembled prompt fits within the
|
|
218
|
+
* token budget. The first item with priority >= `keepFloor` is always kept,
|
|
219
|
+
* so the agent's own instructions can never be evicted. Stable on ties:
|
|
220
|
+
* later-emitted items go first.
|
|
221
|
+
*/
|
|
222
|
+
export class TokenBudgetProcessor {
|
|
223
|
+
constructor(budget = TokenBudgetProcessor.DEFAULT_BUDGET, keepFloor = TokenBudgetProcessor.KEEP_FLOOR) {
|
|
224
|
+
this.budget = budget;
|
|
225
|
+
this.keepFloor = keepFloor;
|
|
226
|
+
this.name = 'token-budget';
|
|
227
|
+
}
|
|
228
|
+
async process(items) {
|
|
229
|
+
const sorted = [...items].sort((a, b) => b.priority - a.priority);
|
|
230
|
+
const out = [];
|
|
231
|
+
let used = 0;
|
|
232
|
+
for (const item of sorted) {
|
|
233
|
+
const cost = estimateTokens(item.content);
|
|
234
|
+
if (item.priority >= this.keepFloor) {
|
|
235
|
+
out.push(item);
|
|
236
|
+
used += cost;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (used + cost <= this.budget) {
|
|
240
|
+
out.push(item);
|
|
241
|
+
used += cost;
|
|
242
|
+
}
|
|
128
243
|
}
|
|
129
|
-
return
|
|
244
|
+
return out;
|
|
130
245
|
}
|
|
131
246
|
}
|
|
247
|
+
/** Soft prompt budget in tokens (matches gpt-4o-mini's reasonable system slice). */
|
|
248
|
+
TokenBudgetProcessor.DEFAULT_BUDGET = 8000;
|
|
249
|
+
/** Items at or above this priority are never dropped. */
|
|
250
|
+
TokenBudgetProcessor.KEEP_FLOOR = 100;
|
|
@@ -21,6 +21,78 @@ function resolveModel(modelString) {
|
|
|
21
21
|
const asRecord = (value) => value && typeof value === 'object' && !Array.isArray(value)
|
|
22
22
|
? value
|
|
23
23
|
: {};
|
|
24
|
+
/** Per-message hard cap (in characters) on tool-result payloads we feed back
|
|
25
|
+
* to the model. Prevents one huge tool output from eating the context window;
|
|
26
|
+
* the original event remains intact in storage. */
|
|
27
|
+
const TOOL_RESULT_MAX_CHARS = 8000;
|
|
28
|
+
/** Sliding window: max number of messages we replay to the model on each
|
|
29
|
+
* invocation. Older turns stay on disk but are not sent. Keeps both the
|
|
30
|
+
* recent prompts and the prompt token budget bounded. */
|
|
31
|
+
const MAX_WINDOW_MESSAGES = 80;
|
|
32
|
+
const truncateToolPayload = (raw) => {
|
|
33
|
+
const serialized = typeof raw === 'string' ? raw : JSON.stringify(raw);
|
|
34
|
+
if (serialized.length <= TOOL_RESULT_MAX_CHARS)
|
|
35
|
+
return serialized;
|
|
36
|
+
const dropped = serialized.length - TOOL_RESULT_MAX_CHARS;
|
|
37
|
+
return `${serialized.slice(0, TOOL_RESULT_MAX_CHARS)}\n…[truncated ${dropped} chars]`;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Trim the message history to a sliding window while preserving tool-call
|
|
41
|
+
* integrity. Drops any leading orphan `tool` messages whose matching
|
|
42
|
+
* assistant call was sliced off, since most providers reject that.
|
|
43
|
+
*/
|
|
44
|
+
const buildMessageWindow = (messages) => {
|
|
45
|
+
if (messages.length <= MAX_WINDOW_MESSAGES)
|
|
46
|
+
return messages;
|
|
47
|
+
const tail = messages.slice(-MAX_WINDOW_MESSAGES);
|
|
48
|
+
const knownAssistantCallIds = new Set();
|
|
49
|
+
for (const m of tail) {
|
|
50
|
+
if (m.role === 'assistant' && m.toolCalls) {
|
|
51
|
+
for (const tc of m.toolCalls)
|
|
52
|
+
knownAssistantCallIds.add(tc.id);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return tail.filter((m) => m.role !== 'tool' || knownAssistantCallIds.has(m.toolCallId));
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Self-healing pass: every assistant tool_call must have a matching tool
|
|
59
|
+
* result before the next user/assistant turn, or providers (OpenAI in
|
|
60
|
+
* particular) reject the request with "Tool result is missing for tool call".
|
|
61
|
+
*
|
|
62
|
+
* This can happen when a handler emits a `:result` event without `meta`
|
|
63
|
+
* (orphaning the call), the process restarts mid-run, or a tool handler
|
|
64
|
+
* crashes. Rather than refuse to continue, we inject synthetic tool messages
|
|
65
|
+
* with a clear error payload — the LLM can then explain the failure to the
|
|
66
|
+
* user and proceed.
|
|
67
|
+
*/
|
|
68
|
+
const repairOpenToolCalls = (messages) => {
|
|
69
|
+
const fulfilled = new Set();
|
|
70
|
+
for (const m of messages) {
|
|
71
|
+
if (m.role === 'tool')
|
|
72
|
+
fulfilled.add(m.toolCallId);
|
|
73
|
+
}
|
|
74
|
+
const repaired = [];
|
|
75
|
+
for (const m of messages) {
|
|
76
|
+
repaired.push(m);
|
|
77
|
+
if (m.role !== 'assistant' || !m.toolCalls)
|
|
78
|
+
continue;
|
|
79
|
+
for (const tc of m.toolCalls) {
|
|
80
|
+
if (fulfilled.has(tc.id))
|
|
81
|
+
continue;
|
|
82
|
+
repaired.push({
|
|
83
|
+
role: 'tool',
|
|
84
|
+
toolCallId: tc.id,
|
|
85
|
+
toolName: tc.function.name,
|
|
86
|
+
content: JSON.stringify({
|
|
87
|
+
success: false,
|
|
88
|
+
error: 'Tool result was lost (handler did not emit a matching :result event).',
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
fulfilled.add(tc.id);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return repaired;
|
|
95
|
+
};
|
|
24
96
|
const readPersistedShortTermMessages = (state) => {
|
|
25
97
|
const source = state.threadDetails?.state ?? state.channelDetails?.state;
|
|
26
98
|
const record = asRecord(source);
|
|
@@ -108,7 +180,7 @@ export const aiSdkRuntime = (options) => (builder) => {
|
|
|
108
180
|
const runLLM = async function* (context, threadId) {
|
|
109
181
|
ensureShortTermMessages(context.state);
|
|
110
182
|
const systemPrompt = await buildSystemPrompt(context.state, system, context, storage, contextEngine);
|
|
111
|
-
const coreMessages = mapToCoreMessages(context.state.shortTermMessages || []);
|
|
183
|
+
const coreMessages = mapToCoreMessages(buildMessageWindow(repairOpenToolCalls(context.state.shortTermMessages || [])));
|
|
112
184
|
try {
|
|
113
185
|
const result = await generateText({
|
|
114
186
|
model,
|
|
@@ -246,7 +318,7 @@ export const aiSdkRuntime = (options) => (builder) => {
|
|
|
246
318
|
ensureShortTermMessages(context.state);
|
|
247
319
|
const toolName = event.type.replace(/^action:/, '').replace(/:result$/, '');
|
|
248
320
|
const resultData = event.data;
|
|
249
|
-
const content =
|
|
321
|
+
const content = truncateToolPayload(resultData);
|
|
250
322
|
context.state.shortTermMessages = [
|
|
251
323
|
...(context.state.shortTermMessages ?? []),
|
|
252
324
|
{ role: 'tool', content, toolCallId, toolName },
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import z from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* `memory` — exposes the global memory store as agent tools.
|
|
4
|
+
*
|
|
5
|
+
* The actual handlers live in `bus/services.ts` because memory is platform
|
|
6
|
+
* infrastructure (shared across every agent on the bus); this plugin only
|
|
7
|
+
* contributes the tool definitions so a runtime plugin (e.g. `ai-sdk`) can
|
|
8
|
+
* surface them to the LLM.
|
|
9
|
+
*
|
|
10
|
+
* Scopes
|
|
11
|
+
* ------
|
|
12
|
+
* - `global` (default) — visible to every agent and channel.
|
|
13
|
+
* - `agent` — visible only to the agent that wrote it.
|
|
14
|
+
* - `channel` — visible only inside the active channel.
|
|
15
|
+
*/
|
|
16
|
+
const memoryToolDefinitions = {
|
|
17
|
+
remember: {
|
|
18
|
+
description: 'Persist a durable fact, preference, or note to long-term memory so it can be recalled in future turns and runs. Use for stable information (user preferences, project conventions, contact details, decisions); avoid using it for transient chatter or per-step scratch state — that belongs in thread state. Keep entries short and self-contained.',
|
|
19
|
+
inputSchema: z.object({
|
|
20
|
+
content: z
|
|
21
|
+
.string()
|
|
22
|
+
.min(1)
|
|
23
|
+
.describe('The fact to remember, written so it makes sense out of context (e.g. "User prefers TypeScript over JavaScript.").'),
|
|
24
|
+
scope: z
|
|
25
|
+
.enum(['global', 'agent', 'channel'])
|
|
26
|
+
.optional()
|
|
27
|
+
.describe('Visibility: `global` (default, all agents everywhere), `agent` (only this agent), `channel` (only this channel).'),
|
|
28
|
+
tags: z
|
|
29
|
+
.array(z.string())
|
|
30
|
+
.optional()
|
|
31
|
+
.describe('Optional tags for filtering with `recall`.'),
|
|
32
|
+
}),
|
|
33
|
+
},
|
|
34
|
+
recall: {
|
|
35
|
+
description: 'Search long-term memory for facts you previously stored with `remember`. Returns up to `limit` matching records with their ids so you can `forget` stale ones.',
|
|
36
|
+
inputSchema: z.object({
|
|
37
|
+
query: z
|
|
38
|
+
.string()
|
|
39
|
+
.optional()
|
|
40
|
+
.describe('Case-insensitive substring filter against memory content.'),
|
|
41
|
+
tag: z.string().optional().describe('Only return memories that include this tag.'),
|
|
42
|
+
scope: z
|
|
43
|
+
.enum(['global', 'agent', 'channel', 'all'])
|
|
44
|
+
.optional()
|
|
45
|
+
.describe('Restrict the search to a single scope. Default `all` returns global + this agent + this channel.'),
|
|
46
|
+
limit: z
|
|
47
|
+
.number()
|
|
48
|
+
.int()
|
|
49
|
+
.positive()
|
|
50
|
+
.max(50)
|
|
51
|
+
.optional()
|
|
52
|
+
.describe('Maximum records to return (default 20, max 50).'),
|
|
53
|
+
}),
|
|
54
|
+
},
|
|
55
|
+
forget: {
|
|
56
|
+
description: 'Delete a memory by id. Use after the user asks to forget something or when a previously remembered fact is now wrong. Get ids from `recall`.',
|
|
57
|
+
inputSchema: z.object({
|
|
58
|
+
id: z.string().describe('The memory record id (returned by `recall`/`remember`).'),
|
|
59
|
+
}),
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
export const memoryPlugin = {
|
|
63
|
+
id: 'memory',
|
|
64
|
+
name: 'Memory',
|
|
65
|
+
description: 'Global long-term memory: remember/recall/forget facts across runs and agents.',
|
|
66
|
+
toolDefinitions: memoryToolDefinitions,
|
|
67
|
+
factory: () => () => {
|
|
68
|
+
// Handlers live in bus/services.ts; this plugin only contributes tool definitions.
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
export default memoryPlugin;
|
package/dist/registry/plugins.js
CHANGED
|
@@ -8,6 +8,7 @@ import { delegationPlugin } from '../plugins/delegation/index.js';
|
|
|
8
8
|
import { storageToolsPlugin } from '../plugins/storage-tools/index.js';
|
|
9
9
|
import { uiPlugin } from '../plugins/ui/index.js';
|
|
10
10
|
import { approvalPlugin } from '../plugins/approval/index.js';
|
|
11
|
+
import { memoryPlugin } from '../plugins/memory/index.js';
|
|
11
12
|
import { DEFAULT_PLUGINS_DIR, DEFAULT_BASE_DIR, loadConfig, resolvePath } from '../app/config.js';
|
|
12
13
|
let pluginsDir = null;
|
|
13
14
|
const loadedPlugins = new Set();
|
|
@@ -20,6 +21,7 @@ const BUILT_IN = {
|
|
|
20
21
|
[storageToolsPlugin.id]: storageToolsPlugin,
|
|
21
22
|
[uiPlugin.id]: uiPlugin,
|
|
22
23
|
[approvalPlugin.id]: approvalPlugin,
|
|
24
|
+
[memoryPlugin.id]: memoryPlugin,
|
|
23
25
|
};
|
|
24
26
|
/** Normalize a dynamically imported plugin module. Supports `plugin`, `default`. */
|
|
25
27
|
export function parsePluginModule(module) {
|