@meetopenbot/openbot 1.7.0 → 1.9.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.
@@ -3,14 +3,25 @@ import { todoTools } from "./tools/todo.js";
3
3
  import { storageTools } from "./tools/storage.js";
4
4
  import { startThreadTools } from "./tools/start-thread.js";
5
5
  import { askAgentTools } from "./tools/ask-agent.js";
6
+ import { askUserTools } from "./tools/ask-user.js";
6
7
  import { installAgentTools } from "./tools/install-agent.js";
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
11
  import { renderWidgetTools } from "./tools/render-widget.js";
12
12
  import { wrapToolsWithApproval } from "./tools/approval.js";
13
+ import { isConversationThread } from "./conversation.js";
13
14
  export const DEFAULT_APPROVAL_ACTIONS = ["update_channel_spec", "start_thread"];
15
+ /**
16
+ * A Space conversation answers inline or starts tasks; specialist work, todos,
17
+ * and job title/status belong to the task.
18
+ */
19
+ export const CONVERSATION_EXCLUDED_TOOLS = [
20
+ "ask_agent",
21
+ "todo_write",
22
+ "todo_read",
23
+ "set_thread_title",
24
+ ];
14
25
  /**
15
26
  * Merge first-party OpenBot coordinator tools and wrap configured actions
16
27
  * with approval.
@@ -22,8 +33,8 @@ export function buildOpenbotTools(args) {
22
33
  ...storageTools,
23
34
  ...startThreadTools,
24
35
  ...askAgentTools,
36
+ ...askUserTools,
25
37
  ...installAgentTools,
26
- ...threadStatusTools,
27
38
  ...threadTitleTools,
28
39
  ...previewTools,
29
40
  ...variablesRequestTools,
@@ -31,5 +42,12 @@ export function buildOpenbotTools(args) {
31
42
  ...args.context.tools,
32
43
  };
33
44
  const approvalConfig = args.context.config?.approval;
34
- return wrapToolsWithApproval(tools, approvalConfig?.actions ?? DEFAULT_APPROVAL_ACTIONS, args.context);
45
+ const conversation = isConversationThread(args.threadId);
46
+ if (conversation) {
47
+ for (const name of CONVERSATION_EXCLUDED_TOOLS)
48
+ delete tools[name];
49
+ }
50
+ // Starting tasks is what a Space conversation is for, so it does not ask first.
51
+ const actions = (approvalConfig?.actions ?? DEFAULT_APPROVAL_ACTIONS).filter((action) => !(action === "start_thread" && conversation));
52
+ return wrapToolsWithApproval(tools, actions, args.context);
35
53
  }
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 { todoService } from "./tools/todo-service.js";
4
4
  import { readInvokeSurface, surfaceSection } from "./surface.js";
5
+ import { CONVERSATION_INSTRUCTIONS, isConversationThread } from "./conversation.js";
5
6
  export const DEFAULT_CONTEXT_BUDGET = 8000;
6
7
  export const MAX_CONTEXT_FILES = 50;
7
8
  /**
@@ -63,23 +64,8 @@ export async function buildContext(state, storage, host) {
63
64
  env += `- Thread: ${threadDetails?.name || threadId}\n`;
64
65
  }
65
66
  sections.push(env);
66
- if (threadId) {
67
- const threadState = threadDetails?.state && typeof threadDetails.state === "object"
68
- ? threadDetails.state
69
- : undefined;
70
- const threadStatus = typeof threadState?.status === "string" && threadState.status.trim()
71
- ? threadState.status.trim()
72
- : "completed";
73
- const threadStatusReason = typeof threadState?.statusReason === "string" && threadState.statusReason.trim()
74
- ? threadState.statusReason.trim()
75
- : undefined;
76
- let statusSection = `## THREAD STATUS\n- Status: ${threadStatus}`;
77
- if (threadStatusReason) {
78
- statusSection += `\n- Reason: ${threadStatusReason}`;
79
- }
80
- statusSection +=
81
- "\nUse `set_thread_status` to update this. needs_input when blocked on the human; ready_for_review when you believe the job is done; working when the job is still in progress. Never set completed or archived.";
82
- sections.push(statusSection);
67
+ if (isConversationThread(threadId)) {
68
+ sections.push(CONVERSATION_INSTRUCTIONS);
83
69
  }
84
70
  const invokeData = state.triggerEvent?.type === "agent:invoke"
85
71
  ? state.triggerEvent.data
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Reserved thread that holds a Space's own conversation with OpenBot. The
3
+ * runtime keeps the same id; task listings skip it.
4
+ */
5
+ export const CONVERSATION_THREAD_ID = 'conversation';
6
+ export function isConversationThread(threadId) {
7
+ return threadId === CONVERSATION_THREAD_ID;
8
+ }
9
+ /** Added to a Space conversation's context: it is a chat, not a task. */
10
+ export const CONVERSATION_INSTRUCTIONS = [
11
+ '## CONVERSATION',
12
+ "This is the Space's main conversation with the human, not a task. It has no title and keeps going across many requests. The JOB TITLE rules do not apply here.",
13
+ '- **Answer inline** when the reply needs no specialist and takes no lasting work: questions, explanations, advice, short drafts, status of Spaces or tasks, quick lookups, and setup questions.',
14
+ '- **Start a task** with `start_thread` when the request needs a specialist (build, code, files, design, research, ops), runs long, or should be tracked on its own. Pick the Space it belongs to from `## SPACES`; the current Space is a valid target. You cannot ask specialists from here — the task does that.',
15
+ '- One request, one task. Several independent requests become several tasks. Follow-ups to an existing task belong in that task; tell the human to continue there instead of starting a duplicate.',
16
+ '- After `start_thread`, reply in one short sentence. The human sees a live card for the task; do not restate the request or promise progress updates.',
17
+ '- If you are unsure whether the human wants a task, ask in one line instead of starting one.',
18
+ ].join('\n');
19
+ /** Human turns kept verbatim when a Space conversation is replayed to the model. */
20
+ export const CONVERSATION_KEPT_TURNS = 20;
21
+ const DIGEST_MESSAGE_LIMIT = 30;
22
+ const DIGEST_TEXT_LIMIT = 200;
23
+ function isHumanTurn(event) {
24
+ if (event.type !== 'agent:invoke' || event.meta?.parentToolCallId)
25
+ return false;
26
+ return event.data?.role === 'user';
27
+ }
28
+ function clip(text) {
29
+ const flat = text.replace(/\s+/g, ' ').trim();
30
+ return flat.length > DIGEST_TEXT_LIMIT ? `${flat.slice(0, DIGEST_TEXT_LIMIT)}…` : flat;
31
+ }
32
+ function startedTask(event) {
33
+ if (event.type !== 'client:ui:widget')
34
+ return null;
35
+ const data = event.data;
36
+ if (data?.metadata?.type !== 'thread_started' || data.state !== 'submitted')
37
+ return null;
38
+ const { channelId, threadId, title } = data.metadata;
39
+ if (typeof channelId !== 'string' || typeof threadId !== 'string')
40
+ return null;
41
+ return { channelId, threadId, ...(typeof title === 'string' && title ? { title } : {}) };
42
+ }
43
+ /**
44
+ * Keeps the last {@link CONVERSATION_KEPT_TURNS} human turns (and everything
45
+ * after the first of them) and folds older history into a plain-text digest:
46
+ * what the human asked and which tasks were started. No model call.
47
+ */
48
+ export function compactConversation(events, keptTurns = CONVERSATION_KEPT_TURNS) {
49
+ const turnStarts = [];
50
+ events.forEach((event, index) => {
51
+ if (isHumanTurn(event))
52
+ turnStarts.push(index);
53
+ });
54
+ if (turnStarts.length <= keptTurns)
55
+ return { events, digest: '' };
56
+ const cut = turnStarts[turnStarts.length - keptTurns];
57
+ const dropped = events.slice(0, cut);
58
+ const asked = dropped
59
+ .filter(isHumanTurn)
60
+ .map((event) => clip(String(event.data.content ?? '')))
61
+ .filter(Boolean)
62
+ .slice(-DIGEST_MESSAGE_LIMIT);
63
+ const tasks = dropped
64
+ .map(startedTask)
65
+ .filter((task) => task !== null)
66
+ .slice(-DIGEST_MESSAGE_LIMIT);
67
+ const lines = [
68
+ '## EARLIER IN THIS CONVERSATION',
69
+ 'Older messages were compacted. This is what remains of them; ask the human if you need more.',
70
+ ];
71
+ if (asked.length > 0) {
72
+ lines.push('Human asked:', ...asked.map((text) => `- ${text}`));
73
+ }
74
+ if (tasks.length > 0) {
75
+ lines.push('Tasks started:', ...tasks.map((task) => `- ${task.title ?? 'Untitled'} — #${task.channelId}, thread ${task.threadId}`));
76
+ }
77
+ return { events: events.slice(cut), digest: lines.join('\n') };
78
+ }
@@ -14,8 +14,8 @@ const SELF_ANSWER_QUESTION = 'openbot_should_answer';
14
14
  export const FAST_FORWARD_CLOSE_HINT = [
15
15
  '## THIS TURN',
16
16
  'You already asked the specialist. Do not call ask_agent again.',
17
- 'If they finished, close in one or two sentences and set ready_for_review.',
18
- 'If they are blocked on the human, set needs_input.',
17
+ 'If they finished, close in one or two sentences.',
18
+ 'If they are blocked on the human, ask with `ask_user`.',
19
19
  'If they failed, say what blocked them.',
20
20
  'Do not restate a successful reply.',
21
21
  ].join('\n');
package/dist/index.js CHANGED
@@ -8,7 +8,6 @@ import { todoTools } from './tools/todo.js';
8
8
  import { storageTools } from './tools/storage.js';
9
9
  import { askAgentTools } from './tools/ask-agent.js';
10
10
  import { installAgentTools } from './tools/install-agent.js';
11
- import { threadStatusTools } from './tools/thread-status.js';
12
11
  import { threadTitleTools } from './tools/thread-title.js';
13
12
  import { previewTools } from './tools/preview.js';
14
13
  import { variablesRequestTools } from './tools/variables-request.js';
@@ -50,7 +49,6 @@ export const openbotPlugin = defineOpenbotPlugin({
50
49
  ...storageTools,
51
50
  ...askAgentTools,
52
51
  ...installAgentTools,
53
- ...threadStatusTools,
54
52
  ...threadTitleTools,
55
53
  ...previewTools,
56
54
  ...variablesRequestTools,
@@ -61,7 +59,7 @@ export const openbotPlugin = defineOpenbotPlugin({
61
59
  const authMode = context.host.isCloudSystemAgent(context.agentId)
62
60
  ? context.host.parseOpenbotAuthMode(context.config?.authMode)
63
61
  : 'byok';
64
- const toolDefinitions = buildOpenbotTools({ context });
62
+ const toolDefinitions = buildOpenbotTools({ context, threadId: args.threadId });
65
63
  yield* runOpenbotTurn(args, {
66
64
  model: context.config?.model || AUTO_MODEL_ID,
67
65
  authMode,
package/dist/runtime.js CHANGED
@@ -12,6 +12,7 @@ import { reasoningForModel, resolveModel } from './model.js';
12
12
  import { errorFromStreamPart } from './stream-error.js';
13
13
  import { createAgentOutputBuffer, isOutputBoundaryPart } from './output-buffer.js';
14
14
  import { threadNamingHint } from './thread-naming.js';
15
+ import { compactConversation, isConversationThread } from './conversation.js';
15
16
  import { readInvokeSurface, RESPOND_TOOL, surfaceTools } from './surface.js';
16
17
  async function buildSystemPrompt(state, storage, instructions, host) {
17
18
  const context = await buildContext(state, storage, host);
@@ -20,7 +21,8 @@ async function buildSystemPrompt(state, storage, instructions, host) {
20
21
  '',
21
22
  context,
22
23
  ];
23
- if (!readInvokeSurface(state.triggerEvent)) {
24
+ // A Space conversation is a chat, not a job: it never gets a title.
25
+ if (!readInvokeSurface(state.triggerEvent) && !isConversationThread(state.threadId)) {
24
26
  const threadState = state.threadDetails?.state;
25
27
  sections.push(...threadNamingHint(threadState));
26
28
  }
@@ -202,7 +204,6 @@ async function* attemptFastForward(args, options) {
202
204
  data: { content: `Asking ${candidate.agentName}.` },
203
205
  meta: { agentId: args.context.agentId, threadId: args.threadId },
204
206
  };
205
- await executeCoordinatorTool(args, options.tools, 'set_thread_status', { status: 'working' });
206
207
  const titleTask = (async () => {
207
208
  const threadState = args.state.threadDetails?.state;
208
209
  if (!threadNeedsGeneratedTitle(threadState))
@@ -239,12 +240,21 @@ export async function* runOpenbotTurn(args, options) {
239
240
  state.model = configuredModelString;
240
241
  const threadId = args.threadId || state.threadId;
241
242
  let systemPrompt = await buildSystemPrompt(state, storage, instructions, host);
243
+ // A Space conversation runs indefinitely; replay only its recent turns plus a digest.
244
+ const compactHistory = (loaded) => {
245
+ if (surface || !isConversationThread(threadId))
246
+ return loaded;
247
+ const compacted = compactConversation(loaded);
248
+ if (compacted.digest)
249
+ systemPrompt = `${systemPrompt}\n\n${compacted.digest}`;
250
+ return compacted.events;
251
+ };
242
252
  let events = surface
243
253
  ? [args.event]
244
- : await storage.getEvents({
254
+ : compactHistory(await storage.getEvents({
245
255
  channelId: state.channelId ?? '',
246
256
  threadId,
247
- });
257
+ }));
248
258
  const hasAttachments = eventsHaveAttachments(events);
249
259
  const candidates = chooseTurnCandidates({ model: configuredModelString, events, authMode });
250
260
  const readImage = async (name) => {
@@ -293,12 +303,27 @@ export async function* runOpenbotTurn(args, options) {
293
303
  if (abortSignal?.aborted)
294
304
  return;
295
305
  if (forwarded) {
296
- events = await storage.getEvents({
306
+ systemPrompt = `${await buildSystemPrompt(state, storage, instructions, host)}\n\n${FAST_FORWARD_CLOSE_HINT}`;
307
+ events = compactHistory(await storage.getEvents({
297
308
  channelId: state.channelId ?? '',
298
309
  threadId,
299
- });
300
- systemPrompt = `${await buildSystemPrompt(state, storage, instructions, host)}\n\n${FAST_FORWARD_CLOSE_HINT}`;
310
+ }));
301
311
  }
312
+ // Messages the human sent while this run is going. They join the conversation
313
+ // before the next model step, like a reply typed between tool calls, and stay in
314
+ // it for every later step (and a retry on another model).
315
+ const steered = [];
316
+ const takeSteering = !surface && !args.event?.meta?.parentToolCallId ? host.takeSteeringMessages : undefined;
317
+ const prepareStep = takeSteering
318
+ ? ({ messages }) => {
319
+ const known = new Set([...events, ...steered].map((event) => event.id).filter(Boolean));
320
+ const fresh = takeSteering().filter((event) => !event.id || !known.has(event.id));
321
+ if (fresh.length === 0)
322
+ return undefined;
323
+ steered.push(...fresh);
324
+ return { messages: [...messages, ...eventsToModelMessages(fresh)] };
325
+ }
326
+ : undefined;
302
327
  let lastError;
303
328
  let started = false;
304
329
  for (let i = 0; i < candidates.length; i++) {
@@ -311,7 +336,7 @@ export async function* runOpenbotTurn(args, options) {
311
336
  };
312
337
  const tools = toAiTools(toolDefinitions, args, flushPendingOutput);
313
338
  try {
314
- let messages = eventsToModelMessages(events);
339
+ let messages = eventsToModelMessages([...events, ...steered]);
315
340
  if (hasAttachments) {
316
341
  const includeImages = modelSupportsImages(candidate, { authMode });
317
342
  messages = await addAttachmentParts(messages, events, {
@@ -330,6 +355,7 @@ export async function* runOpenbotTurn(args, options) {
330
355
  reasoning: reasoningForModel(candidate),
331
356
  stopWhen: surface ? [stepCountIs(8), hasToolCall(RESPOND_TOOL)] : stepCountIs(20),
332
357
  allowSystemInMessages: true,
358
+ ...(prepareStep ? { prepareStep } : {}),
333
359
  });
334
360
  for await (const part of result.fullStream) {
335
361
  if (abortSignal?.aborted)
@@ -4,32 +4,40 @@ export const OPENBOT_SYSTEM_PROMPT = [
4
4
  "",
5
5
  "# SECURITY POLICY",
6
6
  "- **CRITICAL**: Never ask for API keys, passwords, or sensitive credentials in chat or through form widgets; values must never enter your context.",
7
- "- **Credential Guidance**: Only ask for a variable after a specialist or tool actually reports it missing. Then call `request_variables` once with every exact name it reported (backend and build-time `VITE_*` alike), each with a short hint. The human fills them in an inline card that saves straight to Space variables and resumes this thread — do not also link to Settings or list the names as links. Never guess, invent, or name an API key or provider that no agent or tool reported.",
7
+ "- **Credential Guidance**: Only ask for a variable after a specialist or tool actually reports it missing. Then call `request_variables` once with every exact name it reported (backend and build-time `VITE_*` alike), each with a short hint. The human fills them in an inline card that saves straight to Space variables; your run waits on the card and continues once they are saved — do not also link to Settings or list the names as links. Never guess, invent, or name an API key or provider that no agent or tool reported.",
8
8
  "",
9
9
  "# CORE MISSION",
10
10
  "You are a high-level manager. Not every message is project work.",
11
11
  "- Answer questions, listings, setup, and status directly from `## SPACES`, `## INSTALLED AGENTS`, and `## AVAILABLE AGENTS`. Do not create a Space to look anything up.",
12
12
  "- For real project work (build, code, design, ops) you decide **who** (which installed agent), then ask that agent in the current Space, close without restating their work, and keep todos current.",
13
+ "- A quick question gets a plain answer and no todo list. A task gets a todo list first; the human follows progress there, not in your messages.",
13
14
  "",
14
15
  "# THE AGENT LOOP",
15
16
  "You operate in an iterative loop:",
16
17
  "1. **Analyze Events**: Understand the human, mentioned people, the current Space, and `## SPACES`.",
17
- "2. **Right Space**: Every Space, `#general` included, is a place to do work. Do it in the current Space by default. Only if the human's request clearly belongs to a different existing Space from `## SPACES` — for example it names that project — explain in text which Space it belongs in and why. Then either wait for the human to continue there, or — when the request is clear enough to act on — call `start_thread` to open a thread in that Space with their request, written in their own first-person voice because it is posted as them; the human approves it before it starts. Never start a thread in the Space you are already in. Otherwise continue here with `ask_agent`. Create a new Space only when the human named one or explicitly asked for it. Never invent placeholder ids (`noop`, `temp`, `misc`, `untitled`).",
18
+ "2. **Right Space**: Every Space, `#general` included, is a place to do work. Do it in the current Space by default. Only if the human's request clearly belongs to a different existing Space from `## SPACES` — for example it names that project — explain in text which Space it belongs in and why. Then either wait for the human to continue there, or — when the request is clear enough to act on — call `start_thread` to open a thread in that Space with their request, written in their own first-person voice because it is posted as them; the human approves it before it starts. Inside a task, never start a thread in the Space you are already in; otherwise continue there with `ask_agent`. In a Space conversation (`## CONVERSATION`), follow its rules instead: answer inline or start a task with `start_thread`. Create a new Space only when the human named one or explicitly asked for it. Never invent placeholder ids (`noop`, `temp`, `misc`, `untitled`).",
18
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.",
19
20
  "4. **Wait**: Let the asked agent run in its own harness. Read their reply, then continue or ask someone else.",
20
- "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.",
21
+ "5. **Close**: Write one short final message when the task is done or blocked. 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. Never post progress recaps between steps; the todo list shows progress.",
21
22
  "6. **Enter Standby**: Idle until the next human message.",
23
+ "- The human can write while you work. Their message shows up in the conversation between your steps: read it, adjust the plan or the next ask to fit, and keep going. If they ask you to stop or change course, do that first.",
22
24
  "",
23
25
  "# TASK TRACKING (TODOS)",
24
26
  "- For any complex multi-step task (3+ steps), you **MUST** maintain a todo list with `todo_write`.",
25
27
  "- Write the full intended list at task start. Each call replaces the previous list (not a partial patch).",
26
- "- Keep exactly one item `in_progress` while working. Mark items `completed` immediately after finishing them.",
28
+ "- Mark an item `in_progress` while it is being worked on; when you ask several agents at once, each of their items is `in_progress`. Mark items `completed` immediately after finishing them.",
29
+ "- Each item is usually one `ask_agent` call. Mark it `in_progress` before you ask, and keep the same list across turns — update it, never start a second one.",
27
30
  "- Use `cancelled` for steps that are no longer needed. When the overall task is done, mark all items `completed` and leave the list intact (do not clear with empty `items`).",
28
31
  "- The current list is injected into context each turn as `## TODOS`; call `todo_read` only if you need an explicit refresh.",
29
32
  "- Do not stop with open `pending` or `in_progress` items unless you are blocked and have told the user why.",
30
33
  "",
34
+ "# ASKING THE HUMAN",
35
+ "- One way to ask: when a step needs a decision or answer from the human, call `ask_user`. It shows a card, pauses your run, and returns their answer, so you continue the same step. Never ask a blocking question in prose and never continue past it.",
36
+ "- Missing variables go through `request_variables`, which also waits. Missing agent accounts are the host's job: the host shows the connect card and the asked agent waits for it. Do not ask the human to connect or to paste keys for an agent that is already connected.",
37
+ "- Ask once. If an answer or card is already pending, wait for it instead of asking again.",
38
+ "",
31
39
  "# 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.",
40
+ "- Call `render_widget` when your own answer is structured: 3+ similar records (Spaces, agents, options, statuses) or a side-by-side comparison. Use plain text for everything else. Decisions go through `ask_user`, not `render_widget`.",
33
41
  "- Never use it to restate or re-format what a specialist already said in this thread; that rule from Close still applies.",
34
42
  "- 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
43
  "- Forms never ask for secrets; use `request_variables` for those.",
@@ -39,13 +47,6 @@ export const OPENBOT_SYSTEM_PROMPT = [
39
47
  "- Call `set_thread_title` again only if the topic clearly changes.",
40
48
  "- Never re-title a job the user renamed.",
41
49
  "",
42
- "# JOB STATUS",
43
- "- Every thread is a job. Status is `working` (in progress), `needs_input`, `ready_for_review`, `completed`, or `archived`.",
44
- "- Status is the job's workflow state, not whether an agent is currently executing.",
45
- "- When you need a blocking answer from the human, call `set_thread_status` with `needs_input` and a short `reason`.",
46
- "- When you believe the job is done, set `ready_for_review`. Never set `completed` or `archived` — only the human can.",
47
- "- The current status is injected each turn as `## THREAD STATUS`.",
48
- "",
49
50
  "# OPERATIONAL GUIDELINES",
50
51
  '- **Spaces**: Work lives in Spaces (product name for channels). `#general` is the default Space and works like any other. Prefer existing Spaces. When the request clearly belongs to another existing Space, explain the appropriate Space in text, then let them continue there or hand it over with `start_thread`. Never auto-move threads or messages; `start_thread` opens a new thread and leaves this one in place. Create a new Space only when the human asked for one, or they agreed after you suggested it.',
51
52
  "- **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.",
@@ -21,16 +21,24 @@ export const toolDefinitions = {
21
21
  }),
22
22
  },
23
23
  };
24
- /** The host's connect card: the asked agent has no account yet and did not run. */
25
- function connectionRequestName(event) {
24
+ /**
25
+ * The host's connect card: the asked agent has no account yet. Newer runtimes
26
+ * wait on the card and close it once connected; `open` is false from then on.
27
+ */
28
+ function connectionCard(event) {
26
29
  if (event.type !== "client:ui:widget")
27
30
  return undefined;
28
- const metadata = event.data?.metadata;
31
+ const data = event.data;
32
+ const metadata = data?.metadata;
29
33
  if (metadata?.type !== "connection_request")
30
34
  return undefined;
31
35
  // `pluginName` is what runtimes before scoped connections send.
32
36
  const name = metadata.name ?? metadata.pluginName;
33
- return typeof name === "string" ? name : "This agent";
37
+ return {
38
+ widgetId: data?.widgetId ?? "",
39
+ name: typeof name === "string" ? name : "This agent",
40
+ open: !data?.state || data.state === "open",
41
+ };
34
42
  }
35
43
  async function runAskedAgent(ctx, args) {
36
44
  if (ctx.agentId !== ctx.host.orchestratorAgentId) {
@@ -61,7 +69,8 @@ async function runAskedAgent(ctx, args) {
61
69
  }
62
70
  const runId = `ask_${randomUUID()}`;
63
71
  let lastAgentOutput = "";
64
- let needsConnection;
72
+ // Latest state of each connect card the asked agent showed, by widgetId.
73
+ const connectCards = new Map();
65
74
  const eventQueue = [];
66
75
  let resolveNext = null;
67
76
  let isFinished = false;
@@ -99,7 +108,9 @@ async function runAskedAgent(ctx, args) {
99
108
  if (outEvent.type === "agent:output") {
100
109
  lastAgentOutput = outEvent.data.content;
101
110
  }
102
- needsConnection ?? (needsConnection = connectionRequestName(outEvent));
111
+ const card = connectionCard(outEvent);
112
+ if (card)
113
+ connectCards.set(card.widgetId, card);
103
114
  if (resolveNext) {
104
115
  resolveNext();
105
116
  resolveNext = null;
@@ -128,8 +139,10 @@ async function runAskedAgent(ctx, args) {
128
139
  }
129
140
  }
130
141
  await runPromise;
142
+ // A card still open when the run ended means the agent never got its account.
143
+ const needsConnection = [...connectCards.values()].find((card) => card.open)?.name;
131
144
  if (needsConnection) {
132
- const output = `${needsConnection} is not connected yet, so it did not run. The human now sees a ` +
145
+ const output = `${needsConnection} is not connected yet, so it did not run. The human sees a ` +
133
146
  "card to connect it; once they do, the conversation continues on its own. " +
134
147
  "Do not ask them for keys or retry; say in one short line that it needs connecting.";
135
148
  return { success: false, needsConnection: true, error: output, output };
@@ -0,0 +1,77 @@
1
+ import z from 'zod';
2
+ import { requestQuestion } from '@meetopenbot/plugin-sdk';
3
+ /**
4
+ * `ask_user` — OpenBot asks the human one question and waits for the answer.
5
+ * The run pauses on the card (thread status `needs_input`) and the answer is
6
+ * the tool result, so the plan continues from the same step.
7
+ */
8
+ const MAX_OPTIONS = 6;
9
+ export const toolDefinitions = {
10
+ ask_user: {
11
+ description: 'Ask the human one question and wait for their answer before continuing. Use it for a decision the next step depends on (e.g. test or live mode, which option to pick). Give 2-6 short options for a choice; omit options for a free-text answer. Never ask the same question in prose.',
12
+ inputSchema: z.object({
13
+ question: z.string().min(1).describe('The question, one short sentence.'),
14
+ description: z.string().optional().describe('Optional one-line context under the question.'),
15
+ options: z
16
+ .array(z.string().min(1).max(40))
17
+ .max(MAX_OPTIONS)
18
+ .optional()
19
+ .describe('Button labels, recommended option first. Omit for a free-text answer.'),
20
+ }),
21
+ },
22
+ };
23
+ async function askUser(rawArgs, ctx) {
24
+ const args = (rawArgs ?? {});
25
+ const question = args.question?.trim();
26
+ if (!question) {
27
+ const message = 'Pass the question to ask.';
28
+ return { success: false, error: message, output: message };
29
+ }
30
+ const options = [...new Set((args.options ?? []).map((option) => option.trim()).filter(Boolean))].slice(0, MAX_OPTIONS);
31
+ const description = args.description?.trim() || undefined;
32
+ const hitl = {
33
+ emit: ctx.emit,
34
+ host: ctx.host,
35
+ abortSignal: ctx.abortSignal,
36
+ agentId: ctx.agentId,
37
+ threadId: ctx.threadId ?? ctx.state.threadId,
38
+ channelId: ctx.channelId ?? ctx.state.channelId,
39
+ storage: ctx.storage,
40
+ };
41
+ try {
42
+ const response = options.length > 0
43
+ ? await requestQuestion({
44
+ ...hitl,
45
+ title: question,
46
+ description,
47
+ actions: options.map((label, index) => ({
48
+ id: label,
49
+ label,
50
+ variant: index === 0 ? 'primary' : 'secondary',
51
+ })),
52
+ })
53
+ : await requestQuestion({
54
+ ...hitl,
55
+ title: question,
56
+ description,
57
+ fields: [{ id: 'answer', label: question, type: 'textarea', required: true }],
58
+ });
59
+ const answer = options.length > 0 ? response.actionId : String(response.values?.answer ?? '').trim();
60
+ if (!answer || response.actionId === 'deny') {
61
+ const output = 'The human dismissed the question without answering.';
62
+ return { success: false, error: output, output };
63
+ }
64
+ return { success: true, answer, output: `The human answered: ${answer}` };
65
+ }
66
+ catch {
67
+ const output = 'The run stopped before the human answered.';
68
+ return { success: false, error: output, output };
69
+ }
70
+ }
71
+ export const tools = {
72
+ ask_user: {
73
+ ...toolDefinitions.ask_user,
74
+ execute: askUser,
75
+ },
76
+ };
77
+ export const askUserTools = tools;
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import z from 'zod';
3
3
  import { isPlaceholderSpaceId } from '../space-id.js';
4
4
  import { normalizeChannelId } from './storage.js';
5
+ import { isConversationThread } from '../conversation.js';
5
6
  async function emitStartThreadWidget(ctx, args) {
6
7
  const actions = args.state === 'submitted' && args.threadUrl
7
8
  ? [
@@ -24,7 +25,13 @@ async function emitStartThreadWidget(ctx, args) {
24
25
  display: args.state === 'submitted' ? 'expanded' : 'collapsed',
25
26
  state: args.state,
26
27
  ...(actions ? { actions } : {}),
27
- metadata: { type: 'thread_started', channelId: args.channelId || undefined },
28
+ metadata: {
29
+ type: 'thread_started',
30
+ channelId: args.channelId || undefined,
31
+ // The task card follows this thread's live status.
32
+ ...(args.threadId ? { threadId: args.threadId } : {}),
33
+ ...(args.title ? { title: args.title } : {}),
34
+ },
28
35
  },
29
36
  meta: {
30
37
  agentId: ctx.agentId,
@@ -35,7 +42,7 @@ async function emitStartThreadWidget(ctx, args) {
35
42
  }
36
43
  export const startThreadTools = {
37
44
  start_thread: {
38
- description: "Start a new thread in another existing Space and hand the human's request to that Space's agent. Use when work asked here clearly belongs in a different Space from `## SPACES`. The new thread runs on its own; the human follows it there. Never use it for the Space you are already in.",
45
+ description: "Start a new thread (task) in an existing Space and hand the human's request to that Space's agent. From a Space conversation (`## CONVERSATION`), use it for every real piece of work, in the current Space or another one. From inside a task, use it only when work clearly belongs in a different Space from `## SPACES`. The new thread runs on its own; the human follows it there.",
39
46
  inputSchema: z.object({
40
47
  channelId: z.string().describe('Existing Space id from `## SPACES` (e.g. backend-platform).'),
41
48
  prompt: z
@@ -69,7 +76,8 @@ export const startThreadTools = {
69
76
  if (isPlaceholderSpaceId(targetChannelId)) {
70
77
  return fail(`Refused to start a thread in #${targetChannelId}: that is not a real Space.`);
71
78
  }
72
- if (targetChannelId === (ctx.channelId ?? ctx.state.channelId)) {
79
+ const fromConversation = isConversationThread(ctx.threadId ?? ctx.state.threadId);
80
+ if (!fromConversation && targetChannelId === (ctx.channelId ?? ctx.state.channelId)) {
73
81
  return fail(`You are already in #${targetChannelId}. Continue here instead of starting a thread.`);
74
82
  }
75
83
  if (!ctx.host.startThreadRun) {
@@ -84,6 +92,7 @@ export const startThreadTools = {
84
92
  await emitStartThreadWidget(ctx, {
85
93
  widgetId,
86
94
  channelId: targetChannelId,
95
+ threadId,
87
96
  title: threadTitle,
88
97
  body: 'Starting…',
89
98
  state: 'open',
@@ -103,6 +112,7 @@ export const startThreadTools = {
103
112
  await emitStartThreadWidget(ctx, {
104
113
  widgetId,
105
114
  channelId: targetChannelId,
115
+ threadId,
106
116
  title: threadTitle,
107
117
  body: `Started a thread in #${targetChannelId}.`,
108
118
  state: 'submitted',
@@ -0,0 +1,43 @@
1
+ /** Todo list shape and validation; no I/O, so it loads without the build. */
2
+ export const MAX_TODO_ITEMS = 20;
3
+ const TODO_STATUSES = new Set([
4
+ 'pending',
5
+ 'in_progress',
6
+ 'completed',
7
+ 'cancelled',
8
+ ]);
9
+ /**
10
+ * Validate and normalize a todo list write. Throws on invalid input.
11
+ * Enforces: non-empty content, known statuses, unique ids, max size.
12
+ * Several items may be `in_progress`: steps asked in parallel run together.
13
+ */
14
+ export function validateTodoItems(items) {
15
+ if (!Array.isArray(items)) {
16
+ throw new Error('todos must be an array');
17
+ }
18
+ if (items.length > MAX_TODO_ITEMS) {
19
+ throw new Error(`At most ${MAX_TODO_ITEMS} todos allowed`);
20
+ }
21
+ const seen = new Set();
22
+ const normalized = [];
23
+ for (const raw of items) {
24
+ if (!raw || typeof raw !== 'object') {
25
+ throw new Error('Each todo must be an object');
26
+ }
27
+ const id = typeof raw.id === 'string' ? raw.id.trim() : '';
28
+ const content = typeof raw.content === 'string' ? raw.content.trim() : '';
29
+ const status = raw.status;
30
+ if (!id)
31
+ throw new Error('Each todo requires a non-empty id');
32
+ if (!content)
33
+ throw new Error(`Todo "${id}" requires non-empty content`);
34
+ if (!TODO_STATUSES.has(status)) {
35
+ throw new Error(`Todo "${id}" has invalid status "${String(status)}"; expected pending|in_progress|completed|cancelled`);
36
+ }
37
+ if (seen.has(id))
38
+ throw new Error(`Duplicate todo id "${id}"`);
39
+ seen.add(id);
40
+ normalized.push({ id, content, status: status });
41
+ }
42
+ return normalized;
43
+ }
@@ -1,13 +1,8 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { DEFAULT_CHANNELS_DIR, getBaseDir, resolvePath } from '../utils/paths.js';
4
- export const MAX_TODO_ITEMS = 20;
5
- const TODO_STATUSES = new Set([
6
- 'pending',
7
- 'in_progress',
8
- 'completed',
9
- 'cancelled',
10
- ]);
4
+ import { MAX_TODO_ITEMS, validateTodoItems } from './todo-items.js';
5
+ export { MAX_TODO_ITEMS, validateTodoItems };
11
6
  const getThreadDir = (channelId, threadId) => {
12
7
  const base = resolvePath(path.join(getBaseDir(), DEFAULT_CHANNELS_DIR, channelId));
13
8
  return path.join(base, 'threads', threadId);
@@ -17,47 +12,6 @@ const emptyList = () => ({
17
12
  items: [],
18
13
  updatedAt: new Date().toISOString(),
19
14
  });
20
- /**
21
- * Validate and normalize a todo list write. Throws on invalid input.
22
- * Enforces: non-empty content, known statuses, unique ids, max size,
23
- * and at most one `in_progress` item.
24
- */
25
- export function validateTodoItems(items) {
26
- if (!Array.isArray(items)) {
27
- throw new Error('todos must be an array');
28
- }
29
- if (items.length > MAX_TODO_ITEMS) {
30
- throw new Error(`At most ${MAX_TODO_ITEMS} todos allowed`);
31
- }
32
- const seen = new Set();
33
- let inProgressCount = 0;
34
- const normalized = [];
35
- for (const raw of items) {
36
- if (!raw || typeof raw !== 'object') {
37
- throw new Error('Each todo must be an object');
38
- }
39
- const id = typeof raw.id === 'string' ? raw.id.trim() : '';
40
- const content = typeof raw.content === 'string' ? raw.content.trim() : '';
41
- const status = raw.status;
42
- if (!id)
43
- throw new Error('Each todo requires a non-empty id');
44
- if (!content)
45
- throw new Error(`Todo "${id}" requires non-empty content`);
46
- if (!TODO_STATUSES.has(status)) {
47
- throw new Error(`Todo "${id}" has invalid status "${String(status)}"; expected pending|in_progress|completed|cancelled`);
48
- }
49
- if (seen.has(id))
50
- throw new Error(`Duplicate todo id "${id}"`);
51
- seen.add(id);
52
- if (status === 'in_progress')
53
- inProgressCount += 1;
54
- normalized.push({ id, content, status: status });
55
- }
56
- if (inProgressCount > 1) {
57
- throw new Error('At most one todo may be in_progress');
58
- }
59
- return normalized;
60
- }
61
15
  export const todoService = {
62
16
  getTodos: async (args) => {
63
17
  const filePath = getTodosPath(args.channelId, args.threadId);
@@ -35,8 +35,8 @@ function todoListWidget(args) {
35
35
  return {
36
36
  type: 'client:ui:widget',
37
37
  data: {
38
- // One widget per agent run — repeated todo_write calls update in place.
39
- widgetId: `todos:${args.runId}`,
38
+ // One plan per thread — every todo_write, in any run, updates it in place.
39
+ widgetId: `todos:${args.threadId}`,
40
40
  kind: 'list',
41
41
  layout: 'checklist',
42
42
  title: 'Todos',
@@ -51,11 +51,11 @@ const todoItemSchema = z.object({
51
51
  content: z.string().min(1).describe('Short imperative description of the step.'),
52
52
  status: z
53
53
  .enum(['pending', 'in_progress', 'completed', 'cancelled'])
54
- .describe('Todo status. At most one item may be in_progress.'),
54
+ .describe('Todo status. Every step being worked on is in_progress, including steps run in parallel.'),
55
55
  });
56
56
  export const toolDefinitions = {
57
57
  todo_write: {
58
- description: 'Replace the current thread todo list with the provided items. Use for multi-step tasks: write the plan first, keep exactly one item in_progress while working, mark items completed immediately after finishing, and when done leave all items as completed (do not clear the list). Pass the full intended list each call (not a partial patch).',
58
+ description: 'Replace the current thread todo list with the provided items. Use for multi-step tasks: write the plan first, mark each step in_progress while it is being worked on (several when they run in parallel), mark items completed immediately after finishing, and when done leave all items as completed (do not clear the list). Pass the full intended list each call (not a partial patch).',
59
59
  inputSchema: z.object({
60
60
  items: z
61
61
  .array(todoItemSchema)
@@ -81,16 +81,13 @@ export const tools = {
81
81
  const items = (rawArgs ?? {}).items;
82
82
  const list = await todoService.writeTodos({ channelId, threadId, items });
83
83
  const runId = ctx.runId ?? ctx.state.runId;
84
- if (!runId) {
85
- throw new Error('Missing runId for todo_write widget');
86
- }
87
84
  await ctx.emit(todoListWidget({
88
- runId,
85
+ threadId,
89
86
  list,
90
87
  meta: {
91
88
  agentId: ctx.agentId,
92
89
  threadId,
93
- runId,
90
+ ...(runId ? { runId } : {}),
94
91
  toolCallId: ctx.toolCallId,
95
92
  },
96
93
  }));
@@ -1,4 +1,5 @@
1
1
  import z from 'zod';
2
+ import { markThreadNeedsInput, restoreThreadWorking } from '@meetopenbot/plugin-sdk';
2
3
  /** `metadata.type` of the card the host shows to collect missing variables. */
3
4
  export const VARIABLES_REQUEST_TYPE = 'variables_request';
4
5
  const ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -63,22 +64,70 @@ async function requestVariables(rawArgs, ctx) {
63
64
  const message = 'Pass at least one variable name such as FIREBASE_PROJECT_ID.';
64
65
  return { success: false, error: message, output: message };
65
66
  }
66
- await ctx.emit(variablesRequestEvent({
67
+ const card = variablesRequestEvent({
67
68
  variables,
68
69
  reason: typeof data.reason === 'string' ? data.reason : undefined,
69
70
  agentId: ctx.agentId,
70
71
  threadId,
71
- }));
72
+ });
73
+ await ctx.emit(card);
74
+ const names = variables.map((variable) => variable.key).join(', ');
75
+ if (ctx.host?.resumesCardsInPlace)
76
+ return waitForVariables(ctx, card, names);
72
77
  return {
73
78
  success: true,
74
79
  output: 'Shown a card asking the human for ' +
75
- variables.map((variable) => variable.key).join(', ') +
80
+ names +
76
81
  '. They save the values there; you will get a message when they do. Do not ask them to paste values in chat.',
77
82
  };
78
83
  }
84
+ /**
85
+ * Hold the run on the card until the human saves every key. The runtime's
86
+ * variables route resolves the wait and sends the closed card's body back.
87
+ * If the run stops first the card stays open: saving later resumes the thread
88
+ * with a message, also after a restart.
89
+ */
90
+ async function waitForVariables(ctx, card, names) {
91
+ const data = card.data;
92
+ const hitl = {
93
+ emit: ctx.emit,
94
+ host: ctx.host,
95
+ agentId: ctx.agentId,
96
+ channelId: ctx.channelId ?? ctx.state.channelId,
97
+ threadId: ctx.threadId ?? ctx.state.threadId,
98
+ storage: ctx.storage,
99
+ };
100
+ const paused = await markThreadNeedsInput(hitl, `Add ${names}`);
101
+ let response;
102
+ try {
103
+ response = await ctx.host.awaitWidgetResponse(data.widgetId, ctx.abortSignal, { resumable: true });
104
+ }
105
+ catch {
106
+ // The card stays open and the thread stays `needs_input`; saving later resumes it.
107
+ const output = `The run stopped before ${names} were saved. The card stays open; saving it continues the thread.`;
108
+ return { success: false, error: output, output };
109
+ }
110
+ const body = typeof response.metadata?.body === 'string' ? response.metadata.body : `Saved ${names}.`;
111
+ const scope = response.metadata?.scope;
112
+ await ctx.emit({
113
+ ...card,
114
+ data: {
115
+ ...data,
116
+ state: 'submitted',
117
+ body,
118
+ metadata: { ...data.metadata, ...(scope ? { scope } : {}) },
119
+ },
120
+ });
121
+ if (paused)
122
+ await restoreThreadWorking(hitl);
123
+ return {
124
+ success: true,
125
+ output: `${body} They are in the environment now; ask the specialist to restart whatever needed them.`,
126
+ };
127
+ }
79
128
  export const toolDefinitions = {
80
129
  request_variables: {
81
- description: 'Ask the human for missing environment variables with an inline card in the thread. Values are saved as Space variables by default (or for all spaces if they choose) and never appear in chat. Pass every missing name at once, including build-time ones like VITE_*.',
130
+ description: 'Ask the human for missing environment variables with an inline card in the thread, and wait until they are saved. Values are saved as Space variables by default (or for all spaces if they choose) and never appear in chat. Pass every missing name at once, including build-time ones like VITE_*.',
82
131
  inputSchema: z.object({
83
132
  variables: z
84
133
  .array(z.object({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meetopenbot/openbot",
3
- "version": "1.7.0",
3
+ "version": "1.9.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.3.0"
28
+ "@meetopenbot/plugin-sdk": "^2.4.0"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "^20.10.1",
@@ -1,75 +0,0 @@
1
- import z from "zod";
2
- const AGENT_THREAD_STATUSES = [
3
- "working",
4
- "needs_input",
5
- "ready_for_review",
6
- ];
7
- function formatStatusOutput(args) {
8
- const reason = args.reason?.trim();
9
- return reason
10
- ? `Job status set to ${args.status}: ${reason}`
11
- : `Job status set to ${args.status}.`;
12
- }
13
- export const toolDefinitions = {
14
- set_thread_status: {
15
- description: "Set this job's workflow status. Use needs_input when blocked on the human, ready_for_review when you believe the work is done, working when the job is still in progress. Do not use working to mean you are currently executing — that is tracked automatically. Never mark completed or archived — only the human can.",
16
- inputSchema: z.object({
17
- status: z
18
- .enum(AGENT_THREAD_STATUSES)
19
- .describe("working — job is in progress; needs_input — blocked on the human; ready_for_review — you believe the job is done."),
20
- reason: z
21
- .string()
22
- .optional()
23
- .describe("Short reason shown in the job list, e.g. what you need from the human."),
24
- }),
25
- },
26
- };
27
- export const tools = {
28
- set_thread_status: {
29
- ...toolDefinitions.set_thread_status,
30
- execute: async (rawArgs, ctx) => {
31
- try {
32
- const channelId = ctx.channelId ?? ctx.state.channelId ?? '';
33
- const threadId = ctx.threadId ?? ctx.state.threadId;
34
- if (!channelId || !threadId) {
35
- throw new Error("Missing channelId or threadId for set_thread_status");
36
- }
37
- const data = (rawArgs ?? {});
38
- const status = data.status;
39
- if (status !== "working" &&
40
- status !== "needs_input" &&
41
- status !== "ready_for_review") {
42
- throw new Error("Agents may only set working, needs_input, or ready_for_review. The human marks completed and archived.");
43
- }
44
- const reason = typeof data.reason === "string" && data.reason.trim()
45
- ? data.reason.trim()
46
- : "";
47
- await ctx.storage.patchThreadState({
48
- channelId,
49
- threadId,
50
- state: {
51
- status,
52
- statusUpdatedAt: new Date().toISOString(),
53
- statusReason: reason,
54
- },
55
- });
56
- ctx.state.threadDetails = await ctx.storage.getThreadDetails({
57
- channelId,
58
- threadId,
59
- });
60
- const output = formatStatusOutput({ status, reason });
61
- return {
62
- success: true,
63
- status,
64
- ...(reason ? { reason } : {}),
65
- output,
66
- };
67
- }
68
- catch (error) {
69
- const message = error instanceof Error ? error.message : "Unknown error";
70
- return { success: false, error: message, output: message };
71
- }
72
- },
73
- },
74
- };
75
- export const threadStatusTools = tools;