@meetopenbot/openbot 1.8.0 → 1.10.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.
@@ -2,16 +2,27 @@ import { memoryTools } from "./tools/memory.js";
2
2
  import { todoTools } from "./tools/todo.js";
3
3
  import { storageTools } from "./tools/storage.js";
4
4
  import { startThreadTools } from "./tools/start-thread.js";
5
+ import { tasksTools } from "./tools/tasks.js";
5
6
  import { askAgentTools } from "./tools/ask-agent.js";
6
7
  import { askUserTools } from "./tools/ask-user.js";
7
8
  import { installAgentTools } from "./tools/install-agent.js";
8
- import { threadStatusTools } from "./tools/thread-status.js";
9
9
  import { threadTitleTools } from "./tools/thread-title.js";
10
10
  import { previewTools } from "./tools/preview.js";
11
11
  import { variablesRequestTools } from "./tools/variables-request.js";
12
12
  import { renderWidgetTools } from "./tools/render-widget.js";
13
13
  import { wrapToolsWithApproval } from "./tools/approval.js";
14
+ import { isConversationThread } from "./conversation.js";
14
15
  export const DEFAULT_APPROVAL_ACTIONS = ["update_channel_spec", "start_thread"];
16
+ /**
17
+ * A Space conversation answers inline or starts tasks; specialist work, todos,
18
+ * and job title/status belong to the task.
19
+ */
20
+ export const CONVERSATION_EXCLUDED_TOOLS = [
21
+ "ask_agent",
22
+ "todo_write",
23
+ "todo_read",
24
+ "set_thread_title",
25
+ ];
15
26
  /**
16
27
  * Merge first-party OpenBot coordinator tools and wrap configured actions
17
28
  * with approval.
@@ -22,10 +33,10 @@ export function buildOpenbotTools(args) {
22
33
  ...todoTools,
23
34
  ...storageTools,
24
35
  ...startThreadTools,
36
+ ...tasksTools,
25
37
  ...askAgentTools,
26
38
  ...askUserTools,
27
39
  ...installAgentTools,
28
- ...threadStatusTools,
29
40
  ...threadTitleTools,
30
41
  ...previewTools,
31
42
  ...variablesRequestTools,
@@ -33,5 +44,12 @@ export function buildOpenbotTools(args) {
33
44
  ...args.context.tools,
34
45
  };
35
46
  const approvalConfig = args.context.config?.approval;
36
- return wrapToolsWithApproval(tools, approvalConfig?.actions ?? DEFAULT_APPROVAL_ACTIONS, args.context);
47
+ const conversation = isConversationThread(args.threadId);
48
+ if (conversation) {
49
+ for (const name of CONVERSATION_EXCLUDED_TOOLS)
50
+ delete tools[name];
51
+ }
52
+ // Starting tasks is what a Space conversation is for, so it does not ask first.
53
+ const actions = (approvalConfig?.actions ?? DEFAULT_APPROVAL_ACTIONS).filter((action) => !(action === "start_thread" && conversation));
54
+ return wrapToolsWithApproval(tools, actions, args.context);
37
55
  }
package/dist/context.js CHANGED
@@ -2,6 +2,8 @@ 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";
6
+ import { buildTasksSection } from "./tasks.js";
5
7
  export const DEFAULT_CONTEXT_BUDGET = 8000;
6
8
  export const MAX_CONTEXT_FILES = 50;
7
9
  /**
@@ -63,23 +65,8 @@ export async function buildContext(state, storage, host) {
63
65
  env += `- Thread: ${threadDetails?.name || threadId}\n`;
64
66
  }
65
67
  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);
68
+ if (isConversationThread(threadId)) {
69
+ sections.push(CONVERSATION_INSTRUCTIONS);
83
70
  }
84
71
  const invokeData = state.triggerEvent?.type === "agent:invoke"
85
72
  ? state.triggerEvent.data
@@ -111,6 +98,17 @@ export async function buildContext(state, storage, host) {
111
98
  console.warn("[context] Failed to fetch todos:", error);
112
99
  }
113
100
  }
101
+ // 2.55 Tasks this thread started
102
+ if (!surface && channelId && threadId) {
103
+ try {
104
+ const tasks = await buildTasksSection(storage, host, { channelId, threadId });
105
+ if (tasks)
106
+ sections.push(tasks);
107
+ }
108
+ catch (error) {
109
+ console.warn("[context] Failed to fetch tasks:", error);
110
+ }
111
+ }
114
112
  // 2.6 Installed Agents
115
113
  const otherAgents = allAgents.filter((agent) => agent.id !== agentId);
116
114
  if (otherAgents.length > 0) {
@@ -0,0 +1,79 @@
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
+ '- **Task status**: when the human asks how a task went, answer from `## TASKS`; call `get_task` for its full last reply, or `list_tasks` when it is not listed. Never say you cannot see a task before checking.',
15
+ '- **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.',
16
+ '- 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.',
17
+ '- 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.',
18
+ '- If you are unsure whether the human wants a task, ask in one line instead of starting one.',
19
+ ].join('\n');
20
+ /** Human turns kept verbatim when a Space conversation is replayed to the model. */
21
+ export const CONVERSATION_KEPT_TURNS = 20;
22
+ const DIGEST_MESSAGE_LIMIT = 30;
23
+ const DIGEST_TEXT_LIMIT = 200;
24
+ function isHumanTurn(event) {
25
+ if (event.type !== 'agent:invoke' || event.meta?.parentToolCallId)
26
+ return false;
27
+ return event.data?.role === 'user';
28
+ }
29
+ function clip(text) {
30
+ const flat = text.replace(/\s+/g, ' ').trim();
31
+ return flat.length > DIGEST_TEXT_LIMIT ? `${flat.slice(0, DIGEST_TEXT_LIMIT)}…` : flat;
32
+ }
33
+ function startedTask(event) {
34
+ if (event.type !== 'client:ui:widget')
35
+ return null;
36
+ const data = event.data;
37
+ if (data?.metadata?.type !== 'thread_started' || data.state !== 'submitted')
38
+ return null;
39
+ const { channelId, threadId, title } = data.metadata;
40
+ if (typeof channelId !== 'string' || typeof threadId !== 'string')
41
+ return null;
42
+ return { channelId, threadId, ...(typeof title === 'string' && title ? { title } : {}) };
43
+ }
44
+ /**
45
+ * Keeps the last {@link CONVERSATION_KEPT_TURNS} human turns (and everything
46
+ * after the first of them) and folds older history into a plain-text digest:
47
+ * what the human asked and which tasks were started. No model call.
48
+ */
49
+ export function compactConversation(events, keptTurns = CONVERSATION_KEPT_TURNS) {
50
+ const turnStarts = [];
51
+ events.forEach((event, index) => {
52
+ if (isHumanTurn(event))
53
+ turnStarts.push(index);
54
+ });
55
+ if (turnStarts.length <= keptTurns)
56
+ return { events, digest: '' };
57
+ const cut = turnStarts[turnStarts.length - keptTurns];
58
+ const dropped = events.slice(0, cut);
59
+ const asked = dropped
60
+ .filter(isHumanTurn)
61
+ .map((event) => clip(String(event.data.content ?? '')))
62
+ .filter(Boolean)
63
+ .slice(-DIGEST_MESSAGE_LIMIT);
64
+ const tasks = dropped
65
+ .map(startedTask)
66
+ .filter((task) => task !== null)
67
+ .slice(-DIGEST_MESSAGE_LIMIT);
68
+ const lines = [
69
+ '## EARLIER IN THIS CONVERSATION',
70
+ 'Older messages were compacted. This is what remains of them; ask the human if you need more.',
71
+ ];
72
+ if (asked.length > 0) {
73
+ lines.push('Human asked:', ...asked.map((text) => `- ${text}`));
74
+ }
75
+ if (tasks.length > 0) {
76
+ lines.push('Tasks started:', ...tasks.map((task) => `- ${task.title ?? 'Untitled'} — #${task.channelId}, thread ${task.threadId}`));
77
+ }
78
+ return { events: events.slice(cut), digest: lines.join('\n') };
79
+ }
@@ -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,11 +303,11 @@ 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
  }
302
312
  // Messages the human sent while this run is going. They join the conversation
303
313
  // before the next model step, like a reply typed between tool calls, and stay in
@@ -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**: 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`).",
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**: 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.",
@@ -47,13 +47,6 @@ export const OPENBOT_SYSTEM_PROMPT = [
47
47
  "- Call `set_thread_title` again only if the topic clearly changes.",
48
48
  "- Never re-title a job the user renamed.",
49
49
  "",
50
- "# JOB STATUS",
51
- "- Every thread is a job. Status is `working` (in progress), `needs_input`, `ready_for_review`, `completed`, or `archived`.",
52
- "- Status is the job's workflow state, not whether an agent is currently executing.",
53
- "- `ask_user`, `request_variables`, and connect cards set `needs_input` while they wait; do not set it yourself for those.",
54
- "- When you believe the job is done, set `ready_for_review`. Never set `completed` or `archived` — only the human can.",
55
- "- The current status is injected each turn as `## THREAD STATUS`.",
56
- "",
57
50
  "# OPERATIONAL GUIDELINES",
58
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.',
59
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.",
package/dist/tasks.js ADDED
@@ -0,0 +1,169 @@
1
+ /** Tasks listed in `## TASKS`; older ones are a `list_tasks` call away. */
2
+ export const TASKS_SECTION_LIMIT = 10;
3
+ const SECTION_RESULT_CHARS = 200;
4
+ function clip(text, max) {
5
+ const flat = text.replace(/\s+/g, " ").trim();
6
+ return flat.length > max ? `${flat.slice(0, max).trimEnd()}…` : flat;
7
+ }
8
+ export function withRunning(thread, host) {
9
+ const running = host?.isThreadRunning?.({
10
+ channelId: thread.channelId,
11
+ threadId: thread.id,
12
+ });
13
+ return typeof running === "boolean" ? { ...thread, running } : thread;
14
+ }
15
+ /**
16
+ * Where the task stands, in words. A task stays `working` after its run ends
17
+ * until someone moves it on, so the last run's outcome decides what that means.
18
+ */
19
+ export function describeTaskState(task) {
20
+ const reason = task.statusReason ? `: ${task.statusReason}` : "";
21
+ // A run waiting on the human's answer is still "running"; the wait matters more.
22
+ if (task.status === "needs_input")
23
+ return `waiting for the human${reason}`;
24
+ if (task.running)
25
+ return "running now";
26
+ switch (task.status) {
27
+ case "ready_for_review":
28
+ return `ready for review${reason}`;
29
+ case "completed":
30
+ return "completed";
31
+ case "archived":
32
+ return "archived";
33
+ }
34
+ if (task.running === undefined)
35
+ return "working";
36
+ const result = task.result;
37
+ if (!result)
38
+ return "not running, no result recorded";
39
+ if (result.error)
40
+ return `last run failed: ${clip(result.error, SECTION_RESULT_CHARS)}`;
41
+ if (result.stopped)
42
+ return "stopped by the human";
43
+ return "last run finished";
44
+ }
45
+ function formatTaskLine(task) {
46
+ const title = task.name && task.name !== task.id ? task.name : "Untitled";
47
+ const lines = [
48
+ `- ${title} — #${task.channelId}, thread ${task.id} — ${describeTaskState(task)}`,
49
+ ];
50
+ const summary = task.result?.summary;
51
+ if (summary && !task.running) {
52
+ lines.push(` Last reply: ${clip(summary, SECTION_RESULT_CHARS)}`);
53
+ }
54
+ return lines.join("\n");
55
+ }
56
+ export function formatTasksSection(tasks) {
57
+ if (tasks.length === 0)
58
+ return "";
59
+ return [
60
+ "## TASKS",
61
+ "Tasks started from this conversation, most recently active first. Answer questions about them from this list. Call `get_task` for a task's full last reply, and `list_tasks` for older tasks or tasks in other Spaces.",
62
+ ...tasks.map(formatTaskLine),
63
+ ].join("\n");
64
+ }
65
+ export function isFromOrigin(task, origin) {
66
+ return task.origin?.channelId === origin.channelId && task.origin?.threadId === origin.threadId;
67
+ }
68
+ export async function listTasks(storage, host, args) {
69
+ const page = await storage.getThreads({ sort: "updatedAt", order: "desc", ...args });
70
+ return { tasks: page.items.map((thread) => withRunning(thread, host)), total: page.total };
71
+ }
72
+ /** The `## TASKS` section for a thread, or "" when it started none. */
73
+ export async function buildTasksSection(storage, host, origin) {
74
+ if (!storage?.getThreads)
75
+ return "";
76
+ const { tasks } = await listTasks(storage, host, {
77
+ origin,
78
+ limit: TASKS_SECTION_LIMIT,
79
+ });
80
+ // Older runtimes ignore the origin filter and list every thread.
81
+ return formatTasksSection(tasks.filter((task) => isFromOrigin(task, origin)));
82
+ }
83
+ const STATUSES = [
84
+ "working",
85
+ "needs_input",
86
+ "ready_for_review",
87
+ "completed",
88
+ "archived",
89
+ ];
90
+ function isRecord(value) {
91
+ return !!value && typeof value === "object" && !Array.isArray(value);
92
+ }
93
+ function text(value) {
94
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
95
+ }
96
+ /** Reads a task from its thread `state.json`, as `getThreadDetails` returns it. */
97
+ export function taskFromState(channelId, threadId, rawState) {
98
+ const state = isRecord(rawState) ? rawState : {};
99
+ const status = STATUSES.find((value) => value === state.status) ?? "completed";
100
+ const statusReason = text(state.statusReason);
101
+ const origin = isRecord(state.origin) ? state.origin : undefined;
102
+ const originChannelId = text(origin?.channelId);
103
+ const originThreadId = text(origin?.threadId);
104
+ const result = isRecord(state.result) ? state.result : undefined;
105
+ const endedAt = text(result?.endedAt);
106
+ const error = text(result?.error);
107
+ const preview = isRecord(state.preview) ? state.preview : undefined;
108
+ const firstMessage = text(preview?.content);
109
+ const latestAgentOutput = text(preview?.latestAgentOutput);
110
+ const updatedAt = text(state.statusUpdatedAt);
111
+ return {
112
+ id: threadId,
113
+ name: text(state.name) ?? threadId,
114
+ channelId,
115
+ createdAt: new Date(0),
116
+ updatedAt: updatedAt ? new Date(updatedAt) : new Date(0),
117
+ status,
118
+ ...(statusReason ? { statusReason } : {}),
119
+ ...(originChannelId && originThreadId
120
+ ? { origin: { channelId: originChannelId, threadId: originThreadId } }
121
+ : {}),
122
+ ...(result && endedAt
123
+ ? {
124
+ result: {
125
+ summary: text(result.summary) ?? "",
126
+ endedAt,
127
+ ...(error ? { error } : {}),
128
+ ...(result.stopped === true ? { stopped: true } : {}),
129
+ },
130
+ }
131
+ : {}),
132
+ ...(firstMessage
133
+ ? {
134
+ preview: {
135
+ content: firstMessage,
136
+ ...(latestAgentOutput ? { latestAgentOutput } : {}),
137
+ },
138
+ }
139
+ : {}),
140
+ };
141
+ }
142
+ export function formatTaskList(tasks, total) {
143
+ if (tasks.length === 0)
144
+ return "No tasks match.";
145
+ const more = total > tasks.length ? `\n(${total - tasks.length} more not shown.)` : "";
146
+ return `${tasks.map(formatTaskLine).join("\n")}${more}`;
147
+ }
148
+ /** Everything `get_task` reports: state, the request, the last run's reply, and todos. */
149
+ export function formatTaskDetail(task, todos = []) {
150
+ const title = task.name && task.name !== task.id ? task.name : "Untitled";
151
+ const lines = [
152
+ `Task: ${title} — #${task.channelId}, thread ${task.id}`,
153
+ `State: ${describeTaskState(task)}`,
154
+ ];
155
+ if (task.preview?.content)
156
+ lines.push(`Request: ${clip(task.preview.content, 600)}`);
157
+ if (task.result) {
158
+ lines.push(`Last run ended: ${task.result.endedAt}`);
159
+ if (task.result.summary)
160
+ lines.push(`Last reply:\n${task.result.summary}`);
161
+ }
162
+ else if (task.preview?.latestAgentOutput) {
163
+ lines.push(`Latest reply so far:\n${task.preview.latestAgentOutput}`);
164
+ }
165
+ if (todos.length > 0) {
166
+ lines.push("Todos:", ...todos.map((todo) => `- [${todo.status}] ${todo.content}`));
167
+ }
168
+ return lines.join("\n");
169
+ }
@@ -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,16 +92,22 @@ 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',
90
99
  });
100
+ const originChannelId = ctx.channelId ?? ctx.state.channelId;
101
+ const originThreadId = ctx.threadId ?? ctx.state.threadId;
91
102
  try {
92
103
  await ctx.storage.createThread({
93
104
  channelId: targetChannelId,
94
105
  threadId,
95
106
  ...(threadTitle ? { threadTitle } : {}),
96
- initialState: {},
107
+ // Lets the thread that started it list it under `## TASKS`.
108
+ initialState: originChannelId && originThreadId
109
+ ? { origin: { channelId: originChannelId, threadId: originThreadId } }
110
+ : {},
97
111
  });
98
112
  const { agentId } = await ctx.host.startThreadRun({
99
113
  channelId: targetChannelId,
@@ -103,6 +117,7 @@ export const startThreadTools = {
103
117
  await emitStartThreadWidget(ctx, {
104
118
  widgetId,
105
119
  channelId: targetChannelId,
120
+ threadId,
106
121
  title: threadTitle,
107
122
  body: `Started a thread in #${targetChannelId}.`,
108
123
  state: 'submitted',
@@ -0,0 +1,89 @@
1
+ import z from "zod";
2
+ import { normalizeChannelId } from "./storage.js";
3
+ import { todoService } from "./todo-service.js";
4
+ import { formatTaskDetail, formatTaskList, listTasks, taskFromState, withRunning, } from "../tasks.js";
5
+ const LIST_DEFAULT_LIMIT = 20;
6
+ const LIST_MAX_LIMIT = 50;
7
+ const STATUS_VALUES = [
8
+ "working",
9
+ "needs_input",
10
+ "ready_for_review",
11
+ "completed",
12
+ "archived",
13
+ ];
14
+ export const tasksTools = {
15
+ list_tasks: {
16
+ description: "List tasks (threads) with their state and last reply, most recently active first. Use it when a task the human asks about is not in `## TASKS`, or to see tasks in a Space. Read-only.",
17
+ inputSchema: z.object({
18
+ channelId: z
19
+ .string()
20
+ .optional()
21
+ .describe("Space id from `## SPACES`. Omit to list tasks in every Space."),
22
+ fromThisThread: z
23
+ .boolean()
24
+ .optional()
25
+ .describe("Only tasks started from the current thread with `start_thread`."),
26
+ status: z
27
+ .array(z.enum(STATUS_VALUES))
28
+ .optional()
29
+ .describe("Only these statuses. Archived tasks are left out unless listed here."),
30
+ limit: z.number().int().min(1).max(LIST_MAX_LIMIT).optional(),
31
+ }),
32
+ execute: async (raw, ctx) => {
33
+ const args = (raw ?? {});
34
+ try {
35
+ const channelId = args.channelId ? normalizeChannelId(args.channelId) : undefined;
36
+ const currentChannelId = ctx.channelId ?? ctx.state.channelId;
37
+ const currentThreadId = ctx.threadId ?? ctx.state.threadId;
38
+ const origin = args.fromThisThread && currentChannelId && currentThreadId
39
+ ? { channelId: currentChannelId, threadId: currentThreadId }
40
+ : undefined;
41
+ const { tasks, total } = await listTasks(ctx.storage, ctx.host, {
42
+ ...(channelId ? { channelId } : {}),
43
+ ...(origin ? { origin } : {}),
44
+ ...(args.status?.length ? { status: args.status, excludeStatus: [] } : {}),
45
+ limit: Math.min(args.limit ?? LIST_DEFAULT_LIMIT, LIST_MAX_LIMIT),
46
+ });
47
+ return { success: true, total, output: formatTaskList(tasks, total) };
48
+ }
49
+ catch (error) {
50
+ const message = error instanceof Error ? error.message : "Unknown error";
51
+ return { success: false, error: message, output: `Failed to list tasks: ${message}` };
52
+ }
53
+ },
54
+ },
55
+ get_task: {
56
+ description: "Read one task: its state, the request, the full last reply, and its todos. Use it to answer how a task went. Read-only.",
57
+ inputSchema: z.object({
58
+ channelId: z.string().describe("The task's Space id."),
59
+ threadId: z.string().describe("The task's thread id."),
60
+ }),
61
+ execute: async (raw, ctx) => {
62
+ const args = (raw ?? {});
63
+ const channelId = normalizeChannelId(args.channelId ?? "");
64
+ const threadId = typeof args.threadId === "string" ? args.threadId.trim() : "";
65
+ if (!channelId || !threadId) {
66
+ const error = "channelId and threadId are required";
67
+ return { success: false, error, output: error };
68
+ }
69
+ try {
70
+ const details = await ctx.storage.getThreadDetails({ channelId, threadId });
71
+ const state = details.state;
72
+ if (!state || typeof state !== "object" || Object.keys(state).length === 0) {
73
+ const error = `No task ${threadId} in #${channelId}.`;
74
+ return { success: false, error, output: error };
75
+ }
76
+ const task = withRunning(taskFromState(channelId, threadId, state), ctx.host);
77
+ const todos = await todoService
78
+ .getTodos({ channelId, threadId })
79
+ .then((list) => list.items)
80
+ .catch(() => []);
81
+ return { success: true, output: formatTaskDetail(task, todos) };
82
+ }
83
+ catch (error) {
84
+ const message = error instanceof Error ? error.message : "Unknown error";
85
+ return { success: false, error: message, output: `Failed to read task: ${message}` };
86
+ }
87
+ },
88
+ },
89
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meetopenbot/openbot",
3
- "version": "1.8.0",
3
+ "version": "1.10.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.4.0"
28
+ "@meetopenbot/plugin-sdk": "^2.5.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;