openbot 0.3.0 → 0.3.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.
- package/dist/app/cli.js +1 -1
- package/dist/app/server.js +1 -4
- package/dist/bus/services.js +222 -15
- package/dist/harness/context.js +205 -26
- package/dist/harness/queue-processor.js +44 -110
- package/dist/harness/runtime-factory.js +11 -7
- package/dist/harness/todo-advance.js +93 -0
- package/dist/plugins/ai-sdk/index.js +0 -3
- package/dist/plugins/ai-sdk/runtime.js +78 -13
- package/dist/plugins/ai-sdk/system-prompt.js +18 -3
- package/dist/plugins/delegation/index.js +7 -46
- package/dist/plugins/memory/index.js +71 -0
- package/dist/plugins/storage-tools/index.js +2 -11
- package/dist/plugins/todo/index.js +54 -0
- package/dist/plugins/workflow/index.js +65 -0
- package/dist/registry/plugins.js +4 -2
- package/dist/services/memory.js +152 -0
- package/dist/services/storage.js +9 -31
- package/dist/workflow/service.js +106 -0
- package/dist/workflow/types.js +3 -0
- package/docs/agents.md +15 -1
- package/docs/plugins.md +0 -1
- package/package.json +1 -1
- package/src/app/cli.ts +1 -1
- package/src/app/server.ts +3 -4
- package/src/app/types.ts +140 -45
- package/src/bus/plugin.ts +0 -2
- package/src/bus/services.ts +258 -17
- package/src/bus/types.ts +13 -4
- package/src/harness/context.ts +233 -37
- package/src/harness/queue-processor.ts +54 -143
- package/src/harness/runtime-factory.ts +11 -7
- package/src/harness/todo-advance.ts +128 -0
- package/src/plugins/ai-sdk/index.ts +0 -3
- package/src/plugins/ai-sdk/runtime.ts +356 -298
- package/src/plugins/ai-sdk/system-prompt.ts +18 -4
- package/src/plugins/delegation/index.ts +7 -50
- package/src/plugins/memory/index.ts +85 -0
- package/src/plugins/storage-tools/index.ts +8 -19
- package/src/plugins/todo/index.ts +64 -0
- package/src/registry/plugins.ts +4 -3
- package/src/services/memory.ts +213 -0
- package/src/services/storage.ts +9 -49
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { aiSdkRuntime } from './runtime.js';
|
|
2
|
-
import { AI_SDK_SYSTEM_PROMPT } from './system-prompt.js';
|
|
3
2
|
/**
|
|
4
3
|
* `ai-sdk` — generic LLM runtime plugin built on the Vercel AI SDK.
|
|
5
4
|
*
|
|
@@ -11,7 +10,6 @@ export const aiSdkPlugin = {
|
|
|
11
10
|
id: 'ai-sdk',
|
|
12
11
|
name: 'AI SDK Runtime',
|
|
13
12
|
description: 'Generic LLM runtime built on the Vercel AI SDK. Consumes tools contributed by other plugins.',
|
|
14
|
-
defaultInstructions: AI_SDK_SYSTEM_PROMPT,
|
|
15
13
|
configSchema: {
|
|
16
14
|
type: 'object',
|
|
17
15
|
properties: {
|
|
@@ -28,7 +26,6 @@ export const aiSdkPlugin = {
|
|
|
28
26
|
: 'openai/gpt-4o-mini';
|
|
29
27
|
return aiSdkRuntime({
|
|
30
28
|
model,
|
|
31
|
-
system: agentDetails.instructions || AI_SDK_SYSTEM_PROMPT,
|
|
32
29
|
storage,
|
|
33
30
|
toolDefinitions: tools,
|
|
34
31
|
});
|
|
@@ -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);
|
|
@@ -44,15 +116,8 @@ const persistShortTermMessages = async (state, storage) => {
|
|
|
44
116
|
state: { shortTermMessages },
|
|
45
117
|
});
|
|
46
118
|
};
|
|
47
|
-
async function buildSystemPrompt(state,
|
|
48
|
-
|
|
49
|
-
if (system && typeof system === 'string')
|
|
50
|
-
sections.push(system);
|
|
51
|
-
if (system && typeof system === 'function' && context)
|
|
52
|
-
sections.push(await system(context));
|
|
53
|
-
if (contextEngine)
|
|
54
|
-
sections.push(await contextEngine.buildContext(state, storage));
|
|
55
|
-
return sections.join('\n\n');
|
|
119
|
+
async function buildSystemPrompt(state, storage, contextEngine) {
|
|
120
|
+
return contextEngine.buildContext(state, storage);
|
|
56
121
|
}
|
|
57
122
|
/**
|
|
58
123
|
* Generic ai-sdk runtime plugin.
|
|
@@ -62,7 +127,7 @@ async function buildSystemPrompt(state, system, context, storage, contextEngine)
|
|
|
62
127
|
* loader (merged from every tool plugin attached to the same agent).
|
|
63
128
|
*/
|
|
64
129
|
export const aiSdkRuntime = (options) => (builder) => {
|
|
65
|
-
const { model: modelString = 'openai/gpt-4o-mini',
|
|
130
|
+
const { model: modelString = 'openai/gpt-4o-mini', storage, contextEngine = createDefaultContextEngine(), toolDefinitions = {}, } = options;
|
|
66
131
|
let currentModelString = modelString;
|
|
67
132
|
let model = resolveModel(currentModelString);
|
|
68
133
|
const ensureShortTermMessages = (state) => {
|
|
@@ -107,8 +172,8 @@ export const aiSdkRuntime = (options) => (builder) => {
|
|
|
107
172
|
};
|
|
108
173
|
const runLLM = async function* (context, threadId) {
|
|
109
174
|
ensureShortTermMessages(context.state);
|
|
110
|
-
const systemPrompt = await buildSystemPrompt(context.state,
|
|
111
|
-
const coreMessages = mapToCoreMessages(context.state.shortTermMessages || []);
|
|
175
|
+
const systemPrompt = await buildSystemPrompt(context.state, storage, contextEngine);
|
|
176
|
+
const coreMessages = mapToCoreMessages(buildMessageWindow(repairOpenToolCalls(context.state.shortTermMessages || [])));
|
|
112
177
|
try {
|
|
113
178
|
const result = await generateText({
|
|
114
179
|
model,
|
|
@@ -246,7 +311,7 @@ export const aiSdkRuntime = (options) => (builder) => {
|
|
|
246
311
|
ensureShortTermMessages(context.state);
|
|
247
312
|
const toolName = event.type.replace(/^action:/, '').replace(/:result$/, '');
|
|
248
313
|
const resultData = event.data;
|
|
249
|
-
const content =
|
|
314
|
+
const content = truncateToolPayload(resultData);
|
|
250
315
|
context.state.shortTermMessages = [
|
|
251
316
|
...(context.state.shortTermMessages ?? []),
|
|
252
317
|
{ role: 'tool', content, toolCallId, toolName },
|
|
@@ -1,3 +1,18 @@
|
|
|
1
|
-
export const AI_SDK_SYSTEM_PROMPT =
|
|
2
|
-
'
|
|
3
|
-
'
|
|
1
|
+
export const AI_SDK_SYSTEM_PROMPT = [
|
|
2
|
+
'You are a helpful AI assistant on the OpenBot platform.',
|
|
3
|
+
'Use the tools available to you to help the user.',
|
|
4
|
+
'Be concise unless the user asks for depth.',
|
|
5
|
+
'',
|
|
6
|
+
'## Planning with todos',
|
|
7
|
+
'The current thread has a shared todo list (visible under "Shared todo plan" in context).',
|
|
8
|
+
'It is the single source of truth for multi-step work and is shared across every agent in the thread.',
|
|
9
|
+
'',
|
|
10
|
+
'When planning:',
|
|
11
|
+
'- For any task that needs more than one step, call `todo_write` ONCE with the full ordered plan, then stop. Do not call any other tool in the same turn.',
|
|
12
|
+
'- The platform dispatches assignees automatically and completes their todo when their run ends. You do NOT need to call `handoff` to start the plan or `todo_update` to finish items.',
|
|
13
|
+
'- Each item must be concrete and atomic (one verb, one outcome). Skip the list entirely for trivial single-step requests.',
|
|
14
|
+
'',
|
|
15
|
+
'When you are an assignee (you have an `in_progress` todo addressed to you):',
|
|
16
|
+
'- Just do the work and reply. The platform will mark your todo done and dispatch the next one.',
|
|
17
|
+
'- If you genuinely cannot complete it, call `todo_update(id, status: "cancelled")` with a brief reason in your reply.',
|
|
18
|
+
].join('\n');
|
|
@@ -1,21 +1,14 @@
|
|
|
1
1
|
import z from 'zod';
|
|
2
|
-
const
|
|
2
|
+
const handoffToolDefinitions = {
|
|
3
3
|
handoff: {
|
|
4
|
-
description: 'Transfer control to another agent. The target agent continues the task
|
|
4
|
+
description: 'Transfer control to another agent. The target agent continues the task in this thread.',
|
|
5
5
|
inputSchema: z.object({
|
|
6
6
|
agentId: z.string().describe('The ID of the target agent.'),
|
|
7
7
|
content: z.string().describe('The message or task to hand off.'),
|
|
8
8
|
}),
|
|
9
9
|
},
|
|
10
|
-
delegate: {
|
|
11
|
-
description: 'Delegate a subtask to another agent and wait for its result so you can continue.',
|
|
12
|
-
inputSchema: z.object({
|
|
13
|
-
agentId: z.string().describe('The ID of the target agent.'),
|
|
14
|
-
content: z.string().describe('The subtask you want the target agent to execute.'),
|
|
15
|
-
}),
|
|
16
|
-
},
|
|
17
10
|
};
|
|
18
|
-
const
|
|
11
|
+
const handoffPluginRuntime = () => (builder) => {
|
|
19
12
|
builder.on('action:handoff', async function* (event, context) {
|
|
20
13
|
const { agentId, content } = event.data;
|
|
21
14
|
yield {
|
|
@@ -36,44 +29,12 @@ const delegationPluginRuntime = () => (builder) => {
|
|
|
36
29
|
};
|
|
37
30
|
}
|
|
38
31
|
});
|
|
39
|
-
builder.on('action:delegate', async function* (event, context) {
|
|
40
|
-
const { agentId, content } = event.data;
|
|
41
|
-
const widgetId = event.meta?.toolCallId
|
|
42
|
-
? `delegate_${event.meta.toolCallId}`
|
|
43
|
-
: `delegate_${Date.now()}`;
|
|
44
|
-
yield {
|
|
45
|
-
type: 'client:ui:widget',
|
|
46
|
-
data: {
|
|
47
|
-
kind: 'message',
|
|
48
|
-
widgetId,
|
|
49
|
-
title: `Delegation started: ${agentId}`,
|
|
50
|
-
body: `Running delegated task in background.\n\n${content}`,
|
|
51
|
-
state: 'open',
|
|
52
|
-
metadata: {
|
|
53
|
-
type: 'delegation:status',
|
|
54
|
-
phase: 'started',
|
|
55
|
-
delegatedAgentId: agentId,
|
|
56
|
-
},
|
|
57
|
-
},
|
|
58
|
-
meta: { ...(event.meta || {}), agentId: context.state.agentId },
|
|
59
|
-
};
|
|
60
|
-
yield {
|
|
61
|
-
type: 'delegation:request',
|
|
62
|
-
data: { agentId, content },
|
|
63
|
-
meta: {
|
|
64
|
-
...(event.meta || {}),
|
|
65
|
-
parentAgentId: context.state.agentId,
|
|
66
|
-
delegationWidgetId: widgetId,
|
|
67
|
-
agentId: context.state.agentId,
|
|
68
|
-
},
|
|
69
|
-
};
|
|
70
|
-
});
|
|
71
32
|
};
|
|
72
33
|
export const delegationPlugin = {
|
|
73
34
|
id: 'delegation',
|
|
74
|
-
name: '
|
|
75
|
-
description: 'Hand off
|
|
76
|
-
toolDefinitions:
|
|
77
|
-
factory: () =>
|
|
35
|
+
name: 'Handoff',
|
|
36
|
+
description: 'Hand off tasks to other agents on the bus.',
|
|
37
|
+
toolDefinitions: handoffToolDefinitions,
|
|
38
|
+
factory: () => handoffPluginRuntime(),
|
|
78
39
|
};
|
|
79
40
|
export default delegationPlugin;
|
|
@@ -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;
|
|
@@ -42,20 +42,11 @@ const storageToolDefinitions = {
|
|
|
42
42
|
.refine((value) => value.state !== undefined || value.spec !== undefined || value.cwd !== undefined, { message: 'Provide at least one of state, spec, or cwd.' }),
|
|
43
43
|
},
|
|
44
44
|
patch_thread_details: {
|
|
45
|
-
description: 'Patch current thread details (state
|
|
46
|
-
inputSchema: z
|
|
47
|
-
.object({
|
|
45
|
+
description: 'Patch current thread details (state).',
|
|
46
|
+
inputSchema: z.object({
|
|
48
47
|
state: z
|
|
49
48
|
.record(z.string(), z.unknown())
|
|
50
|
-
.optional()
|
|
51
49
|
.describe('JSON state object for the thread. Use for structured data like `todos` or progress.'),
|
|
52
|
-
spec: z
|
|
53
|
-
.string()
|
|
54
|
-
.optional()
|
|
55
|
-
.describe('Markdown content for the thread specification (SPEC.md). Use for plans and goals.'),
|
|
56
|
-
})
|
|
57
|
-
.refine((value) => value.state !== undefined || value.spec !== undefined, {
|
|
58
|
-
message: 'Provide at least one of state or spec.',
|
|
59
50
|
}),
|
|
60
51
|
},
|
|
61
52
|
create_variable: {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import z from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* `todo` — shared, per-thread task list for autonomous multi-agent flows.
|
|
4
|
+
*
|
|
5
|
+
* Todos live in `threadDetails.state.todos` and are owned by the system
|
|
6
|
+
* (handlers in `bus/services.ts`). Any agent in the thread can read the
|
|
7
|
+
* list via context, and propose mutations through these tools. Each item
|
|
8
|
+
* may carry an `assignee` agent id; combine with `handoff` to drive an
|
|
9
|
+
* autonomous, multi-step plan across agents.
|
|
10
|
+
*
|
|
11
|
+
* Keep the surface minimal: two tools (replace-all, patch-one) cover plan
|
|
12
|
+
* authoring, status transitions, and reassignment.
|
|
13
|
+
*/
|
|
14
|
+
const todoStatus = z.enum(['pending', 'in_progress', 'done', 'cancelled']);
|
|
15
|
+
const todoToolDefinitions = {
|
|
16
|
+
todo_write: {
|
|
17
|
+
description: 'Author or rewrite the shared todo plan for the current thread. Pass the full ordered list — missing items are removed. Use at the start of multi-step work, or whenever the plan changes shape. For status flips, prefer `todo_update`.',
|
|
18
|
+
inputSchema: z.object({
|
|
19
|
+
todos: z
|
|
20
|
+
.array(z.object({
|
|
21
|
+
id: z
|
|
22
|
+
.string()
|
|
23
|
+
.optional()
|
|
24
|
+
.describe('Stable id. Reuse existing ids to preserve history; omit to create.'),
|
|
25
|
+
content: z.string().min(1).describe('What needs to be done. One concrete step.'),
|
|
26
|
+
status: todoStatus.optional().describe('Defaults to `pending`.'),
|
|
27
|
+
assignee: z
|
|
28
|
+
.string()
|
|
29
|
+
.optional()
|
|
30
|
+
.describe('Agent id responsible for this step. Pair with `handoff` to delegate.'),
|
|
31
|
+
}))
|
|
32
|
+
.describe('The complete, ordered plan.'),
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
todo_update: {
|
|
36
|
+
description: 'Patch a single todo by id. Use to mark progress (`in_progress`, `done`, `cancelled`), rephrase, or reassign without rewriting the whole list.',
|
|
37
|
+
inputSchema: z.object({
|
|
38
|
+
id: z.string().describe('Todo id from `todo_write` or the rendered list.'),
|
|
39
|
+
status: todoStatus.optional(),
|
|
40
|
+
content: z.string().min(1).optional(),
|
|
41
|
+
assignee: z.string().optional().describe('Use empty string to clear.'),
|
|
42
|
+
}),
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
export const todoPlugin = {
|
|
46
|
+
id: 'todo',
|
|
47
|
+
name: 'Todo',
|
|
48
|
+
description: 'Shared per-thread task list for coordinating multi-step, multi-agent work.',
|
|
49
|
+
toolDefinitions: todoToolDefinitions,
|
|
50
|
+
factory: () => () => {
|
|
51
|
+
// Handlers live in bus/services.ts; this plugin only contributes tool definitions.
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
export default todoPlugin;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import z from 'zod';
|
|
2
|
+
import { setWorkflowPlan, updateWorkflowTodo } from '../../workflow/service.js';
|
|
3
|
+
const workflowToolDefinitions = {
|
|
4
|
+
workflow_set_plan: {
|
|
5
|
+
description: 'Define or replace the multi-agent plan for this thread.',
|
|
6
|
+
inputSchema: z.object({
|
|
7
|
+
title: z.string().optional().describe('Optional title for the plan.'),
|
|
8
|
+
todos: z.array(z.object({
|
|
9
|
+
id: z.string().optional().describe('Optional stable id.'),
|
|
10
|
+
text: z.string().describe('What needs to be done.'),
|
|
11
|
+
})).min(1).describe('Ordered steps.'),
|
|
12
|
+
}),
|
|
13
|
+
},
|
|
14
|
+
workflow_update_todo: {
|
|
15
|
+
description: 'Update a step in the plan with progress or results.',
|
|
16
|
+
inputSchema: z.object({
|
|
17
|
+
todoId: z.string().describe('The id of the todo to update.'),
|
|
18
|
+
status: z.enum(['pending', 'done', 'failed']).optional().describe('New status.'),
|
|
19
|
+
summary: z.string().optional().describe('Concise result or note about this step.'),
|
|
20
|
+
}),
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
const workflowPluginRuntime = () => (builder) => {
|
|
24
|
+
builder.on('action:workflow_set_plan', async function* (event, context) {
|
|
25
|
+
const { threadId, channelId } = context.state;
|
|
26
|
+
if (!threadId) {
|
|
27
|
+
yield { type: 'action:workflow_set_plan:result', data: { success: false, error: 'No active thread.' }, meta: event.meta };
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const workflow = await setWorkflowPlan({ channelId, threadId, plan: event.data });
|
|
32
|
+
yield { type: 'action:workflow_set_plan:result', data: { success: true, workflowId: workflow.id }, meta: event.meta };
|
|
33
|
+
yield {
|
|
34
|
+
type: 'agent:output',
|
|
35
|
+
data: { content: `Plan updated: **${workflow.title || workflow.id}** (${workflow.todos.length} steps).` },
|
|
36
|
+
meta: { ...(event.meta || {}), agentId: context.state.agentId, threadId },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
yield { type: 'action:workflow_set_plan:result', data: { success: false, error: String(error) }, meta: event.meta };
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
builder.on('action:workflow_update_todo', async function* (event, context) {
|
|
44
|
+
const { threadId, channelId } = context.state;
|
|
45
|
+
if (!threadId) {
|
|
46
|
+
yield { type: 'action:workflow_update_todo:result', data: { success: false, error: 'No active thread.' }, meta: event.meta };
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const workflow = await updateWorkflowTodo({ channelId, threadId, update: event.data });
|
|
51
|
+
yield { type: 'action:workflow_update_todo:result', data: { success: true, todoId: event.data.todoId }, meta: event.meta };
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
yield { type: 'action:workflow_update_todo:result', data: { success: false, error: String(error) }, meta: event.meta };
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
export const workflowPlugin = {
|
|
59
|
+
id: 'workflow',
|
|
60
|
+
name: 'Workflow',
|
|
61
|
+
description: 'Simple thread-scoped planning for multi-agent coordination.',
|
|
62
|
+
toolDefinitions: workflowToolDefinitions,
|
|
63
|
+
factory: () => workflowPluginRuntime(),
|
|
64
|
+
};
|
|
65
|
+
export default workflowPlugin;
|
package/dist/registry/plugins.js
CHANGED
|
@@ -8,6 +8,8 @@ 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';
|
|
12
|
+
import { todoPlugin } from '../plugins/todo/index.js';
|
|
11
13
|
import { DEFAULT_PLUGINS_DIR, DEFAULT_BASE_DIR, loadConfig, resolvePath } from '../app/config.js';
|
|
12
14
|
let pluginsDir = null;
|
|
13
15
|
const loadedPlugins = new Set();
|
|
@@ -20,6 +22,8 @@ const BUILT_IN = {
|
|
|
20
22
|
[storageToolsPlugin.id]: storageToolsPlugin,
|
|
21
23
|
[uiPlugin.id]: uiPlugin,
|
|
22
24
|
[approvalPlugin.id]: approvalPlugin,
|
|
25
|
+
[memoryPlugin.id]: memoryPlugin,
|
|
26
|
+
[todoPlugin.id]: todoPlugin,
|
|
23
27
|
};
|
|
24
28
|
/** Normalize a dynamically imported plugin module. Supports `plugin`, `default`. */
|
|
25
29
|
export function parsePluginModule(module) {
|
|
@@ -33,14 +37,12 @@ export function parsePluginModule(module) {
|
|
|
33
37
|
const name = typeof raw.name === 'string' ? raw.name : '';
|
|
34
38
|
const description = typeof raw.description === 'string' ? raw.description : '';
|
|
35
39
|
const image = typeof raw.image === 'string' ? raw.image : undefined;
|
|
36
|
-
const defaultInstructions = typeof raw.defaultInstructions === 'string' ? raw.defaultInstructions : undefined;
|
|
37
40
|
const configSchema = raw.configSchema;
|
|
38
41
|
const toolDefinitions = raw.toolDefinitions;
|
|
39
42
|
return {
|
|
40
43
|
name,
|
|
41
44
|
description,
|
|
42
45
|
image,
|
|
43
|
-
defaultInstructions,
|
|
44
46
|
configSchema,
|
|
45
47
|
toolDefinitions,
|
|
46
48
|
factory: factory,
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { DEFAULT_BASE_DIR, loadConfig, resolvePath } from '../app/config.js';
|
|
5
|
+
const DEFAULT_LIMIT = 50;
|
|
6
|
+
const MAX_LIMIT = 500;
|
|
7
|
+
const getMemoryDir = () => {
|
|
8
|
+
const config = loadConfig();
|
|
9
|
+
return path.join(resolvePath(config.baseDir || DEFAULT_BASE_DIR), 'memory');
|
|
10
|
+
};
|
|
11
|
+
const getLogPath = () => path.join(getMemoryDir(), 'log.jsonl');
|
|
12
|
+
const ensureDir = async () => {
|
|
13
|
+
await fs.mkdir(getMemoryDir(), { recursive: true });
|
|
14
|
+
};
|
|
15
|
+
const readLog = async () => {
|
|
16
|
+
try {
|
|
17
|
+
const raw = await fs.readFile(getLogPath(), 'utf-8');
|
|
18
|
+
return raw
|
|
19
|
+
.split(/\r?\n/)
|
|
20
|
+
.map((line) => line.trim())
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
.map((line) => {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(line);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
.filter((e) => !!e);
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
if (e?.code === 'ENOENT')
|
|
34
|
+
return [];
|
|
35
|
+
throw e;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const replay = (entries) => {
|
|
39
|
+
const out = new Map();
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
if (entry.op === 'add') {
|
|
42
|
+
out.set(entry.record.id, entry.record);
|
|
43
|
+
}
|
|
44
|
+
else if (entry.op === 'delete') {
|
|
45
|
+
out.delete(entry.id);
|
|
46
|
+
}
|
|
47
|
+
else if (entry.op === 'update') {
|
|
48
|
+
const existing = out.get(entry.id);
|
|
49
|
+
if (!existing)
|
|
50
|
+
continue;
|
|
51
|
+
out.set(entry.id, {
|
|
52
|
+
...existing,
|
|
53
|
+
...entry.patch,
|
|
54
|
+
id: existing.id,
|
|
55
|
+
updatedAt: entry.at,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
};
|
|
61
|
+
const appendEntry = async (entry) => {
|
|
62
|
+
await ensureDir();
|
|
63
|
+
await fs.appendFile(getLogPath(), `${JSON.stringify(entry)}\n`, 'utf-8');
|
|
64
|
+
};
|
|
65
|
+
const matchesQuery = (record, query, tag) => {
|
|
66
|
+
if (tag) {
|
|
67
|
+
if (!record.tags || !record.tags.includes(tag))
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
if (query) {
|
|
71
|
+
const q = query.toLowerCase();
|
|
72
|
+
if (!record.content.toLowerCase().includes(q))
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
};
|
|
77
|
+
export const memoryService = {
|
|
78
|
+
appendMemory: async (args) => {
|
|
79
|
+
const now = new Date().toISOString();
|
|
80
|
+
const record = {
|
|
81
|
+
id: crypto.randomUUID(),
|
|
82
|
+
scope: args.scope,
|
|
83
|
+
content: args.content,
|
|
84
|
+
tags: args.tags?.length ? args.tags : undefined,
|
|
85
|
+
createdAt: now,
|
|
86
|
+
updatedAt: now,
|
|
87
|
+
};
|
|
88
|
+
await appendEntry({ op: 'add', record });
|
|
89
|
+
return record;
|
|
90
|
+
},
|
|
91
|
+
updateMemory: async (args) => {
|
|
92
|
+
const entries = await readLog();
|
|
93
|
+
const map = replay(entries);
|
|
94
|
+
if (!map.has(args.id))
|
|
95
|
+
return false;
|
|
96
|
+
const at = new Date().toISOString();
|
|
97
|
+
const patch = {};
|
|
98
|
+
if (args.content !== undefined)
|
|
99
|
+
patch.content = args.content;
|
|
100
|
+
if (args.tags !== undefined)
|
|
101
|
+
patch.tags = args.tags.length ? args.tags : undefined;
|
|
102
|
+
if (Object.keys(patch).length === 0)
|
|
103
|
+
return true;
|
|
104
|
+
await appendEntry({ op: 'update', id: args.id, patch, at });
|
|
105
|
+
return true;
|
|
106
|
+
},
|
|
107
|
+
deleteMemory: async (args) => {
|
|
108
|
+
const entries = await readLog();
|
|
109
|
+
const map = replay(entries);
|
|
110
|
+
if (!map.has(args.id))
|
|
111
|
+
return false;
|
|
112
|
+
await appendEntry({ op: 'delete', id: args.id, at: new Date().toISOString() });
|
|
113
|
+
return true;
|
|
114
|
+
},
|
|
115
|
+
listMemories: async (args = {}) => {
|
|
116
|
+
const entries = await readLog();
|
|
117
|
+
const map = replay(entries);
|
|
118
|
+
const limit = Math.min(Math.max(args.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT);
|
|
119
|
+
const scopeSet = (() => {
|
|
120
|
+
if (args.scope)
|
|
121
|
+
return new Set([args.scope]);
|
|
122
|
+
if (args.scopes && args.scopes.length > 0)
|
|
123
|
+
return new Set(args.scopes);
|
|
124
|
+
return null;
|
|
125
|
+
})();
|
|
126
|
+
const filtered = [];
|
|
127
|
+
for (const record of map.values()) {
|
|
128
|
+
if (scopeSet && !scopeSet.has(record.scope))
|
|
129
|
+
continue;
|
|
130
|
+
if (!matchesQuery(record, args.query, args.tag))
|
|
131
|
+
continue;
|
|
132
|
+
filtered.push(record);
|
|
133
|
+
}
|
|
134
|
+
filtered.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
|
|
135
|
+
return filtered.slice(0, limit);
|
|
136
|
+
},
|
|
137
|
+
/**
|
|
138
|
+
* Compact the log into a single `add` per surviving record. Cheap to call
|
|
139
|
+
* occasionally; not required for correctness.
|
|
140
|
+
*/
|
|
141
|
+
compact: async () => {
|
|
142
|
+
const entries = await readLog();
|
|
143
|
+
const map = replay(entries);
|
|
144
|
+
const surviving = Array.from(map.values());
|
|
145
|
+
await ensureDir();
|
|
146
|
+
const tmp = `${getLogPath()}.tmp`;
|
|
147
|
+
const body = surviving.map((record) => JSON.stringify({ op: 'add', record })).join('\n');
|
|
148
|
+
await fs.writeFile(tmp, body ? `${body}\n` : '', 'utf-8');
|
|
149
|
+
await fs.rename(tmp, getLogPath());
|
|
150
|
+
return surviving.length;
|
|
151
|
+
},
|
|
152
|
+
};
|