@meetopenbot/openbot 1.2.19 → 1.3.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.
@@ -6,7 +6,6 @@ import { installAgentTools } from "./tools/install-agent.js";
6
6
  import { threadStatusTools } from "./tools/thread-status.js";
7
7
  import { threadTitleTools } from "./tools/thread-title.js";
8
8
  import { previewTools } from "./tools/preview.js";
9
- import { suggestSpaceTools } from "./tools/suggest-space.js";
10
9
  import { wrapToolsWithApproval } from "./tools/approval.js";
11
10
  export const DEFAULT_APPROVAL_ACTIONS = ["update_channel_spec"];
12
11
  /**
@@ -23,7 +22,6 @@ export function buildOpenbotTools(args) {
23
22
  ...threadStatusTools,
24
23
  ...threadTitleTools,
25
24
  ...previewTools,
26
- ...suggestSpaceTools,
27
25
  ...args.context.tools,
28
26
  };
29
27
  const approvalConfig = args.context.config?.approval;
package/dist/context.js CHANGED
@@ -2,6 +2,7 @@ import { formatGitIdentity, readGitIdentity, } from "@meetopenbot/plugin-sdk";
2
2
  import { formatAvailableAgentsSection, formatInstalledAgentsSection, loadAgentModelCatalogs, availableMarketplaceAgents, } from "./agent-models.js";
3
3
  import { GENERAL_SPACE_ID } from "./space-id.js";
4
4
  import { todoService } from "./tools/todo-service.js";
5
+ import { readInvokeSurface, surfaceSection } from "./surface.js";
5
6
  export const DEFAULT_CONTEXT_BUDGET = 8000;
6
7
  export const MAX_CONTEXT_FILES = 50;
7
8
  /**
@@ -30,6 +31,8 @@ export const getContextBudgetForModel = (modelString) => {
30
31
  export async function buildContext(state, storage, host) {
31
32
  const { channelId, threadId, channelDetails, agentId, threadDetails, } = state;
32
33
  const sections = [];
34
+ // Inline asks skip routing context (marketplace, other Spaces): they never move work.
35
+ const surface = readInvokeSurface(state.triggerEvent);
33
36
  // Fetch agents once if storage is available
34
37
  const allAgents = storage?.getAgents
35
38
  ? await storage.getAgents().catch(() => [])
@@ -118,7 +121,7 @@ export async function buildContext(state, storage, host) {
118
121
  sections.push(formatInstalledAgentsSection(otherAgents, catalogs));
119
122
  }
120
123
  // 2.7 Marketplace agents not yet installed
121
- if (host?.listMarketplaceAgents) {
124
+ if (!surface && host?.listMarketplaceAgents) {
122
125
  try {
123
126
  const marketplace = await host.listMarketplaceAgents();
124
127
  const available = availableMarketplaceAgents(allAgents, marketplace);
@@ -130,7 +133,7 @@ export async function buildContext(state, storage, host) {
130
133
  console.warn("[context] Failed to fetch marketplace agents:", error);
131
134
  }
132
135
  }
133
- if (storage?.getChannels) {
136
+ if (!surface && storage?.getChannels) {
134
137
  try {
135
138
  const spaces = (await storage.getChannels());
136
139
  if (spaces.length > 0) {
@@ -213,5 +216,7 @@ export async function buildContext(state, storage, host) {
213
216
  console.warn("[context] Failed to fetch memories:", error);
214
217
  }
215
218
  }
219
+ if (surface)
220
+ sections.push(surfaceSection(surface));
216
221
  return sections.join("\n\n");
217
222
  }
@@ -0,0 +1,177 @@
1
+ import { creditsProviderBaseUrl, resolveByokApiKey, resolveCreditsAuthConfig, shouldUseCreditsAuth, INTEGRATIONS_TOKEN_HEADER, } from '@meetopenbot/plugin-sdk';
2
+ import { isAutoModel } from './auto-model.js';
3
+ import { GENERAL_SPACE_ID } from './space-id.js';
4
+ import { isManuallyNamedThread } from './thread-naming.js';
5
+ /** Decisions model. Public beta supports only this id. */
6
+ export const DECISION_MODEL = 'gpt-6-luna';
7
+ /**
8
+ * Forward only when the predicate is confident the message is not OpenBot's
9
+ * to answer. The middle of the range falls through to the coordinator.
10
+ */
11
+ export const SELF_ANSWER_FORWARD_MAX = 0.3;
12
+ const DECISION_TIMEOUT_MS = 2500;
13
+ const OPENAI_DECISIONS_URL = 'https://api.openai.com/v1/decisions';
14
+ const SELF_ANSWER_QUESTION = 'openbot_should_answer';
15
+ export const FAST_FORWARD_CLOSE_HINT = [
16
+ '## THIS TURN',
17
+ 'You already asked the specialist. Do not call ask_agent again.',
18
+ 'If they finished, close in one or two sentences and set ready_for_review.',
19
+ 'If they are blocked on the human, set needs_input.',
20
+ 'If they failed, say what blocked them.',
21
+ 'Do not restate a successful reply.',
22
+ ].join('\n');
23
+ function eventData(event) {
24
+ const data = event.data;
25
+ return data && typeof data === 'object' ? data : undefined;
26
+ }
27
+ function isUserInvoke(event) {
28
+ if (event.meta?.parentToolCallId)
29
+ return false;
30
+ if (event.type !== 'agent:invoke')
31
+ return false;
32
+ return eventData(event)?.role === 'user';
33
+ }
34
+ /**
35
+ * Auto, dedicated Space, first user message, exactly one installed @mention,
36
+ * no attachments. Anything else stays on the coordinator.
37
+ */
38
+ export function selectFastForwardCandidate(input) {
39
+ if (!isAutoModel(input.model))
40
+ return undefined;
41
+ if (!input.channelId || input.channelId === GENERAL_SPACE_ID)
42
+ return undefined;
43
+ const userMessages = input.events.filter(isUserInvoke);
44
+ if (userMessages.length !== 1)
45
+ return undefined;
46
+ const message = userMessages[0];
47
+ const data = eventData(message);
48
+ const attachments = data?.attachments;
49
+ if (Array.isArray(attachments) && attachments.length > 0)
50
+ return undefined;
51
+ const mentioned = Array.isArray(data?.mentionedAgentIds)
52
+ ? data.mentionedAgentIds.filter((id) => typeof id === 'string' && id.length > 0)
53
+ : [];
54
+ if (mentioned.length !== 1)
55
+ return undefined;
56
+ const agentId = mentioned[0];
57
+ if (agentId === input.orchestratorAgentId)
58
+ return undefined;
59
+ const agent = input.installedAgents.find((item) => item.id === agentId);
60
+ if (!agent)
61
+ return undefined;
62
+ const prompt = typeof data?.content === 'string' ? data.content.trim() : '';
63
+ if (!prompt)
64
+ return undefined;
65
+ const alreadyAsked = input.events.some((event) => !event.meta?.parentToolCallId &&
66
+ (event.type === 'action:ask_agent' || event.type === 'action:delegate_task'));
67
+ if (alreadyAsked)
68
+ return undefined;
69
+ return { agentId, agentName: agent.name || agentId, prompt };
70
+ }
71
+ export function threadNeedsGeneratedTitle(state) {
72
+ const record = state && typeof state === 'object' ? state : {};
73
+ return record.isSmartNamed !== true && !isManuallyNamedThread(state);
74
+ }
75
+ export function sanitizeThreadTitle(raw) {
76
+ const line = raw.split('\n')[0]?.trim() ?? '';
77
+ const unquoted = line.replace(/^["'`]+|["'`]+$/g, '').trim();
78
+ const title = unquoted.replace(/\s+/g, ' ').slice(0, 80).trim();
79
+ return title || undefined;
80
+ }
81
+ /** Where to POST `/v1/decisions`. Credits when that mode is on, otherwise OpenAI BYOK. */
82
+ export function resolveDecisionEndpoint(options) {
83
+ if (shouldUseCreditsAuth({ authMode: options.authMode })) {
84
+ const config = resolveCreditsAuthConfig();
85
+ if (!config)
86
+ return undefined;
87
+ return {
88
+ url: `${creditsProviderBaseUrl(config, 'openai')}/decisions`,
89
+ headers: {
90
+ Authorization: `Bearer ${config.token}`,
91
+ [INTEGRATIONS_TOKEN_HEADER]: config.token,
92
+ 'Content-Type': 'application/json',
93
+ },
94
+ };
95
+ }
96
+ const apiKey = resolveByokApiKey('openai', [], options.env ?? process.env);
97
+ if (!apiKey)
98
+ return undefined;
99
+ return {
100
+ url: OPENAI_DECISIONS_URL,
101
+ headers: {
102
+ Authorization: `Bearer ${apiKey}`,
103
+ 'Content-Type': 'application/json',
104
+ },
105
+ };
106
+ }
107
+ export function interpretHandoffDecision(body) {
108
+ const answers = body && typeof body === 'object' && Array.isArray(body.answers)
109
+ ? body.answers
110
+ : [];
111
+ const answer = answers.find((item) => item &&
112
+ typeof item === 'object' &&
113
+ item.name === SELF_ANSWER_QUESTION);
114
+ if (!answer)
115
+ return { forward: false, reason: 'no decision answer' };
116
+ if (answer.type === 'refusal')
117
+ return { forward: false, reason: 'decision refused' };
118
+ if (answer.type !== 'predicate' || typeof answer.probability !== 'number') {
119
+ return { forward: false, reason: 'decision was not a probability' };
120
+ }
121
+ if (!Number.isFinite(answer.probability)) {
122
+ return { forward: false, reason: 'decision was not a probability' };
123
+ }
124
+ if (answer.probability > SELF_ANSWER_FORWARD_MAX) {
125
+ return {
126
+ forward: false,
127
+ probability: answer.probability,
128
+ reason: `self-answer ${answer.probability}`,
129
+ };
130
+ }
131
+ return { forward: true, probability: answer.probability, reason: 'forward' };
132
+ }
133
+ function decisionInput(agentName, agentId, prompt) {
134
+ return `Specialist: ${agentName} (${agentId})\n\nMessage:\n${prompt}`;
135
+ }
136
+ /**
137
+ * One predicate: should OpenBot answer this instead of the mentioned specialist?
138
+ * Failures and low confidence return `forward: false` so the normal turn runs.
139
+ */
140
+ export async function classifySpecialistHandoff(args) {
141
+ if (args.signal?.aborted)
142
+ return { forward: false, reason: 'aborted' };
143
+ const endpoint = resolveDecisionEndpoint({ authMode: args.authMode, env: args.env });
144
+ if (!endpoint)
145
+ return { forward: false, reason: 'no decisions auth' };
146
+ const timeout = AbortSignal.timeout(args.timeoutMs ?? DECISION_TIMEOUT_MS);
147
+ const signal = args.signal ? AbortSignal.any([args.signal, timeout]) : timeout;
148
+ const fetchImpl = args.fetchImpl ?? fetch;
149
+ try {
150
+ const response = await fetchImpl(endpoint.url, {
151
+ method: 'POST',
152
+ headers: endpoint.headers,
153
+ signal,
154
+ body: JSON.stringify({
155
+ model: DECISION_MODEL,
156
+ input: decisionInput(args.agentName, args.agentId, args.prompt),
157
+ questions: [
158
+ {
159
+ type: 'predicate',
160
+ name: SELF_ANSWER_QUESTION,
161
+ instructions: 'The human @mentioned one specialist. High probability if OpenBot should answer this itself: a question about the workspace, people, setup, status, or Spaces, or small talk that is not a task for that specialist. Low probability if the message is work, a change, or a question that specialist should handle.',
162
+ },
163
+ ],
164
+ }),
165
+ });
166
+ if (!response.ok) {
167
+ return { forward: false, reason: `decision HTTP ${response.status}` };
168
+ }
169
+ return interpretHandoffDecision(await response.json());
170
+ }
171
+ catch (error) {
172
+ if (args.signal?.aborted)
173
+ return { forward: false, reason: 'aborted' };
174
+ const message = error instanceof Error ? error.message : String(error);
175
+ return { forward: false, reason: message || 'decision failed' };
176
+ }
177
+ }
package/dist/index.js CHANGED
@@ -11,7 +11,6 @@ import { installAgentTools } from './tools/install-agent.js';
11
11
  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
- import { suggestSpaceTools } from './tools/suggest-space.js';
15
14
  import { buildOpenbotTools } from './build-tools.js';
16
15
  export const OPENBOT_PLUGIN_ID = '@meetopenbot/openbot';
17
16
  const modelField = await modelConfigField({
@@ -52,7 +51,6 @@ export const openbotPlugin = defineOpenbotPlugin({
52
51
  ...threadStatusTools,
53
52
  ...threadTitleTools,
54
53
  ...previewTools,
55
- ...suggestSpaceTools,
56
54
  },
57
55
  async *run(args) {
58
56
  const { context } = args;
package/dist/runtime.js CHANGED
@@ -1,14 +1,17 @@
1
- import { streamText, stepCountIs } from 'ai';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { generateText, hasToolCall, streamText, stepCountIs } from 'ai';
2
3
  import { BYOK_ENV_KEYS, isAuthErrorMessage, isCreditsErrorMessage, fetchModelRegistry, getProviderModelOptions, listApiKeyProviders, PROVIDER_API_KEY_LINKS, } from '@meetopenbot/plugin-sdk';
3
4
  import { eventsToModelMessages } from './history.js';
4
5
  import { addAttachmentParts, chooseTurnCandidates, eventsHaveAttachments, modelSupportsImages, } from './images.js';
5
6
  import { buildContext } from './context.js';
6
7
  import { OPENBOT_SYSTEM_PROMPT, withGithubGitInstructions, withUserInstructions, } from './system-prompt.js';
7
- import { AUTO_MODEL_ID, isAutoModel, isRetryableModelError, missingByokApiKeyMessage, } from './auto-model.js';
8
+ import { AUTO_MODEL_ID, isAutoModel, isRetryableModelError, missingByokApiKeyMessage, pickAutoModel, } from './auto-model.js';
9
+ import { classifySpecialistHandoff, FAST_FORWARD_CLOSE_HINT, sanitizeThreadTitle, selectFastForwardCandidate, threadNeedsGeneratedTitle, } from './fast-forward.js';
8
10
  import { reasoningForModel, resolveModel } from './model.js';
9
11
  import { errorFromStreamPart } from './stream-error.js';
10
12
  import { createAgentOutputBuffer, isOutputBoundaryPart } from './output-buffer.js';
11
13
  import { threadNamingHint } from './thread-naming.js';
14
+ import { readInvokeSurface, RESPOND_TOOL, surfaceTools } from './surface.js';
12
15
  async function buildSystemPrompt(state, storage, instructions, host) {
13
16
  const context = await buildContext(state, storage, host);
14
17
  const sections = [
@@ -16,8 +19,10 @@ async function buildSystemPrompt(state, storage, instructions, host) {
16
19
  '',
17
20
  context,
18
21
  ];
19
- const threadState = state.threadDetails?.state;
20
- sections.push(...threadNamingHint(threadState));
22
+ if (!readInvokeSurface(state.triggerEvent)) {
23
+ const threadState = state.threadDetails?.state;
24
+ sections.push(...threadNamingHint(threadState));
25
+ }
21
26
  return sections.join('\n');
22
27
  }
23
28
  function toAiTools(tools, args, flushPendingOutput) {
@@ -86,8 +91,145 @@ function toAiTools(tools, args, flushPendingOutput) {
86
91
  }
87
92
  return mapped;
88
93
  }
94
+ async function executeCoordinatorTool(args, tools, name, input) {
95
+ const def = tools[name];
96
+ if (!def?.execute)
97
+ return;
98
+ const toolCallId = `ff_${randomUUID()}`;
99
+ const threadId = args.threadId;
100
+ const agentId = args.context.agentId;
101
+ await args.emit({
102
+ type: `action:${name}`,
103
+ data: input,
104
+ meta: { toolCallId, agentId, threadId },
105
+ });
106
+ try {
107
+ const result = await def.execute(input, {
108
+ agentId,
109
+ channelId: args.state.channelId,
110
+ threadId,
111
+ runId: args.state.runId,
112
+ toolCallId,
113
+ abortSignal: args.context.abortSignal,
114
+ storage: args.context.storage,
115
+ host: args.context.host,
116
+ publicBaseUrl: args.context.publicBaseUrl,
117
+ config: args.context.config,
118
+ state: args.state,
119
+ emit: args.emit,
120
+ });
121
+ const data = typeof result === 'string'
122
+ ? { success: true, output: result }
123
+ : { success: result.success !== false, ...result };
124
+ await args.emit({
125
+ type: `action:${name}:result`,
126
+ data,
127
+ meta: { toolCallId, agentId, threadId },
128
+ });
129
+ }
130
+ catch (error) {
131
+ const message = error instanceof Error ? error.message : String(error);
132
+ await args.emit({
133
+ type: `action:${name}:result`,
134
+ data: { success: false, error: message, output: message },
135
+ meta: { toolCallId, agentId, threadId },
136
+ });
137
+ }
138
+ }
139
+ async function suggestThreadTitle(args) {
140
+ const model = pickAutoModel({ authMode: args.authMode });
141
+ const result = await generateText({
142
+ model: resolveModel(model, { authMode: args.authMode }),
143
+ system: 'Name this job. Reply with a concise human title of at most 6 words. No quotes.',
144
+ prompt: args.prompt,
145
+ maxOutputTokens: 24,
146
+ abortSignal: args.signal,
147
+ reasoning: reasoningForModel(model),
148
+ });
149
+ return sanitizeThreadTitle(result.text);
150
+ }
151
+ /**
152
+ * On Auto, a first message in a dedicated Space that @mentions one installed
153
+ * agent can skip the coordinator and ask that specialist immediately.
154
+ * Returns true when the specialist already ran and the caller should close.
155
+ */
156
+ async function* attemptFastForward(args, options) {
157
+ if (args.context.agentId !== options.host.orchestratorAgentId)
158
+ return false;
159
+ if (!options.tools.ask_agent?.execute || options.abortSignal?.aborted)
160
+ return false;
161
+ let installed = [];
162
+ try {
163
+ const agents = await options.storage.getAgents?.();
164
+ if (Array.isArray(agents)) {
165
+ installed = agents.flatMap((agent) => {
166
+ if (!agent || typeof agent.id !== 'string' || !agent.id)
167
+ return [];
168
+ const name = typeof agent.name === 'string' && agent.name.trim() ? agent.name.trim() : agent.id;
169
+ return [{ id: agent.id, name }];
170
+ });
171
+ }
172
+ }
173
+ catch (error) {
174
+ console.warn('[openbot] fast-forward could not list agents', error);
175
+ return false;
176
+ }
177
+ const candidate = selectFastForwardCandidate({
178
+ model: options.model,
179
+ channelId: args.state.channelId,
180
+ orchestratorAgentId: options.host.orchestratorAgentId,
181
+ installedAgents: installed,
182
+ events: options.events,
183
+ });
184
+ if (!candidate)
185
+ return false;
186
+ const decision = await classifySpecialistHandoff({
187
+ prompt: candidate.prompt,
188
+ agentId: candidate.agentId,
189
+ agentName: candidate.agentName,
190
+ authMode: options.authMode,
191
+ env: args.context.env,
192
+ signal: options.abortSignal,
193
+ });
194
+ if (!decision.forward) {
195
+ console.info(`[openbot] fast-forward skipped: ${decision.reason}`);
196
+ return false;
197
+ }
198
+ console.info(`[openbot] fast-forward ${candidate.agentId} (self-answer ${decision.probability ?? 'n/a'})`);
199
+ yield {
200
+ type: 'agent:output',
201
+ data: { content: `Asking ${candidate.agentName}.` },
202
+ meta: { agentId: args.context.agentId, threadId: args.threadId },
203
+ };
204
+ await executeCoordinatorTool(args, options.tools, 'set_thread_status', { status: 'working' });
205
+ const titleTask = (async () => {
206
+ const threadState = args.state.threadDetails?.state;
207
+ if (!threadNeedsGeneratedTitle(threadState))
208
+ return;
209
+ const title = await suggestThreadTitle({
210
+ prompt: candidate.prompt,
211
+ authMode: options.authMode,
212
+ signal: options.abortSignal,
213
+ });
214
+ if (!title || options.abortSignal?.aborted)
215
+ return;
216
+ await executeCoordinatorTool(args, options.tools, 'set_thread_title', { title });
217
+ })().catch((error) => {
218
+ console.warn('[openbot] fast-forward title failed', error);
219
+ });
220
+ await executeCoordinatorTool(args, options.tools, 'ask_agent', {
221
+ agentId: candidate.agentId,
222
+ prompt: candidate.prompt,
223
+ });
224
+ await titleTask;
225
+ return true;
226
+ }
89
227
  export async function* runOpenbotTurn(args, options) {
90
- const { model: modelString = AUTO_MODEL_ID, authMode = 'byok', instructions, storage, toolDefinitions = {}, abortSignal, host, } = options;
228
+ const { model: modelString = AUTO_MODEL_ID, authMode = 'byok', instructions, storage, toolDefinitions: allToolDefinitions = {}, abortSignal, host, } = options;
229
+ // Asked inline from a UI surface: no thread history, a narrowed tool set, and
230
+ // the run ends when OpenBot calls `respond`.
231
+ const surface = readInvokeSurface(args.event);
232
+ const toolDefinitions = surface ? surfaceTools(allToolDefinitions) : allToolDefinitions;
91
233
  let configuredModelString = modelString;
92
234
  const isCreditsCloudAgent = (id) => host.isCloudSystemAgent(id ?? '') && authMode === 'credits';
93
235
  if (!storage || abortSignal?.aborted)
@@ -95,11 +237,13 @@ export async function* runOpenbotTurn(args, options) {
95
237
  const state = args.state;
96
238
  state.model = configuredModelString;
97
239
  const threadId = args.threadId || state.threadId;
98
- const systemPrompt = await buildSystemPrompt(state, storage, instructions, host);
99
- const events = await storage.getEvents({
100
- channelId: state.channelId ?? '',
101
- threadId,
102
- });
240
+ let systemPrompt = await buildSystemPrompt(state, storage, instructions, host);
241
+ let events = surface
242
+ ? [args.event]
243
+ : await storage.getEvents({
244
+ channelId: state.channelId ?? '',
245
+ threadId,
246
+ });
103
247
  const hasAttachments = eventsHaveAttachments(events);
104
248
  const candidates = chooseTurnCandidates({ model: configuredModelString, events, authMode });
105
249
  const readImage = async (name) => {
@@ -136,6 +280,24 @@ export async function* runOpenbotTurn(args, options) {
136
280
  const missingKey = missingByokApiKeyMessage(candidates[0], { authMode });
137
281
  if (missingKey)
138
282
  throw new Error(missingKey);
283
+ const forwarded = !surface && (yield* attemptFastForward(args, {
284
+ model: configuredModelString,
285
+ authMode,
286
+ storage,
287
+ tools: toolDefinitions,
288
+ events: events,
289
+ abortSignal,
290
+ host,
291
+ }));
292
+ if (abortSignal?.aborted)
293
+ return;
294
+ if (forwarded) {
295
+ events = await storage.getEvents({
296
+ channelId: state.channelId ?? '',
297
+ threadId,
298
+ });
299
+ systemPrompt = `${await buildSystemPrompt(state, storage, instructions, host)}\n\n${FAST_FORWARD_CLOSE_HINT}`;
300
+ }
139
301
  let lastError;
140
302
  let started = false;
141
303
  for (let i = 0; i < candidates.length; i++) {
@@ -165,7 +327,7 @@ export async function* runOpenbotTurn(args, options) {
165
327
  tools,
166
328
  abortSignal,
167
329
  reasoning: reasoningForModel(candidate),
168
- stopWhen: stepCountIs(20),
330
+ stopWhen: surface ? [stepCountIs(8), hasToolCall(RESPOND_TOOL)] : stepCountIs(20),
169
331
  allowSystemInMessages: true,
170
332
  });
171
333
  for await (const part of result.fullStream) {
@@ -0,0 +1,46 @@
1
+ import { z } from 'zod';
2
+ export const RESPOND_TOOL = 'respond';
3
+ /**
4
+ * Tools OpenBot keeps when asked inline from a UI surface. Anything that writes
5
+ * to a thread, a Space, or todos stays out: there is no thread, and the human
6
+ * did not ask for a job.
7
+ */
8
+ const SURFACE_TOOL_ALLOWLIST = new Set(['ask_agent', 'recall', RESPOND_TOOL]);
9
+ /** The surface an invoke came from, when it did not come from a thread. */
10
+ export function readInvokeSurface(event) {
11
+ const raw = event?.meta?.surface;
12
+ if (!raw || typeof raw.id !== 'string' || !raw.id)
13
+ return undefined;
14
+ return {
15
+ id: raw.id,
16
+ label: typeof raw.label === 'string' && raw.label ? raw.label : raw.id,
17
+ intent: typeof raw.intent === 'string' ? raw.intent : '',
18
+ };
19
+ }
20
+ const respondTool = {
21
+ description: 'Give the surface the value it asked for. Call exactly once, as your final action; the text is shown to the human as-is.',
22
+ inputSchema: z.object({
23
+ text: z.string().describe('The value itself, ready to use. No preamble or quotes.'),
24
+ }),
25
+ execute: () => 'Delivered.',
26
+ };
27
+ /** Narrows the coordinator's tools to the surface allowlist and adds `respond`. */
28
+ export function surfaceTools(tools) {
29
+ const out = {};
30
+ for (const [name, def] of Object.entries(tools)) {
31
+ if (SURFACE_TOOL_ALLOWLIST.has(name))
32
+ out[name] = def;
33
+ }
34
+ out[RESPOND_TOOL] = respondTool;
35
+ return out;
36
+ }
37
+ /** System-prompt section that switches OpenBot from thread mode to inline mode. */
38
+ export function surfaceSection(surface) {
39
+ return [
40
+ '## SURFACE',
41
+ `- You were asked inline from ${surface.label}, not in a thread. There is no job, thread status, title, or todo list here.`,
42
+ `- Task: ${surface.intent}`,
43
+ '- The context the surface sent is in the human message. Do this yourself — it is a small, direct ask — and use `ask_agent` only when the answer needs something you cannot see.',
44
+ `- Finish by calling \`${RESPOND_TOOL}\` once with the result. Do not write anything else.`,
45
+ ].join('\n');
46
+ }
@@ -15,7 +15,7 @@ export const OPENBOT_SYSTEM_PROMPT = [
15
15
  "# THE AGENT LOOP",
16
16
  "You operate in an iterative loop:",
17
17
  "1. **Analyze Events**: Understand the human, mentioned people, the current Space, and `## SPACES`.",
18
- "2. **Right Space**: Use `## SPACES` to see where work belongs. If the human's intent clearly fits a different existing Space than the one you are in — especially project work posted in `#general` — call `suggest_space` with that Space id, a self-contained version of their ask, and a one-line reason, then stop. Do not also explain it in prose, and do not `ask_agent` in this thread. Do not move or copy their message. In `#general`, handle questions, listings, setup, and routing only; do not run project execution there. If they are already in the right Space, continue with `ask_agent`. If no Space fits, propose a concrete name and why, then wait for the human to agree before `create_channel` — unless they already named a new Space or explicitly asked you to create it. Never invent placeholder ids (`noop`, `temp`, `misc`, `untitled`). Never call `suggest_space` for a Space that is not in `## SPACES`.",
18
+ "2. **Right Space**: Use `## SPACES` to see where work belongs. If the human's intent clearly fits a different existing Space than the one you are in — especially project work posted in `#general` — explain in text which Space it belongs in and why, then wait for the human to continue there. Do not move or copy their message. In `#general`, handle questions, listings, setup, and routing only; do not run project execution there. If they are already in the right Space, continue with `ask_agent`. If no Space fits, propose a concrete name and why, then wait for the human to agree before `create_channel` — unless they already named a new Space or explicitly asked you to create it. Never invent placeholder ids (`noop`, `temp`, `misc`, `untitled`).",
19
19
  "3. **Ask people**: Use `ask_agent` to send a message to a specialist. Write it as a request to a colleague, not as a tool invocation.",
20
20
  "4. **Wait**: Let the asked agent run in its own harness. Read their reply, then continue or ask someone else.",
21
21
  "5. **Close**: The human already saw the specialist speak in this thread. Do not restate, re-bullet, or paraphrase their findings. Name who you asked, then add only what is new — a decision, a conflict, a next step, or a caveat. If nothing is new, close in one or two sentences.",
@@ -42,7 +42,7 @@ export const OPENBOT_SYSTEM_PROMPT = [
42
42
  "- The current status is injected each turn as `## THREAD STATUS`.",
43
43
  "",
44
44
  "# OPERATIONAL GUIDELINES",
45
- '- **Spaces**: Work lives in Spaces (product name for channels). `#general` is dispatch — setup, questions, listings, routing, and "what should we do next." Project, coding, design, and ops work belongs in a dedicated Space. Prefer existing Spaces. When the human is in the wrong existing Space, call `suggest_space` and stop so they can edit the Space and the message, then send it as a new thread. Never auto-move threads or messages. Create a new Space only when the human asked for one, or they agreed after you suggested it.',
45
+ '- **Spaces**: Work lives in Spaces (product name for channels). `#general` is dispatch — setup, questions, listings, routing, and "what should we do next." Project, coding, design, and ops work belongs in a dedicated Space. Prefer existing Spaces. When the human is in the wrong existing Space, explain the appropriate Space in text and wait for them to continue there. Never auto-move threads or messages. Create a new Space only when the human asked for one, or they agreed after you suggested it.',
46
46
  "- **Asking**: You ask specialists with `ask_agent`. If `## MENTIONED AGENTS` is present, you MUST ask those people. You may also ask others if needed. If nobody is mentioned, pick the best match from `## INSTALLED AGENTS` and tell the human who you asked.",
47
47
  "- **Models**: When you `ask_agent`, pick `model` from that agent's list in `## INSTALLED AGENTS` using the descriptions — cheaper/faster for simple work, stronger for complex coding or reasoning. Omit `model` to use their default. Do not reuse another agent's ids.",
48
48
  "- **Closer**: After `ask_agent`, you are the closer, not a second author of their work. Don't repeat. Add or close. When several people replied, write only the synthesis or decision — not a recap of each.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meetopenbot/openbot",
3
- "version": "1.2.19",
3
+ "version": "1.3.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": "^1.3.0"
28
+ "@meetopenbot/plugin-sdk": "^1.4.0"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "^20.10.1",
@@ -1,115 +0,0 @@
1
- import z from 'zod';
2
- import { isPlaceholderSpaceId } from '../space-id.js';
3
- /** Widget metadata the client uses to render a Space handoff card. */
4
- export const SPACE_HANDOFF_TYPE = 'space_handoff';
5
- const MAX_MESSAGE_LENGTH = 8000;
6
- const MAX_REASON_LENGTH = 240;
7
- function normalizeChannelId(raw) {
8
- return raw
9
- .trim()
10
- .toLowerCase()
11
- .replace(/[^a-z0-9]+/g, '-')
12
- .replace(/(^-|-$)/g, '');
13
- }
14
- function spaceOptionLabel(space) {
15
- const name = space.name?.trim();
16
- if (!name || name.toLowerCase() === space.id)
17
- return `#${space.id}`;
18
- return `#${space.id} · ${name}`;
19
- }
20
- function failure(channelId, error) {
21
- return { success: false, channelId, error, output: error };
22
- }
23
- async function suggestSpace(rawArgs, ctx) {
24
- const data = (rawArgs ?? {});
25
- const channelId = normalizeChannelId(typeof data.channelId === 'string' ? data.channelId : '');
26
- const message = typeof data.message === 'string' ? data.message.trim() : '';
27
- const reason = typeof data.reason === 'string' ? data.reason.trim().slice(0, MAX_REASON_LENGTH) : '';
28
- const currentId = normalizeChannelId(ctx.channelId ?? ctx.state.channelId ?? '');
29
- if (!channelId)
30
- return failure('', 'channelId is required');
31
- if (isPlaceholderSpaceId(channelId)) {
32
- return failure(channelId, `Refused to suggest #${channelId}: that looks like a placeholder, not a real Space.`);
33
- }
34
- if (!message)
35
- return failure(channelId, 'message is required');
36
- if (message.length > MAX_MESSAGE_LENGTH) {
37
- return failure(channelId, `message must be ${MAX_MESSAGE_LENGTH} characters or fewer.`);
38
- }
39
- if (currentId && channelId === currentId) {
40
- return failure(channelId, `Already in #${channelId}. Continue here with ask_agent.`);
41
- }
42
- const channels = (await ctx.storage.getChannels());
43
- const target = channels.find((space) => normalizeChannelId(space.id) === channelId);
44
- if (!target) {
45
- return failure(channelId, `Space #${channelId} does not exist. Suggest a name and wait before create_channel.`);
46
- }
47
- const targetId = target.id;
48
- const options = channels
49
- .filter((space) => normalizeChannelId(space.id) !== currentId)
50
- .map((space) => ({ label: spaceOptionLabel(space), value: space.id }));
51
- const threadId = ctx.threadId ?? ctx.state.threadId;
52
- const description = reason || `This belongs in #${targetId}.`;
53
- await ctx.emit({
54
- type: 'client:ui:widget',
55
- data: {
56
- widgetId: ctx.toolCallId ?? `suggest_space:${targetId}`,
57
- kind: 'form',
58
- title: `Run this in #${targetId}`,
59
- description,
60
- fields: [
61
- {
62
- id: 'channelId',
63
- label: 'Space',
64
- type: 'select',
65
- required: true,
66
- defaultValue: targetId,
67
- options,
68
- },
69
- {
70
- id: 'message',
71
- label: 'Message',
72
- type: 'textarea',
73
- required: true,
74
- defaultValue: message,
75
- placeholder: 'What should OpenBot do there?',
76
- },
77
- ],
78
- actions: [
79
- {
80
- id: 'start_in_space',
81
- label: 'Send',
82
- variant: 'primary',
83
- value: { channelId: targetId },
84
- },
85
- ],
86
- display: 'expanded',
87
- state: 'open',
88
- metadata: { type: SPACE_HANDOFF_TYPE, channelId: targetId },
89
- },
90
- meta: {
91
- agentId: ctx.agentId,
92
- ...(threadId ? { threadId } : {}),
93
- },
94
- });
95
- const output = `Showed a handoff to #${targetId}. Stop. Do not ask_agent in this thread and do not explain the handoff in prose. The human sends the message there if they confirm.`;
96
- return { success: true, channelId: targetId, output };
97
- }
98
- export const suggestSpaceTools = {
99
- suggest_space: {
100
- description: 'When the human is in the wrong existing Space, show a card with that Space and a self-contained version of their ask. They can edit both, then send it as a new thread there. Call this and stop. Do not ask_agent in this thread. Only use a Space id from ## SPACES, never the current Space, and never a Space you have not created yet.',
101
- inputSchema: z.object({
102
- channelId: z
103
- .string()
104
- .describe('Existing Space id from ## SPACES. Not the Space you are in.'),
105
- message: z
106
- .string()
107
- .describe("The human's ask, rewritten so it stands alone in that Space. They can edit it before sending."),
108
- reason: z
109
- .string()
110
- .optional()
111
- .describe('One line on why this Space fits. Shown on the card.'),
112
- }),
113
- execute: suggestSpace,
114
- },
115
- };