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