@meetopenbot/openbot 0.2.6 → 1.0.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.
@@ -0,0 +1,6 @@
1
+ /** AI SDK `fullStream` reports failures as `{ type: 'error' }` instead of throwing. */
2
+ export function errorFromStreamPart(part) {
3
+ if (part.type !== 'error')
4
+ return undefined;
5
+ return part.error ?? new Error('Model stream failed');
6
+ }
@@ -28,6 +28,10 @@ export const OPENBOT_SYSTEM_PROMPT = [
28
28
  "- The current list is injected into context each turn as `## TODOS`; call `todo_read` only if you need an explicit refresh.",
29
29
  "- Do not stop with open `pending` or `in_progress` items unless you are blocked and have told the user why.",
30
30
  "",
31
+ "# JOB TITLE",
32
+ "- Unnamed jobs get a system hint to call `set_thread_title` once with a concise, human-readable title.",
33
+ "- Call `set_thread_title` again only if the topic clearly changes.",
34
+ "",
31
35
  "# JOB STATUS",
32
36
  "- Every thread is a job. Status is `working` (in progress), `needs_input`, `ready_for_review`, `completed`, or `archived`.",
33
37
  "- Status is the job's workflow state, not whether an agent is currently executing.",
@@ -1,130 +1,159 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { agentOutput } from '@meetopenbot/plugin-sdk';
2
3
  /**
3
- * `approval` — gates protected tool calls behind a UI confirmation widget.
4
+ * Gates protected tool calls behind a UI confirmation widget.
4
5
  *
5
- * This is a simplified version that intercepts specified actions (default: bash)
6
- * and requires user approval before they are allowed to proceed.
6
+ * Wraps `execute` for the listed actions: emit an approval widget, wait for
7
+ * the click via `host.awaitWidgetResponse`, then run the inner execute or
8
+ * return a denial result.
7
9
  */
8
- // In-memory tracking for pending approval IDs with TTL (shared across plugin instances)
9
10
  const pendingApprovals = new Map();
10
11
  const TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
11
- export const approvalPlugin = {
12
- id: 'approval',
13
- name: 'Approval',
14
- description: 'Gate protected tool calls behind a UI confirmation widget.',
15
- factory: ({ config, storage }) => (builder) => {
16
- // Actions that require approval. Defaults to bash.
17
- const actionsToApprove = config.actions || ['action:shell_exec'];
18
- for (const action of actionsToApprove) {
19
- builder.intercept(action, (event, context) => {
20
- // If already approved in this flow, let it pass to the actual handler
21
- if (event.meta?.approvalStatus === 'approved')
22
- return event;
23
- // Otherwise, intercept and ask for approval via a UI widget
24
- const displayData = JSON.stringify(event?.data) || '';
12
+ function actionToolName(action) {
13
+ return action.startsWith('action:') ? action.slice('action:'.length) : action;
14
+ }
15
+ function normalizeResult(result) {
16
+ return typeof result === 'string' ? { output: result } : result;
17
+ }
18
+ async function emitApprovalWidget(execCtx, args) {
19
+ const threadId = execCtx.threadId ?? execCtx.state.threadId;
20
+ await execCtx.emit({
21
+ type: 'client:ui:widget',
22
+ data: {
23
+ widgetId: args.widgetId,
24
+ kind: 'message',
25
+ title: args.title,
26
+ body: args.body,
27
+ ...(args.state ? { state: args.state } : {}),
28
+ ...(args.state && args.state !== 'open'
29
+ ? { display: 'collapsed', disabled: true, actions: [] }
30
+ : { actions: args.actions }),
31
+ metadata: {
32
+ type: 'approval:request',
33
+ originalEvent: {
34
+ type: args.action,
35
+ data: args.toolArgs,
36
+ meta: {
37
+ toolCallId: execCtx.toolCallId,
38
+ agentId: execCtx.agentId,
39
+ threadId,
40
+ },
41
+ },
42
+ },
43
+ },
44
+ meta: { agentId: execCtx.agentId, threadId },
45
+ });
46
+ }
47
+ /**
48
+ * Wrap `execute` on tools whose names appear in `actionsToApprove`
49
+ * (bare name or `action:<name>`).
50
+ */
51
+ export function wrapToolsWithApproval(tools, actionsToApprove, ctx) {
52
+ if (!actionsToApprove.length)
53
+ return tools;
54
+ const namesToWrap = new Set(actionsToApprove.map(actionToolName));
55
+ const wrapped = { ...tools };
56
+ for (const [name, definition] of Object.entries(tools)) {
57
+ if (!namesToWrap.has(name) || !definition.execute)
58
+ continue;
59
+ const inner = definition.execute;
60
+ const action = `action:${name}`;
61
+ wrapped[name] = {
62
+ ...definition,
63
+ async execute(args, execCtx) {
64
+ const displayData = JSON.stringify(args ?? '') || '';
25
65
  const widgetId = randomUUID();
26
66
  pendingApprovals.set(widgetId, Date.now());
27
- return {
28
- type: 'client:ui:widget',
29
- data: {
67
+ await emitApprovalWidget(execCtx, {
68
+ widgetId,
69
+ action,
70
+ toolArgs: args,
71
+ title: `The agent wants to perform \`${action}\``,
72
+ body: displayData,
73
+ actions: [
74
+ { id: 'approve', label: 'Approve', variant: 'primary' },
75
+ { id: 'deny', label: 'Deny', variant: 'danger' },
76
+ ],
77
+ });
78
+ let response;
79
+ try {
80
+ response = await execCtx.host.awaitWidgetResponse(widgetId, execCtx.abortSignal);
81
+ }
82
+ catch (error) {
83
+ pendingApprovals.delete(widgetId);
84
+ const message = error instanceof Error ? error.message : 'Approval interrupted';
85
+ await emitApprovalWidget(execCtx, {
30
86
  widgetId,
31
- kind: 'message',
32
- title: `The agent wants to perform \`${action}\``,
87
+ action,
88
+ toolArgs: args,
89
+ title: 'Action Denied',
33
90
  body: displayData,
34
- metadata: {
35
- type: 'approval:request',
36
- originalEvent: event,
37
- },
38
- actions: [
39
- { id: 'approve', label: 'Approve', variant: 'primary' },
40
- { id: 'deny', label: 'Deny', variant: 'danger' },
41
- ],
42
- },
43
- meta: { agentId: context.state.agentId, threadId: context.state.threadId },
44
- };
45
- });
46
- }
47
- // Handle the user's response from the UI widget
48
- builder.on('client:ui:widget:response', async function* (event, context) {
49
- const { widgetId, actionId } = event.data;
50
- const metadata = event.data?.metadata;
51
- if (metadata?.type !== 'approval:request')
52
- return;
53
- // Verify the widget is still pending and hasn't expired
54
- if (!widgetId || !pendingApprovals.has(widgetId)) {
55
- console.warn(`[approval] Received response for unknown or already handled widget: ${widgetId}`);
56
- return;
57
- }
58
- const timestamp = pendingApprovals.get(widgetId);
59
- if (Date.now() - timestamp > TTL_MS) {
91
+ state: 'cancelled',
92
+ });
93
+ return { success: false, error: message, output: message };
94
+ }
95
+ const timestamp = pendingApprovals.get(widgetId);
60
96
  pendingApprovals.delete(widgetId);
61
- console.warn(`[approval] Received response for expired widget: ${widgetId}`);
62
- return;
63
- }
64
- // Mark as handled
65
- pendingApprovals.delete(widgetId);
66
- const originalEvent = metadata.originalEvent;
67
- const approved = actionId === 'approve';
68
- const displayData = JSON.stringify(event?.data) || '';
69
- // Yield a "responded" widget update to the UI
70
- yield {
71
- type: 'client:ui:widget',
72
- data: {
97
+ if (timestamp == null) {
98
+ console.warn(`[approval] Received response for unknown or already handled widget: ${widgetId}`);
99
+ return {
100
+ success: false,
101
+ error: 'Approval request is no longer pending.',
102
+ output: 'Approval request is no longer pending.',
103
+ };
104
+ }
105
+ if (Date.now() - timestamp > TTL_MS) {
106
+ console.warn(`[approval] Received response for expired widget: ${widgetId}`);
107
+ return {
108
+ success: false,
109
+ error: 'Approval request expired.',
110
+ output: 'Approval request expired.',
111
+ };
112
+ }
113
+ const approved = response.actionId === 'approve';
114
+ await emitApprovalWidget(execCtx, {
73
115
  widgetId,
74
- kind: 'message',
116
+ action,
117
+ toolArgs: args,
75
118
  title: `Action ${approved ? 'Approved' : 'Denied'}`,
76
119
  body: displayData,
77
120
  state: approved ? 'submitted' : 'cancelled',
78
- display: 'collapsed',
79
- disabled: true,
80
- actions: [], // Clear actions to disable buttons in UI
81
- },
82
- meta: { agentId: context.state.agentId, threadId: context.state.threadId },
83
- };
84
- if (approved) {
85
- // Re-emit the original event with approved status so the actual handler can run
86
- yield {
87
- ...originalEvent,
121
+ });
122
+ if (approved) {
123
+ return normalizeResult(await inner(args, execCtx));
124
+ }
125
+ const threadId = execCtx.threadId ?? execCtx.state.threadId;
126
+ const originalEvent = {
127
+ type: action,
128
+ data: args,
88
129
  meta: {
89
- ...(originalEvent.meta || {}),
90
- approvalStatus: 'approved',
130
+ toolCallId: execCtx.toolCallId,
131
+ agentId: execCtx.agentId,
132
+ threadId,
133
+ approvalStatus: 'denied',
91
134
  },
92
135
  };
93
- }
94
- else {
95
- // Manually store the original event with denied status so it's recorded in history
96
- // but NOT re-emitted to the pipeline (to avoid actual execution).
97
- if (storage) {
136
+ const storage = ctx.storage;
137
+ if (storage.storeEvent) {
98
138
  await storage.storeEvent({
99
- channelId: context.state.channelId,
100
- threadId: context.state.threadId,
101
- event: {
102
- ...originalEvent,
103
- meta: {
104
- ...(originalEvent.meta || {}),
105
- approvalStatus: 'denied',
106
- },
107
- },
139
+ channelId: execCtx.channelId ?? execCtx.state.channelId ?? '',
140
+ threadId,
141
+ event: originalEvent,
108
142
  });
109
143
  }
110
- // Emit a failure result event for the denied action to clear the pending tool batch
111
- yield {
112
- type: `${originalEvent.type}:result`,
113
- data: {
114
- success: false,
115
- error: 'Action denied by user.',
116
- stderr: 'Action denied by user.',
117
- output: 'Action denied by user.',
118
- },
119
- meta: originalEvent.meta,
120
- };
121
- yield {
122
- type: 'agent:output',
123
- data: { content: `Action \`${originalEvent.type}\` was denied.` },
124
- meta: { agentId: context.state.agentId },
144
+ await execCtx.emit(agentOutput({
145
+ agentId: execCtx.agentId,
146
+ threadId,
147
+ content: `Action \`${action}\` was denied.`,
148
+ }));
149
+ return {
150
+ success: false,
151
+ error: 'Action denied by user.',
152
+ stderr: 'Action denied by user.',
153
+ output: 'Action denied by user.',
125
154
  };
126
- }
127
- });
128
- },
129
- };
130
- export default approvalPlugin;
155
+ },
156
+ };
157
+ }
158
+ return wrapped;
159
+ }
@@ -1,6 +1,11 @@
1
1
  import { z } from "zod";
2
2
  import { randomUUID } from "node:crypto";
3
- const askAgentToolDefinitions = {
3
+ /**
4
+ * `ask_agent` — OpenBot sends a message to another agent in its own harness.
5
+ * Only the orchestrator may ask. Child events stream into this thread so the
6
+ * human sees the specialist speak; the child's last message is the tool result.
7
+ */
8
+ export const toolDefinitions = {
4
9
  ask_agent: {
5
10
  description: "Ask another installed agent to do work in their own harness. Write the prompt as a message to a colleague, not as a command to a tool. The human sees their reply in this thread; do not restate it when you close.",
6
11
  inputSchema: z.object({
@@ -11,29 +16,30 @@ const askAgentToolDefinitions = {
11
16
  }),
12
17
  },
13
18
  };
14
- async function* runAskedAgent(pluginContext, event, context, resultType) {
15
- if (context.state.agentId !== pluginContext.host.orchestratorAgentId) {
16
- yield {
17
- type: resultType,
18
- data: {
19
- success: false,
20
- error: "Only OpenBot can ask other agents.",
21
- },
22
- meta: event.meta,
19
+ async function runAskedAgent(ctx, args) {
20
+ if (ctx.agentId !== ctx.host.orchestratorAgentId) {
21
+ return {
22
+ success: false,
23
+ error: "Only OpenBot can ask other agents.",
24
+ output: "Only OpenBot can ask other agents.",
25
+ };
26
+ }
27
+ const agentId = args.agentId;
28
+ const prompt = args.prompt;
29
+ const toolCallId = ctx.toolCallId;
30
+ if (!agentId || !prompt || !toolCallId) {
31
+ return {
32
+ success: false,
33
+ error: "agentId, prompt, and toolCallId are required",
34
+ output: "agentId, prompt, and toolCallId are required",
23
35
  };
24
- return;
25
36
  }
26
- const agentId = event.data.agentId;
27
- const prompt = event.data.prompt;
28
- const toolCallId = event.meta?.toolCallId;
29
- if (!agentId || !prompt || !toolCallId)
30
- return;
31
37
  const runId = `ask_${randomUUID()}`;
32
38
  let lastAgentOutput = "";
33
39
  const eventQueue = [];
34
40
  let resolveNext = null;
35
41
  let isFinished = false;
36
- const runPromise = pluginContext.host
42
+ const runPromise = ctx.host
37
43
  .runAgent({
38
44
  runId,
39
45
  agentId,
@@ -45,20 +51,20 @@ async function* runAskedAgent(pluginContext, event, context, resultType) {
45
51
  agentId,
46
52
  },
47
53
  meta: {
48
- channelId: context.state.channelId,
49
- threadId: context.state.threadId,
50
- parentAgentId: context.state.agentId,
54
+ channelId: ctx.channelId ?? ctx.state.channelId ?? '',
55
+ threadId: ctx.threadId ?? ctx.state.threadId,
56
+ parentAgentId: ctx.agentId,
51
57
  parentToolCallId: toolCallId,
52
58
  },
53
59
  },
54
- publicBaseUrl: pluginContext.publicBaseUrl,
60
+ publicBaseUrl: ctx.publicBaseUrl,
55
61
  persistEvents: false,
56
62
  onEvent: async (outEvent) => {
57
63
  const enrichedEvent = {
58
64
  ...outEvent,
59
65
  meta: {
60
66
  ...outEvent.meta,
61
- parentAgentId: context.state.agentId,
67
+ parentAgentId: ctx.agentId,
62
68
  parentToolCallId: toolCallId,
63
69
  },
64
70
  };
@@ -89,35 +95,21 @@ async function* runAskedAgent(pluginContext, event, context, resultType) {
89
95
  });
90
96
  }
91
97
  while (eventQueue.length > 0) {
92
- yield eventQueue.shift();
98
+ await ctx.emit(eventQueue.shift());
93
99
  }
94
100
  }
95
101
  await runPromise;
96
- yield {
97
- type: resultType,
98
- data: {
99
- success: true,
100
- output: lastAgentOutput,
101
- },
102
- meta: {
103
- ...event.meta,
104
- agentId: context.state.agentId,
105
- toolCallId,
106
- },
102
+ return {
103
+ success: true,
104
+ output: lastAgentOutput,
107
105
  };
108
106
  }
109
- export const askAgentPlugin = {
110
- id: "ask-agent",
111
- name: "Ask agent",
112
- description: "Lets OpenBot ask specialized agents to do work in their own harness.",
113
- toolDefinitions: askAgentToolDefinitions,
114
- factory: (pluginContext) => (builder) => {
115
- builder.on("action:ask_agent", async function* (event, context) {
116
- yield* runAskedAgent(pluginContext, event, context, "action:ask_agent:result");
117
- });
118
- builder.on("action:delegate_task", async function* (event, context) {
119
- yield* runAskedAgent(pluginContext, event, context, "action:delegate_task:result");
120
- });
107
+ export const tools = {
108
+ ask_agent: {
109
+ ...toolDefinitions.ask_agent,
110
+ execute: async (rawArgs, ctx) => {
111
+ return runAskedAgent(ctx, (rawArgs ?? {}));
112
+ },
121
113
  },
122
114
  };
123
- export default askAgentPlugin;
115
+ export const askAgentTools = tools;
@@ -1,9 +1,8 @@
1
1
  import z from 'zod';
2
- import { asActionBuilder } from '../types.js';
3
2
  /**
4
3
  * Resolve a scope alias to a concrete scope string. Aliases let tools accept
5
- * `agent`/`channel`/`global` without knowing the active ids; the bus rewrites
6
- * them using `context.state`.
4
+ * `agent`/`channel`/`global` without knowing the active ids; they are rewritten
5
+ * using execute context state.
7
6
  */
8
7
  function resolveMemoryScope(alias, state) {
9
8
  switch (alias) {
@@ -24,11 +23,11 @@ function resolveMemoryScopeFilter(alias, state) {
24
23
  }
25
24
  return [resolveMemoryScope(alias, state)];
26
25
  }
27
- /**
28
- * `memory` exposes the global memory store as agent tools and provides
29
- * platform-level memory handlers.
30
- */
31
- const memoryToolDefinitions = {
26
+ function fail(error) {
27
+ const message = error instanceof Error ? error.message : 'Unknown error';
28
+ return { success: false, error: message, output: message };
29
+ }
30
+ export const toolDefinitions = {
32
31
  remember: {
33
32
  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.',
34
33
  inputSchema: z.object({
@@ -74,90 +73,59 @@ const memoryToolDefinitions = {
74
73
  }),
75
74
  },
76
75
  };
77
- export const memoryPlugin = {
78
- id: 'memory',
79
- name: 'Memory',
80
- description: 'Global long-term memory: remember/recall/forget facts across runs and agents.',
81
- toolDefinitions: memoryToolDefinitions,
82
- factory: ({ storage }) => (builder) => {
83
- const store = storage;
84
- const actions = asActionBuilder(builder);
85
- actions.on('remember', async function* (event, context) {
86
- const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
76
+ export const tools = {
77
+ remember: {
78
+ ...toolDefinitions.remember,
79
+ execute: async (rawArgs, ctx) => {
87
80
  try {
88
- const { content, scope, tags } = event.data;
89
- const record = await store.appendMemory({
90
- scope: resolveMemoryScope(scope, context.state),
81
+ const { content, scope, tags } = (rawArgs ?? {});
82
+ const record = await ctx.storage.appendMemory({
83
+ scope: resolveMemoryScope(scope, ctx.state),
91
84
  content,
92
85
  tags,
93
86
  });
94
- yield {
95
- type: 'action:remember:result',
96
- data: { success: true, record },
97
- meta: resultMeta,
98
- };
87
+ return { success: true, record, output: JSON.stringify(record) };
99
88
  }
100
89
  catch (error) {
101
- yield {
102
- type: 'action:remember:result',
103
- data: {
104
- success: false,
105
- error: error instanceof Error ? error.message : 'Unknown error',
106
- },
107
- meta: resultMeta,
108
- };
90
+ return fail(error);
109
91
  }
110
- });
111
- actions.on('recall', async function* (event, context) {
112
- const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
92
+ },
93
+ },
94
+ recall: {
95
+ ...toolDefinitions.recall,
96
+ execute: async (rawArgs, ctx) => {
113
97
  try {
114
- const { query, tag, scope, limit } = event.data;
115
- const records = await store.listMemories({
116
- scopes: resolveMemoryScopeFilter(scope, context.state),
98
+ const { query, tag, scope, limit } = (rawArgs ?? {});
99
+ const records = await ctx.storage.listMemories({
100
+ scopes: resolveMemoryScopeFilter(scope, ctx.state),
117
101
  query,
118
102
  tag,
119
103
  limit,
120
104
  });
121
- yield {
122
- type: 'action:recall:result',
123
- data: { success: true, records },
124
- meta: resultMeta,
125
- };
105
+ return { success: true, records, output: JSON.stringify(records) };
126
106
  }
127
107
  catch (error) {
128
- yield {
129
- type: 'action:recall:result',
130
- data: {
131
- success: false,
132
- records: [],
133
- error: error instanceof Error ? error.message : 'Unknown error',
134
- },
135
- meta: resultMeta,
136
- };
108
+ return { ...fail(error), records: [] };
137
109
  }
138
- });
139
- actions.on('forget', async function* (event, context) {
140
- const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
110
+ },
111
+ },
112
+ forget: {
113
+ ...toolDefinitions.forget,
114
+ execute: async (rawArgs, ctx) => {
141
115
  try {
142
- const deleted = await store.deleteMemory({ id: event.data.id });
143
- yield {
144
- type: 'action:forget:result',
145
- data: { success: true, deleted },
146
- meta: resultMeta,
116
+ const deleted = await ctx.storage.deleteMemory({
117
+ id: (rawArgs ?? {}).id,
118
+ });
119
+ return {
120
+ success: true,
121
+ deleted,
122
+ output: deleted ? 'Memory deleted.' : 'Memory not found.',
147
123
  };
148
124
  }
149
125
  catch (error) {
150
- yield {
151
- type: 'action:forget:result',
152
- data: {
153
- success: false,
154
- deleted: false,
155
- error: error instanceof Error ? error.message : 'Unknown error',
156
- },
157
- meta: resultMeta,
158
- };
126
+ return { ...fail(error), deleted: false };
159
127
  }
160
- });
128
+ },
161
129
  },
162
130
  };
163
- export default memoryPlugin;
131
+ export const memoryTools = tools;