@meetopenbot/openbot 1.9.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,6 +2,7 @@ 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";
@@ -32,6 +33,7 @@ export function buildOpenbotTools(args) {
32
33
  ...todoTools,
33
34
  ...storageTools,
34
35
  ...startThreadTools,
36
+ ...tasksTools,
35
37
  ...askAgentTools,
36
38
  ...askUserTools,
37
39
  ...installAgentTools,
package/dist/context.js CHANGED
@@ -3,6 +3,7 @@ import { formatAvailableAgentsSection, formatInstalledAgentsSection, loadAgentMo
3
3
  import { todoService } from "./tools/todo-service.js";
4
4
  import { readInvokeSurface, surfaceSection } from "./surface.js";
5
5
  import { CONVERSATION_INSTRUCTIONS, isConversationThread } from "./conversation.js";
6
+ import { buildTasksSection } from "./tasks.js";
6
7
  export const DEFAULT_CONTEXT_BUDGET = 8000;
7
8
  export const MAX_CONTEXT_FILES = 50;
8
9
  /**
@@ -97,6 +98,17 @@ export async function buildContext(state, storage, host) {
97
98
  console.warn("[context] Failed to fetch todos:", error);
98
99
  }
99
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
+ }
100
112
  // 2.6 Installed Agents
101
113
  const otherAgents = allAgents.filter((agent) => agent.id !== agentId);
102
114
  if (otherAgents.length > 0) {
@@ -11,6 +11,7 @@ export const CONVERSATION_INSTRUCTIONS = [
11
11
  '## CONVERSATION',
12
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
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.',
14
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.',
15
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.',
16
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.',
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
+ }
@@ -97,12 +97,17 @@ export const startThreadTools = {
97
97
  body: 'Starting…',
98
98
  state: 'open',
99
99
  });
100
+ const originChannelId = ctx.channelId ?? ctx.state.channelId;
101
+ const originThreadId = ctx.threadId ?? ctx.state.threadId;
100
102
  try {
101
103
  await ctx.storage.createThread({
102
104
  channelId: targetChannelId,
103
105
  threadId,
104
106
  ...(threadTitle ? { threadTitle } : {}),
105
- initialState: {},
107
+ // Lets the thread that started it list it under `## TASKS`.
108
+ initialState: originChannelId && originThreadId
109
+ ? { origin: { channelId: originChannelId, threadId: originThreadId } }
110
+ : {},
106
111
  });
107
112
  const { agentId } = await ctx.host.startThreadRun({
108
113
  channelId: targetChannelId,
@@ -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.9.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",