@meetopenbot/openbot 1.6.2 → 1.7.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.
@@ -8,6 +8,7 @@ import { threadStatusTools } from "./tools/thread-status.js";
8
8
  import { threadTitleTools } from "./tools/thread-title.js";
9
9
  import { previewTools } from "./tools/preview.js";
10
10
  import { variablesRequestTools } from "./tools/variables-request.js";
11
+ import { renderWidgetTools } from "./tools/render-widget.js";
11
12
  import { wrapToolsWithApproval } from "./tools/approval.js";
12
13
  export const DEFAULT_APPROVAL_ACTIONS = ["update_channel_spec", "start_thread"];
13
14
  /**
@@ -26,6 +27,7 @@ export function buildOpenbotTools(args) {
26
27
  ...threadTitleTools,
27
28
  ...previewTools,
28
29
  ...variablesRequestTools,
30
+ ...renderWidgetTools,
29
31
  ...args.context.tools,
30
32
  };
31
33
  const approvalConfig = args.context.config?.approval;
package/dist/history.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { compactRenderWidgetInput, renderedWidgetReplyText } from './tools/render-widget.js';
1
2
  function asReasoningPart(part) {
2
3
  const text = typeof part.text === 'string' ? part.text : '';
3
4
  if (!text.trim() && !part.providerOptions)
@@ -201,6 +202,12 @@ export function eventsToModelMessages(events, options) {
201
202
  }
202
203
  break;
203
204
  }
205
+ case 'client:ui:widget:response': {
206
+ const reply = renderedWidgetReplyText(event.data);
207
+ if (reply)
208
+ messages.push({ role: 'user', content: reply });
209
+ break;
210
+ }
204
211
  default:
205
212
  // Handle tool calls (action:*)
206
213
  if (event.type.startsWith('action:') && !event.type.endsWith(':result')) {
@@ -212,7 +219,9 @@ export function eventsToModelMessages(events, options) {
212
219
  type: 'tool-call',
213
220
  toolCallId,
214
221
  toolName,
215
- input: event.data,
222
+ input: toolName === 'render_widget'
223
+ ? compactRenderWidgetInput(event.data)
224
+ : event.data,
216
225
  };
217
226
  const last = messages[messages.length - 1];
218
227
  if (last && last.role === 'assistant') {
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ import { threadStatusTools } from './tools/thread-status.js';
12
12
  import { threadTitleTools } from './tools/thread-title.js';
13
13
  import { previewTools } from './tools/preview.js';
14
14
  import { variablesRequestTools } from './tools/variables-request.js';
15
+ import { renderWidgetTools } from './tools/render-widget.js';
15
16
  import { buildOpenbotTools } from './build-tools.js';
16
17
  export const OPENBOT_PLUGIN_ID = '@meetopenbot/openbot';
17
18
  const modelField = await modelConfigField({
@@ -53,6 +54,7 @@ export const openbotPlugin = defineOpenbotPlugin({
53
54
  ...threadTitleTools,
54
55
  ...previewTools,
55
56
  ...variablesRequestTools,
57
+ ...renderWidgetTools,
56
58
  },
57
59
  async *run(args) {
58
60
  const { context } = args;
package/dist/runtime.js CHANGED
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { generateText, hasToolCall, streamText, stepCountIs } from 'ai';
3
3
  import { BYOK_ENV_KEYS, isAuthErrorMessage, isCreditsErrorMessage, fetchModelRegistry, getProviderModelOptions, listApiKeyProviders, PROVIDER_API_KEY_LINKS, } from '@meetopenbot/plugin-sdk';
4
4
  import { eventsToModelMessages } from './history.js';
5
+ import { RENDER_WIDGET_METADATA_TYPE, settledRenderedWidget } from './tools/render-widget.js';
5
6
  import { addAttachmentParts, chooseTurnCandidates, eventsHaveAttachments, modelSupportsImages, } from './images.js';
6
7
  import { buildContext } from './context.js';
7
8
  import { OPENBOT_SYSTEM_PROMPT, withGithubGitInstructions, withUserInstructions, } from './system-prompt.js';
@@ -438,6 +439,18 @@ export async function* handleOpenbotWidgetResponse(args, options) {
438
439
  const { host, storage } = options;
439
440
  let configuredModelString = options.model ?? AUTO_MODEL_ID;
440
441
  const state = args.state;
442
+ // A click on a card the model rendered is the human's next message: the
443
+ // harness already stored it, and history replays it as a user turn.
444
+ if (metadata?.type === RENDER_WIDGET_METADATA_TYPE) {
445
+ if (storage) {
446
+ const events = await storage.getEvents({ channelId: state.channelId ?? '', threadId });
447
+ const settled = settledRenderedWidget(events, event.data);
448
+ if (settled)
449
+ yield settled;
450
+ }
451
+ yield* runOpenbotTurn(args, options);
452
+ return;
453
+ }
441
454
  if (host.isCloudSystemAgent(state.agentId) && options.authMode === 'credits')
442
455
  return;
443
456
  if (metadata?.type === 'api_provider_selection') {
@@ -459,7 +472,6 @@ export async function* handleOpenbotWidgetResponse(args, options) {
459
472
  body: `${label} provider was selected.`,
460
473
  state: 'submitted',
461
474
  display: 'collapsed',
462
- disabled: true,
463
475
  actions: [],
464
476
  },
465
477
  meta: { agentId: state.agentId, threadId },
@@ -536,7 +548,6 @@ export async function* handleOpenbotWidgetResponse(args, options) {
536
548
  body: `${modelId} was selected.`,
537
549
  state: 'submitted',
538
550
  display: 'collapsed',
539
- disabled: true,
540
551
  actions: [],
541
552
  },
542
553
  meta: { agentId: state.agentId, threadId },
@@ -28,6 +28,12 @@ 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
+ "# WIDGETS",
32
+ "- Call `render_widget` when your own answer is structured: 3+ similar records (Spaces, agents, options, statuses), a side-by-side comparison, a decision with fixed choices, or a few fields you need from the human. Use plain text for everything else.",
33
+ "- Never use it to restate or re-format what a specialist already said in this thread; that rule from Close still applies.",
34
+ "- Do not repeat the widget's contents in text. A click or form submission arrives as the human's next message, quoting what they picked.",
35
+ "- Forms never ask for secrets; use `request_variables` for those.",
36
+ "",
31
37
  "# JOB TITLE",
32
38
  "- Unnamed jobs get a system hint to call `set_thread_title` once with a concise, human-readable title.",
33
39
  "- Call `set_thread_title` again only if the topic clearly changes.",
@@ -54,6 +60,7 @@ export const OPENBOT_SYSTEM_PROMPT = [
54
60
  "- You see an image only when it is shown to you. You cannot open other files (PDF, documents, spreadsheets, archives, code).",
55
61
  "- Never claim you read a file or saw an image you were not shown.",
56
62
  "- When a coding agent should use a file, `ask_agent` them with the exact full path(s) and ask them to open the file there. Do not paraphrase or shorten the path.",
63
+ "- When one agent's result is a file another agent needs (for example a draft a coding agent wrote that Resend should send), pass the path exactly as the first agent reported it and ask the next agent to read it there. Do not paste or retype the file's contents yourself.",
57
64
  "- If an image was not shown to you and no specialist fits, say the current model cannot view images and suggest picking a vision-capable model.",
58
65
  "",
59
66
  "# CLOUD COMPUTER",
@@ -0,0 +1,317 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import z from 'zod';
3
+ import { uiWidgetIssue, } from '@meetopenbot/plugin-sdk';
4
+ /**
5
+ * `render_widget` — the coordinator shows structured data as an inline card
6
+ * instead of markdown. Clicks and form submits come back as a new turn
7
+ * (`handleOpenbotWidgetResponse` → `history.ts`), tagged by this metadata type.
8
+ */
9
+ export const RENDER_WIDGET_METADATA_TYPE = 'render_widget';
10
+ const MAX_COLUMNS = 12;
11
+ const MAX_ROWS = 200;
12
+ const MAX_ITEMS = 50;
13
+ const MAX_ACTIONS = 6;
14
+ /** Ids the client runs itself (navigate, open_url, …); a model action must not collide. */
15
+ const RESERVED_ACTION_IDS = new Set(['open_preview', 'open_url', 'navigate', 'start_in_space']);
16
+ const actionSchema = z.object({
17
+ id: z.string().min(1).max(64).describe('Stable id returned to you when clicked, e.g. "approve".'),
18
+ label: z.string().min(1).max(40).describe('Button text.'),
19
+ variant: z.enum(['primary', 'secondary', 'danger']).optional(),
20
+ });
21
+ const cellSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
22
+ const inputSchema = z.object({
23
+ kind: z
24
+ .enum(['table', 'list', 'choice', 'form', 'media'])
25
+ .describe('table: rows × columns of data. list: records with status/badge and optional per-item buttons. choice: a question answered with buttons. form: structured input. media: images/videos/files.'),
26
+ title: z.string().min(1).max(120),
27
+ description: z.string().max(400).optional().describe('One short line under the title.'),
28
+ summary: z
29
+ .string()
30
+ .min(1)
31
+ .max(400)
32
+ .describe('One or two sentences on what the widget shows. Kept in history in place of the full data, so include what you will need to refer back to.'),
33
+ columns: z
34
+ .array(z.object({
35
+ id: z.string().min(1),
36
+ label: z.string(),
37
+ align: z.enum(['left', 'right', 'center']).optional().describe('Use right for numbers.'),
38
+ }))
39
+ .max(MAX_COLUMNS)
40
+ .optional()
41
+ .describe('table only.'),
42
+ rows: z
43
+ .array(z.array(cellSchema))
44
+ .max(MAX_ROWS)
45
+ .optional()
46
+ .describe('table only. Each row lists one cell per column, in column order.'),
47
+ items: z
48
+ .array(z.object({
49
+ id: z.string().min(1),
50
+ label: z.string().min(1),
51
+ description: z.string().optional(),
52
+ badge: z.string().optional(),
53
+ status: z.string().optional(),
54
+ statusVariant: z.enum(['default', 'success', 'warning', 'danger', 'info']).optional(),
55
+ actions: z.array(actionSchema).max(3).optional(),
56
+ }))
57
+ .max(MAX_ITEMS)
58
+ .optional()
59
+ .describe('list only.'),
60
+ fields: z
61
+ .array(z.object({
62
+ id: z.string().min(1),
63
+ label: z.string().min(1),
64
+ type: z.enum(['text', 'textarea', 'number', 'boolean', 'select', 'multiselect', 'date']),
65
+ description: z.string().optional(),
66
+ placeholder: z.string().optional(),
67
+ required: z.boolean().optional(),
68
+ options: z.array(z.object({ label: z.string(), value: z.string() })).optional(),
69
+ }))
70
+ .max(12)
71
+ .optional()
72
+ .describe('form only. Never ask for secrets.'),
73
+ submitLabel: z.string().max(40).optional().describe('form only.'),
74
+ media: z
75
+ .array(z.object({
76
+ type: z.enum(['image', 'video', 'audio', 'file']),
77
+ url: z.string().url(),
78
+ title: z.string().optional(),
79
+ alt: z.string().optional(),
80
+ }))
81
+ .max(20)
82
+ .optional()
83
+ .describe('media only. Public https URLs.'),
84
+ actions: z
85
+ .array(actionSchema)
86
+ .max(MAX_ACTIONS)
87
+ .optional()
88
+ .describe('Buttons under the widget. Required for choice.'),
89
+ });
90
+ function actionIdError(actions = []) {
91
+ const reserved = actions.find((action) => RESERVED_ACTION_IDS.has(action.id));
92
+ return reserved ? `Action id "${reserved.id}" is reserved; pick another id.` : null;
93
+ }
94
+ function labelsById(input) {
95
+ const labels = {};
96
+ for (const action of input.actions ?? [])
97
+ labels[action.id] = action.label;
98
+ for (const item of input.items ?? []) {
99
+ for (const action of item.actions ?? [])
100
+ labels[action.id] = action.label;
101
+ }
102
+ return labels;
103
+ }
104
+ /** Validate the flat tool input for its kind and map it onto the shared widget shape. */
105
+ export function buildRenderedWidget(input, widgetId) {
106
+ const base = {
107
+ widgetId,
108
+ title: input.title,
109
+ ...(input.description ? { description: input.description } : {}),
110
+ display: 'expanded',
111
+ state: 'open',
112
+ metadata: {
113
+ type: RENDER_WIDGET_METADATA_TYPE,
114
+ title: input.title,
115
+ summary: input.summary,
116
+ labels: labelsById(input),
117
+ },
118
+ };
119
+ const actions = input.actions?.length ? input.actions : undefined;
120
+ const reserved = actionIdError(input.actions) ??
121
+ actionIdError((input.items ?? []).flatMap((item) => item.actions ?? []));
122
+ if (reserved)
123
+ return { error: reserved };
124
+ let widget;
125
+ switch (input.kind) {
126
+ case 'table': {
127
+ const columns = input.columns ?? [];
128
+ const rows = input.rows ?? [];
129
+ if (columns.length === 0)
130
+ return { error: 'table needs at least one column.' };
131
+ const badRow = rows.findIndex((row) => row.length !== columns.length);
132
+ if (badRow !== -1) {
133
+ return {
134
+ error: `table row ${badRow} has ${rows[badRow].length} cells; expected ${columns.length} (one per column).`,
135
+ };
136
+ }
137
+ widget = {
138
+ ...base,
139
+ kind: 'table',
140
+ columns,
141
+ rows: rows.map((row) => Object.fromEntries(columns.map((column, i) => [column.id, row[i]]))),
142
+ ...(actions ? { actions } : {}),
143
+ };
144
+ break;
145
+ }
146
+ case 'list': {
147
+ if (!input.items?.length)
148
+ return { error: 'list needs at least one item.' };
149
+ widget = {
150
+ ...base,
151
+ kind: 'list',
152
+ items: input.items.map((item) => ({
153
+ ...item,
154
+ ...(item.actions?.length
155
+ ? {
156
+ actions: item.actions.map((action) => ({
157
+ ...action,
158
+ value: { itemId: item.id, itemLabel: item.label },
159
+ })),
160
+ }
161
+ : {}),
162
+ })),
163
+ ...(actions ? { actions } : {}),
164
+ };
165
+ break;
166
+ }
167
+ case 'choice':
168
+ if (!actions)
169
+ return { error: 'choice needs at least one action.' };
170
+ widget = { ...base, kind: 'choice', actions };
171
+ break;
172
+ case 'form':
173
+ if (!input.fields?.length)
174
+ return { error: 'form needs at least one field.' };
175
+ widget = {
176
+ ...base,
177
+ kind: 'form',
178
+ fields: input.fields,
179
+ ...(input.submitLabel ? { submitLabel: input.submitLabel } : {}),
180
+ };
181
+ break;
182
+ case 'media':
183
+ if (!input.media?.length)
184
+ return { error: 'media needs at least one item.' };
185
+ widget = {
186
+ ...base,
187
+ kind: 'media',
188
+ items: input.media,
189
+ layout: input.media.length > 1 ? 'grid' : 'single',
190
+ ...(actions ? { actions } : {}),
191
+ };
192
+ break;
193
+ }
194
+ const issue = uiWidgetIssue(widget);
195
+ return issue ? { error: issue } : { widget };
196
+ }
197
+ function asRecord(value) {
198
+ return value && typeof value === 'object' && !Array.isArray(value)
199
+ ? value
200
+ : undefined;
201
+ }
202
+ /**
203
+ * How a click on a `render_widget` card reads in the model's history, or
204
+ * undefined for widget responses that came from anything else.
205
+ */
206
+ export function renderedWidgetReplyText(data) {
207
+ const metadata = asRecord(data.metadata);
208
+ if (metadata?.type !== RENDER_WIDGET_METADATA_TYPE)
209
+ return undefined;
210
+ const title = typeof metadata.title === 'string' ? metadata.title : 'widget';
211
+ const values = { ...(data.values ?? {}) };
212
+ if (data.actionId === 'submit' && !asRecord(metadata.labels)?.submit) {
213
+ return `[Widget "${title}"] Submitted: ${JSON.stringify(values)}`;
214
+ }
215
+ const label = asRecord(metadata.labels)?.[data.actionId];
216
+ const clicked = typeof label === 'string' ? label : data.actionId;
217
+ const itemLabel = values.itemLabel;
218
+ delete values.itemId;
219
+ delete values.itemLabel;
220
+ const target = typeof itemLabel === 'string' ? ` on "${itemLabel}"` : '';
221
+ const extra = Object.keys(values).length > 0 ? ` with ${JSON.stringify(values)}` : '';
222
+ return `[Widget "${title}"] Clicked "${clicked}"${target}${extra} (action id: ${data.actionId})`;
223
+ }
224
+ /**
225
+ * A choice or form answers once: re-emit it as submitted, with the picked
226
+ * button highlighted or the submitted values filled in. Lists, tables and
227
+ * media stay open for further clicks.
228
+ */
229
+ export function settledRenderedWidget(events, response) {
230
+ for (let i = events.length - 1; i >= 0; i--) {
231
+ const event = events[i];
232
+ if (event.type !== 'client:ui:widget' || event.data.widgetId !== response.widgetId)
233
+ continue;
234
+ const widget = event.data;
235
+ if (widget.metadata?.type !== RENDER_WIDGET_METADATA_TYPE)
236
+ return undefined;
237
+ if (widget.kind === 'choice') {
238
+ return {
239
+ ...event,
240
+ data: {
241
+ ...widget,
242
+ state: 'submitted',
243
+ actions: widget.actions.map((action) => ({
244
+ ...action,
245
+ variant: action.id === response.actionId ? 'primary' : 'secondary',
246
+ })),
247
+ },
248
+ };
249
+ }
250
+ if (widget.kind === 'form') {
251
+ const values = response.values ?? {};
252
+ return {
253
+ ...event,
254
+ data: {
255
+ ...widget,
256
+ state: 'submitted',
257
+ fields: widget.fields.map((field) => field.id in values ? { ...field, defaultValue: values[field.id] } : field),
258
+ },
259
+ };
260
+ }
261
+ return undefined;
262
+ }
263
+ return undefined;
264
+ }
265
+ /** History stand-in for a `render_widget` call: what it showed, without the data. */
266
+ export function compactRenderWidgetInput(input) {
267
+ const record = asRecord(input);
268
+ if (!record)
269
+ return input;
270
+ const { kind, title, summary } = record;
271
+ return { kind, title, summary };
272
+ }
273
+ function isInteractive(widget) {
274
+ if (widget.kind === 'choice' || widget.kind === 'form')
275
+ return true;
276
+ if (widget.actions?.length)
277
+ return true;
278
+ return widget.kind === 'list' && widget.items.some((item) => item.actions?.length);
279
+ }
280
+ async function renderWidget(rawArgs, ctx) {
281
+ const parsed = inputSchema.safeParse(rawArgs ?? {});
282
+ if (!parsed.success) {
283
+ const message = parsed.error.issues[0]?.message ?? 'Invalid render_widget input.';
284
+ return { success: false, error: message, output: message };
285
+ }
286
+ const threadId = ctx.threadId ?? ctx.state.threadId;
287
+ const built = buildRenderedWidget(parsed.data, ctx.toolCallId ?? `render:${randomUUID()}`);
288
+ if ('error' in built) {
289
+ return { success: false, error: built.error, output: built.error };
290
+ }
291
+ await ctx.emit({
292
+ type: 'client:ui:widget',
293
+ data: built.widget,
294
+ meta: {
295
+ agentId: ctx.agentId,
296
+ ...(threadId ? { threadId } : {}),
297
+ ...(ctx.toolCallId ? { toolCallId: ctx.toolCallId } : {}),
298
+ },
299
+ });
300
+ const output = isInteractive(built.widget)
301
+ ? 'Shown to the human. Do not repeat its contents in text. Their click or submission arrives as their next message; end your turn unless you have something new to add.'
302
+ : 'Shown to the human. Do not repeat its contents in text; add only a short takeaway if useful.';
303
+ return { success: true, widgetId: built.widget.widgetId, output };
304
+ }
305
+ export const toolDefinitions = {
306
+ render_widget: {
307
+ description: 'Show structured data to the human as an inline card instead of markdown: a table, a list of records with status, a choice with buttons, a short form, or media. Use it when the answer is a collection of 3+ similar records, a comparison, or a decision or input you need from the human. Do not use it for prose, single facts, or to restate what a specialist already said in this thread.',
308
+ inputSchema,
309
+ },
310
+ };
311
+ export const tools = {
312
+ render_widget: {
313
+ ...toolDefinitions.render_widget,
314
+ execute: renderWidget,
315
+ },
316
+ };
317
+ export const renderWidgetTools = tools;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meetopenbot/openbot",
3
- "version": "1.6.2",
3
+ "version": "1.7.0",
4
4
  "description": "OpenBot coordinator runtime: ask specialists, route work into Spaces, and track todos.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -25,7 +25,7 @@
25
25
  "@ai-sdk/openai": "^4.0.72",
26
26
  "ai": "^7.0.111",
27
27
  "zod": "^4.3.5",
28
- "@meetopenbot/plugin-sdk": "^2.1.0"
28
+ "@meetopenbot/plugin-sdk": "^2.3.0"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "^20.10.1",