@oar-sh/oar 0.9.3 → 0.9.4
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/.github/extensions/web-relay/extension.mjs +6 -1
- package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
- package/CHANGELOG.md +67 -0
- package/README.md +62 -13
- package/package.json +5 -2
- package/server/README.md +43 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
- package/server/claude-worker/claude-session-process.mjs +92 -25
- package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
- package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
- package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
- package/server/db-schema.mjs +8 -0
- package/server/grok-worker/acp-client.mjs +92 -6
- package/server/grok-worker/grok-turn-runner.mjs +59 -8
- package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
- package/server/mcp/oar-mcp-server.mjs +473 -0
- package/server/migrations/0005-remote-relays.mjs +90 -0
- package/server/public/app/action-confirmations.js +141 -13
- package/server/public/app/api-client.js +103 -0
- package/server/public/app/bootstrap.js +21 -6
- package/server/public/app/conversation-deep-link.mjs +42 -0
- package/server/public/app/conversation-view.js +98 -7
- package/server/public/app/host-suspend-ui.js +272 -0
- package/server/public/app/journal-view.js +4 -1
- package/server/public/app/mention-autocomplete.mjs +298 -0
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/pwa-install.js +8 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
- package/server/public/app/remote-relay-origin-view.mjs +76 -0
- package/server/public/app/remote-relay-shared.mjs +85 -0
- package/server/public/app/remote-relays-settings.js +618 -0
- package/server/public/app/remote-relays-store.mjs +126 -0
- package/server/public/app/settings-modal.js +4 -2
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +18 -1
- package/server/public/app/thinking-follow.js +260 -0
- package/server/public/app/tool-activity-emoji.mjs +4 -0
- package/server/public/index.html +257 -8
- package/server/relay-tools.md +5 -1
- package/server/repositories/message-repository.mjs +7 -0
- package/server/repositories/question-repository.mjs +3 -0
- package/server/repositories/remote-relay-repository.mjs +75 -0
- package/server/repositories/session-repository.mjs +6 -1
- package/server/routes/ask-user-routes.mjs +42 -2
- package/server/routes/messages-routes.mjs +90 -16
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +80 -29
- package/server/server-runtime.mjs +238 -0
- package/server/services/github-ci-activity-service.mjs +199 -0
- package/server/services/host-suspend-command.mjs +38 -0
- package/server/services/host-suspend-service.mjs +254 -0
- package/server/services/push-dispatch-service.mjs +37 -4
- package/server/services/remote-relay-caller-context.mjs +341 -0
- package/server/services/remote-relay-client.mjs +332 -0
- package/server/services/remote-relay-dispatcher.mjs +1500 -0
- package/server/services/remote-relay-inbound.mjs +187 -0
- package/server/services/remote-relay-pairing.mjs +189 -0
- package/server/services/remote-relay-registry-service.mjs +581 -0
- package/server/services/session-worker-launch-service.mjs +131 -12
- package/shared/remote-relay-contract.mjs +539 -0
- package/shared/remote-relay-mentions.mjs +129 -0
- package/shared/remote-relay-tool-core.mjs +314 -0
- package/shared/worker-runtime/api-client.mjs +112 -22
|
@@ -0,0 +1,1500 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { randomUUID as nodeRandomUUID } from 'crypto';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
REMOTE_RELAY_APPROVAL_MODES,
|
|
7
|
+
REMOTE_RELAY_ERROR_CODES,
|
|
8
|
+
REMOTE_RELAY_LIMITS,
|
|
9
|
+
REMOTE_RELAY_PROVIDERS,
|
|
10
|
+
isRemoteRelayApprovalQuestion,
|
|
11
|
+
isRemoteRelayWriteAction,
|
|
12
|
+
normalizeRemoteRelayOrigin,
|
|
13
|
+
remoteConversationUrl,
|
|
14
|
+
remoteRelayPermissionAllows,
|
|
15
|
+
REMOTE_RELAY_ACTION_PERMISSION,
|
|
16
|
+
stripRemotePromptHeader,
|
|
17
|
+
summarizeRemoteRelayCall,
|
|
18
|
+
validateRemoteRelayToolInput,
|
|
19
|
+
withRemotePromptHeader,
|
|
20
|
+
} from '../../shared/remote-relay-contract.mjs';
|
|
21
|
+
import { schemaFields } from '../../shared/question-schema.mjs';
|
|
22
|
+
|
|
23
|
+
// The one place a `remote_relay` tool call is decided and carried out (plan
|
|
24
|
+
// §5.6). Every provider adapter ends up at POST /api/remote-relays/tool, whose
|
|
25
|
+
// handler calls dispatch(). In order:
|
|
26
|
+
//
|
|
27
|
+
// validate → list_relays (local, no gate) → resolve the relay → mention gate
|
|
28
|
+
// → permission → hop limit → rate limit → approval (write actions in
|
|
29
|
+
// ask/plan) → execute against the remote relay's existing API
|
|
30
|
+
//
|
|
31
|
+
// The remote is reached only through the outbound client (auth, origin and
|
|
32
|
+
// hop headers are its job). Reply detection reads the remote's own transcript:
|
|
33
|
+
// the assistant row whose sourceMessageId is the message we queued, with the
|
|
34
|
+
// steering settle kinds (folded / absorbed / stopped) resolved the way the
|
|
35
|
+
// remote's UI resolves them.
|
|
36
|
+
//
|
|
37
|
+
// Nothing here logs prompt or reply text: a write action leaves one line with
|
|
38
|
+
// the action, the relay, the remote session and a result code.
|
|
39
|
+
|
|
40
|
+
const LOG_PREFIX = '[remote-relays]';
|
|
41
|
+
|
|
42
|
+
// An unexpected failure inside this relay.
|
|
43
|
+
export const REMOTE_RELAY_INTERNAL_ERROR_CODE = REMOTE_RELAY_ERROR_CODES.internal;
|
|
44
|
+
|
|
45
|
+
const RATE_WINDOW_MS = 60_000;
|
|
46
|
+
// Rows after the sent message the wait loop reads per poll. A reply sits a few
|
|
47
|
+
// rows behind its prompt even in a busy, steered session.
|
|
48
|
+
const WAIT_PAGE_LIMIT = 30;
|
|
49
|
+
// The most messages GET /api/conversation/:id returns in one page.
|
|
50
|
+
const HISTORY_PAGE_MAX = 100;
|
|
51
|
+
// When a page after the sent message comes back full, the wait reads on, up to
|
|
52
|
+
// this many pages (a late wait in a busy session: the reply is further back).
|
|
53
|
+
const AFTER_SCAN_PAGES = 10;
|
|
54
|
+
// /api/conversations caps a page at 100; a filtered listing (query or active)
|
|
55
|
+
// scans at most this many pages to fill one page of results.
|
|
56
|
+
const LIST_PAGE_MAX = 100;
|
|
57
|
+
const LIST_SCAN_PAGES = 5;
|
|
58
|
+
const PROGRESS_TEXT_MAX = 600;
|
|
59
|
+
// How long a session that worked in the background must stay quiet (no task,
|
|
60
|
+
// no turn in flight or queued) before its last follow-up counts as the end.
|
|
61
|
+
const FOLLOW_UP_SETTLE_MS = 10_000;
|
|
62
|
+
const PROGRESS_ACTIVITY_COUNT = 5;
|
|
63
|
+
const ACTIVITY_LINE_MAX = 300;
|
|
64
|
+
const ACTIVITY_LINES_PER_MESSAGE = 20;
|
|
65
|
+
const REPLY_TEXT_MAX = REMOTE_RELAY_LIMITS.perMessageChars * 2;
|
|
66
|
+
// A message that would only fit as a stub is dropped instead (read_session).
|
|
67
|
+
const MIN_TRIMMED_MESSAGE_CHARS = 200;
|
|
68
|
+
const TITLE_MAX = 80;
|
|
69
|
+
|
|
70
|
+
// The provider settings routes a relay serves; github (Copilot) has none with
|
|
71
|
+
// models — its catalogue is /api/models plus /api/status defaultModel.
|
|
72
|
+
const SETTINGS_PROVIDERS = Object.freeze(['claude', 'cursor', 'grok', 'openai']);
|
|
73
|
+
// How /api/models `providersByModel` names each provider.
|
|
74
|
+
const CATALOG_PROVIDER_LABELS = Object.freeze({
|
|
75
|
+
github: 'github-copilot',
|
|
76
|
+
openai: 'openai-byok',
|
|
77
|
+
claude: 'claude',
|
|
78
|
+
cursor: 'cursor',
|
|
79
|
+
grok: 'grok',
|
|
80
|
+
});
|
|
81
|
+
// Worker statuses that mean "working on something right now" (scope active).
|
|
82
|
+
const WORKING_WORKER_STATUSES = new Set(['starting', 'processing']);
|
|
83
|
+
// Terminal turn failures are stored as ordinary assistant rows; these are the
|
|
84
|
+
// texts the relay writes for them (buildTerminalFailureTextForChat and the two
|
|
85
|
+
// recovery-limit paths in messages-routes / server-runtime). Each is the whole
|
|
86
|
+
// reply and opens with its marker, or (the stable-code form) with a short
|
|
87
|
+
// message followed by "Error code: relay.<code>." in the same paragraph. So a
|
|
88
|
+
// marker counts only there: a normal reply that quotes a relay error further
|
|
89
|
+
// down is a reply, not a failure.
|
|
90
|
+
const FAILURE_TEXT_PATTERNS = Object.freeze([
|
|
91
|
+
/^(?:(?!\n[ \t]*\r?\n)[\s\S]){0,600}?\berror code:\s*relay\.[a-z0-9-]+\.(?=\s|$)/i,
|
|
92
|
+
/^relay recovery limit reached after \d+ attempts/i,
|
|
93
|
+
/^relay timeout after \d+ attempts/i,
|
|
94
|
+
]);
|
|
95
|
+
const CLAUDE_LONG_CONTEXT_SUFFIX = /\[1m\]$/i;
|
|
96
|
+
// Assistant rows that are markers, not replies of their own.
|
|
97
|
+
const STUB_KINDS = new Set(['folded', 'stopped']);
|
|
98
|
+
|
|
99
|
+
function toText(value) {
|
|
100
|
+
return String(value ?? '').trim();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isPlainObject(value) {
|
|
104
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function shortId(value) {
|
|
108
|
+
return toText(value).slice(0, 8) || '-';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function nonNegativeInt(value) {
|
|
112
|
+
const number = Number(value);
|
|
113
|
+
return Number.isInteger(number) && number >= 0 ? number : 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Keeps the head and the tail of a long text: the context and the conclusion. */
|
|
117
|
+
function clipMiddle(value, max) {
|
|
118
|
+
const text = String(value ?? '');
|
|
119
|
+
if (text.length <= max) return { text, truncated: false };
|
|
120
|
+
const marker = ` … [${text.length - max} characters trimmed] … `;
|
|
121
|
+
const room = Math.max(0, max - marker.length);
|
|
122
|
+
const head = Math.ceil(room * 0.6);
|
|
123
|
+
return { text: `${text.slice(0, head)}${marker}${text.slice(text.length - (room - head))}`, truncated: true };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The newest part of a text that is still growing (a live stream). */
|
|
127
|
+
function clipTail(value, max) {
|
|
128
|
+
const text = String(value ?? '');
|
|
129
|
+
return text.length <= max ? text : `…${text.slice(text.length - (max - 1))}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function clipLine(value, max) {
|
|
133
|
+
const text = toText(value).replace(/\s+/g, ' ');
|
|
134
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function firstLine(value, max) {
|
|
138
|
+
const line = String(value ?? '').split(/\r?\n/).map((part) => part.trim()).find(Boolean) || '';
|
|
139
|
+
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function encodeCursor(cursor) {
|
|
143
|
+
return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The cursor object the agent passed back, or null when it is not one of ours. */
|
|
147
|
+
function decodeCursor(value, keys) {
|
|
148
|
+
try {
|
|
149
|
+
const parsed = JSON.parse(Buffer.from(toText(value), 'base64url').toString('utf8'));
|
|
150
|
+
if (!isPlainObject(parsed)) return null;
|
|
151
|
+
const cursor = {};
|
|
152
|
+
for (const key of keys) {
|
|
153
|
+
const text = toText(parsed[key]);
|
|
154
|
+
if (text) cursor[key] = text.slice(0, 200);
|
|
155
|
+
}
|
|
156
|
+
return Object.keys(cursor).length ? cursor : null;
|
|
157
|
+
} catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function compactQuery(query = {}) {
|
|
163
|
+
const out = {};
|
|
164
|
+
for (const [key, value] of Object.entries(query)) {
|
|
165
|
+
if (value === undefined || value === null || value === '') continue;
|
|
166
|
+
out[key] = value;
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function looksLikeFailureText(text) {
|
|
172
|
+
const value = toText(text);
|
|
173
|
+
return FAILURE_TEXT_PATTERNS.some((pattern) => pattern.test(value));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function normalizeProvider(value) {
|
|
177
|
+
const provider = toText(value).toLowerCase();
|
|
178
|
+
return REMOTE_RELAY_PROVIDERS.includes(provider) ? provider : '';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function uniqueTexts(values) {
|
|
182
|
+
const seen = new Set();
|
|
183
|
+
const out = [];
|
|
184
|
+
for (const value of values) {
|
|
185
|
+
const text = toText(value);
|
|
186
|
+
if (!text || seen.has(text.toLowerCase())) continue;
|
|
187
|
+
seen.add(text.toLowerCase());
|
|
188
|
+
out.push(text);
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The providers /api/models lists for a model id (keys may keep their casing). */
|
|
194
|
+
function catalogProvidersFor(providersByModel, model) {
|
|
195
|
+
if (!isPlainObject(providersByModel)) return [];
|
|
196
|
+
const direct = providersByModel[model] || providersByModel[String(model).toLowerCase()];
|
|
197
|
+
const list = Array.isArray(direct)
|
|
198
|
+
? direct
|
|
199
|
+
: (Object.entries(providersByModel).find(([key]) => key.toLowerCase() === String(model).toLowerCase())?.[1] || []);
|
|
200
|
+
return Array.isArray(list) ? list.map((entry) => toText(entry).toLowerCase()).filter(Boolean) : [];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The remote's own id for `candidate` among `offered`: a case-insensitive
|
|
205
|
+
* match, or the same Claude model without / with its "[1m]" long-context
|
|
206
|
+
* suffix. '' when the remote does not offer it.
|
|
207
|
+
*/
|
|
208
|
+
function matchOfferedModel(candidate, offered = []) {
|
|
209
|
+
const wanted = toText(candidate).toLowerCase();
|
|
210
|
+
if (!wanted) return '';
|
|
211
|
+
const exact = offered.find((model) => model.toLowerCase() === wanted);
|
|
212
|
+
if (exact) return exact;
|
|
213
|
+
const wantedBase = wanted.replace(CLAUDE_LONG_CONTEXT_SUFFIX, '');
|
|
214
|
+
return offered.find((model) => model.toLowerCase().replace(CLAUDE_LONG_CONTEXT_SUFFIX, '') === wantedBase) || '';
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function isRemoteRelayError(error) {
|
|
218
|
+
return error?.name === 'RemoteRelayError' || /^REMOTE_/.test(toText(error?.code));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** A failure that says nothing about whether the request got through: no connection, a timeout, a 5xx. */
|
|
222
|
+
function isTransientRemoteError(error) {
|
|
223
|
+
if (!isRemoteRelayError(error)) return false;
|
|
224
|
+
return toText(error.code) === REMOTE_RELAY_ERROR_CODES.offline || Number(error.status) >= 500;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// POST /api/message answers a replayed messageId with this (409).
|
|
228
|
+
const DUPLICATE_MESSAGE_ID_CODE = 'DUPLICATE_MESSAGE_ID';
|
|
229
|
+
|
|
230
|
+
function isOwnDuplicateMessage(error, messageId) {
|
|
231
|
+
const body = isPlainObject(error?.remoteBody) ? error.remoteBody : null;
|
|
232
|
+
if (Number(error?.status) !== 409 || toText(body?.code) !== DUPLICATE_MESSAGE_ID_CODE) return false;
|
|
233
|
+
return !toText(body.messageId) || toText(body.messageId) === messageId;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A refusal decided on this relay, with the status and code the agent sees. */
|
|
237
|
+
class DispatchFailure extends Error {
|
|
238
|
+
constructor(status, code, message, extra = {}) {
|
|
239
|
+
super(message);
|
|
240
|
+
this.name = 'DispatchFailure';
|
|
241
|
+
this.status = status;
|
|
242
|
+
this.code = code;
|
|
243
|
+
this.extra = extra;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function failure(status, code, error, extra = {}) {
|
|
248
|
+
return { status, body: { ok: false, code, error, ...extra } };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function invalidInput(error) {
|
|
252
|
+
return new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, error);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const PERMISSION_DESCRIPTIONS = Object.freeze({
|
|
256
|
+
read: 'only read',
|
|
257
|
+
prompt: 'only read and send prompts',
|
|
258
|
+
full: 'do everything',
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
function relayTarget(relay) {
|
|
262
|
+
return { id: relay.id, name: relay.name, url: relay.url };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The question card's view of a relay question: what the agent needs to answer
|
|
267
|
+
* it. Null for the remote's own remote-relay approval cards, which only its
|
|
268
|
+
* user may answer (the remote hides them from us as well).
|
|
269
|
+
*/
|
|
270
|
+
function mapPendingQuestion(question) {
|
|
271
|
+
if (!isPlainObject(question) || !toText(question.id)) return null;
|
|
272
|
+
if (isRemoteRelayApprovalQuestion(question)) return null;
|
|
273
|
+
const context = isPlainObject(question.context) ? question.context : {};
|
|
274
|
+
const fields = schemaFields(question.requestSchema);
|
|
275
|
+
return {
|
|
276
|
+
id: toText(question.id),
|
|
277
|
+
question: toText(question.prompt),
|
|
278
|
+
header: toText(context.header) || null,
|
|
279
|
+
options: Array.isArray(question.choices) ? question.choices.map((choice) => toText(choice)).filter(Boolean) : [],
|
|
280
|
+
multiSelect: context.multiSelect === true,
|
|
281
|
+
allowFreeform: question.allowFreeform !== false,
|
|
282
|
+
...(fields.length ? {
|
|
283
|
+
fields: fields.map((field) => ({
|
|
284
|
+
name: field.name,
|
|
285
|
+
title: field.title,
|
|
286
|
+
type: field.type,
|
|
287
|
+
required: field.required,
|
|
288
|
+
...(field.choices.length ? { options: field.choices.map((choice) => String(choice.value)) } : {}),
|
|
289
|
+
})),
|
|
290
|
+
} : {}),
|
|
291
|
+
message_id: toText(question.messageId) || null,
|
|
292
|
+
expiresAt: question.expiresAt || null,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function activityTexts(activities, { max = ACTIVITY_LINES_PER_MESSAGE, fromEnd = false } = {}) {
|
|
297
|
+
const lines = (Array.isArray(activities) ? activities : [])
|
|
298
|
+
.map((entry) => (typeof entry === 'string' ? entry : entry?.text))
|
|
299
|
+
.map((line) => clipLine(line, ACTIVITY_LINE_MAX))
|
|
300
|
+
.filter(Boolean);
|
|
301
|
+
return fromEnd ? lines.slice(-max) : lines.slice(0, max);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* What a turn in flight is doing: its last main-thread stream text and its
|
|
306
|
+
* last activity lines. `messageId` names the message the caller waits for, so
|
|
307
|
+
* the snapshot can say whether that message's own turn is running.
|
|
308
|
+
*/
|
|
309
|
+
function progressSnapshot(inFlight, messageId = '') {
|
|
310
|
+
if (!isPlainObject(inFlight)) return null;
|
|
311
|
+
const events = Array.isArray(inFlight.streamEvents) ? inFlight.streamEvents : [];
|
|
312
|
+
const mainThread = events.filter((event) => !event?.subagentRunId);
|
|
313
|
+
const lastEvent = (mainThread.length ? mainThread : events).at(-1);
|
|
314
|
+
const turnMessageId = toText(inFlight.messageId) || null;
|
|
315
|
+
const own = !messageId || turnMessageId === messageId;
|
|
316
|
+
return {
|
|
317
|
+
status: own ? 'running' : 'waiting_behind_turn',
|
|
318
|
+
turnMessageId,
|
|
319
|
+
startedAt: inFlight.processingAt || inFlight.timestamp || null,
|
|
320
|
+
text: clipTail(toText(lastEvent?.text), PROGRESS_TEXT_MAX),
|
|
321
|
+
activities: activityTexts(inFlight.activities, { max: PROGRESS_ACTIVITY_COUNT, fromEnd: true }),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const FINISHED_TASK_STATUSES = new Set(['completed', 'complete', 'done', 'failed', 'stopped', 'killed', 'cancelled', 'canceled']);
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Whether the remote agent keeps working after it answered `replyMessageId`:
|
|
329
|
+
* a Claude agent often starts a background task, ends its turn with an
|
|
330
|
+
* interim reply ("started it in the background"), and answers for real in a
|
|
331
|
+
* follow-up turn (kind `continuation`) once the task reports back. `rows` are
|
|
332
|
+
* the rows after the sent message, oldest first; `page` is the conversation
|
|
333
|
+
* read they came with. A later user message ends the follow-up: from there
|
|
334
|
+
* the session is working for someone else.
|
|
335
|
+
*/
|
|
336
|
+
export function remoteFollowUpState(rows, replyMessageId, page, { sawBackgroundWork = false } = {}) {
|
|
337
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
338
|
+
const start = list.findIndex((row) => toText(row?.id) === replyMessageId);
|
|
339
|
+
const afterReply = start === -1 ? [] : list.slice(start + 1);
|
|
340
|
+
const nextUser = afterReply.findIndex((row) => row?.role === 'user');
|
|
341
|
+
const window = nextUser === -1 ? afterReply : afterReply.slice(0, nextUser);
|
|
342
|
+
const followUps = window.filter((row) => row?.role === 'assistant' && toText(row?.kind).toLowerCase() === 'continuation');
|
|
343
|
+
const tasks = (Array.isArray(page?.backgroundTasks) ? page.backgroundTasks : [])
|
|
344
|
+
.filter((task) => !FINISHED_TASK_STATUSES.has(toText(task?.status).toLowerCase()));
|
|
345
|
+
const handedOver = nextUser !== -1;
|
|
346
|
+
// A queued follow-up turn shows only as activeTurn (between a task's end and
|
|
347
|
+
// the follow-up turn starting); on its own, activeTurn is too weak a sign, so
|
|
348
|
+
// it counts once background work or a follow-up was seen for this message.
|
|
349
|
+
const backgroundSeen = sawBackgroundWork || tasks.length > 0 || followUps.length > 0;
|
|
350
|
+
const working = !handedOver && (
|
|
351
|
+
tasks.length > 0
|
|
352
|
+
|| (isPlainObject(page?.inFlight) && backgroundSeen)
|
|
353
|
+
|| (page?.activeTurn === true && backgroundSeen)
|
|
354
|
+
);
|
|
355
|
+
return {
|
|
356
|
+
backgroundSeen,
|
|
357
|
+
working,
|
|
358
|
+
latest: followUps.at(-1) || null,
|
|
359
|
+
followUpCount: followUps.length,
|
|
360
|
+
tasks: tasks.slice(0, 5).map((task) => clipLine(task?.description || task?.summary || task?.title || task?.taskId || 'background task', 120)),
|
|
361
|
+
taskCount: tasks.length,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function replyOf(row, extra = {}) {
|
|
366
|
+
const clipped = clipMiddle(toText(row?.text), REPLY_TEXT_MAX);
|
|
367
|
+
return {
|
|
368
|
+
text: clipped.text,
|
|
369
|
+
model: toText(row?.model) || null,
|
|
370
|
+
messageId: toText(row?.id) || null,
|
|
371
|
+
...(clipped.truncated ? { truncated: true } : {}),
|
|
372
|
+
...extra,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* The outcome for `messageId` from the rows after it (oldest first), or null
|
|
378
|
+
* while there is none yet. `{ foldedWithoutReply:true }` means the message was
|
|
379
|
+
* folded into a turn whose reply has not appeared (yet).
|
|
380
|
+
*/
|
|
381
|
+
export function settleRemoteReply(messages, messageId) {
|
|
382
|
+
const rows = Array.isArray(messages) ? messages : [];
|
|
383
|
+
const own = rows.find((row) => row?.role === 'assistant' && toText(row?.sourceMessageId) === messageId);
|
|
384
|
+
if (!own) return null;
|
|
385
|
+
const kind = toText(own.kind).toLowerCase();
|
|
386
|
+
if (kind === 'stopped') {
|
|
387
|
+
return { status: 'stopped', note: 'The remote turn was stopped before it answered this message.' };
|
|
388
|
+
}
|
|
389
|
+
if (kind === 'folded') {
|
|
390
|
+
const answer = rows.find((row) => row?.role === 'assistant'
|
|
391
|
+
&& row !== own
|
|
392
|
+
&& (!toText(row.kind) || toText(row.kind).toLowerCase() === 'absorbed'));
|
|
393
|
+
if (!answer) return { foldedWithoutReply: true };
|
|
394
|
+
const note = 'The message was steered into the turn that was already running; this is that turn\'s reply.';
|
|
395
|
+
if (looksLikeFailureText(answer.text)) return { status: 'failed', reply: replyOf(answer, { folded: true }), note };
|
|
396
|
+
return { status: 'done', reply: replyOf(answer, { folded: true }), note };
|
|
397
|
+
}
|
|
398
|
+
if (looksLikeFailureText(own.text)) {
|
|
399
|
+
return { status: 'failed', reply: replyOf(own), note: 'The remote turn failed; the reply is the relay\'s failure text.' };
|
|
400
|
+
}
|
|
401
|
+
if (kind === 'absorbed') {
|
|
402
|
+
return {
|
|
403
|
+
status: 'done',
|
|
404
|
+
reply: replyOf(own, { absorbed: true }),
|
|
405
|
+
note: 'The remote agent picked up a later message mid-reply, so the turn continued there; read_session shows the rest.',
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
return { status: 'done', reply: replyOf(own) };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function createRemoteRelayDispatcher({
|
|
412
|
+
registry,
|
|
413
|
+
client,
|
|
414
|
+
repository,
|
|
415
|
+
getCallerContext = async () => null,
|
|
416
|
+
requestApproval = async () => false,
|
|
417
|
+
now = Date.now,
|
|
418
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
419
|
+
logger = console,
|
|
420
|
+
emitStatusEvent = () => {},
|
|
421
|
+
pollIntervalMs = 2000,
|
|
422
|
+
randomUUID = nodeRandomUUID,
|
|
423
|
+
// The OAR MCP server names its session by SDK session id, which older
|
|
424
|
+
// conversations do not share with their conversation id: map it back, so
|
|
425
|
+
// the unlock gate and the caller context read the right conversation.
|
|
426
|
+
resolveConversationId = (id) => id,
|
|
427
|
+
} = {}) {
|
|
428
|
+
const inflightByConversation = new Map();
|
|
429
|
+
const callTimesByConversation = new Map();
|
|
430
|
+
const writeTimesByConversation = new Map();
|
|
431
|
+
|
|
432
|
+
// ─── Bookkeeping ───────────────────────────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
function inflight(conversationId) {
|
|
435
|
+
const requested = toText(conversationId);
|
|
436
|
+
let resolved = requested;
|
|
437
|
+
try {
|
|
438
|
+
resolved = toText(resolveConversationId(requested)) || requested;
|
|
439
|
+
} catch {}
|
|
440
|
+
return inflightByConversation.get(resolved) || 0;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// The runtime passes no repository when migration 0005 failed this boot:
|
|
444
|
+
// nothing is unlocked then (fail closed).
|
|
445
|
+
function isUnlocked(conversationId, relayId) {
|
|
446
|
+
try {
|
|
447
|
+
return !!repository?.hasUnlock?.(conversationId, relayId);
|
|
448
|
+
} catch {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function adjustInflight(conversationId, delta) {
|
|
454
|
+
const next = inflight(conversationId) + delta;
|
|
455
|
+
if (next > 0) inflightByConversation.set(conversationId, next);
|
|
456
|
+
else inflightByConversation.delete(conversationId);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function recentTimes(map, conversationId, cutoff) {
|
|
460
|
+
const times = (map.get(conversationId) || []).filter((at) => at > cutoff);
|
|
461
|
+
if (times.length) map.set(conversationId, times);
|
|
462
|
+
else map.delete(conversationId);
|
|
463
|
+
return times;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Sliding one-minute windows per conversation. Null when allowed (and counted). */
|
|
467
|
+
function consumeRateLimit(conversationId, write) {
|
|
468
|
+
const at = now();
|
|
469
|
+
const cutoff = at - RATE_WINDOW_MS;
|
|
470
|
+
const calls = recentTimes(callTimesByConversation, conversationId, cutoff);
|
|
471
|
+
const writes = recentTimes(writeTimesByConversation, conversationId, cutoff);
|
|
472
|
+
const limited = calls.length >= REMOTE_RELAY_LIMITS.callsPerMinute
|
|
473
|
+
? calls
|
|
474
|
+
: (write && writes.length >= REMOTE_RELAY_LIMITS.writesPerMinute ? writes : null);
|
|
475
|
+
if (limited) {
|
|
476
|
+
return Math.max(1, Math.ceil((limited[0] + RATE_WINDOW_MS - at) / 1000));
|
|
477
|
+
}
|
|
478
|
+
callTimesByConversation.set(conversationId, [...calls, at]);
|
|
479
|
+
if (write) writeTimesByConversation.set(conversationId, [...writes, at]);
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function auditWrite({ conversationId, action, relay, session, outcome, ok }) {
|
|
484
|
+
const relayName = toText(relay?.name) || '-';
|
|
485
|
+
try {
|
|
486
|
+
logger?.log?.(`${LOG_PREFIX} ${action} ${relayName} ${shortId(session)} → ${outcome} conv=${shortId(conversationId)}`);
|
|
487
|
+
} catch {}
|
|
488
|
+
try {
|
|
489
|
+
emitStatusEvent({
|
|
490
|
+
type: 'remote_relay',
|
|
491
|
+
action,
|
|
492
|
+
relay: relayName,
|
|
493
|
+
relayId: toText(relay?.id) || null,
|
|
494
|
+
session: toText(session) || null,
|
|
495
|
+
result: outcome,
|
|
496
|
+
ok: !!ok,
|
|
497
|
+
conversationId,
|
|
498
|
+
});
|
|
499
|
+
} catch {}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ─── Remote calls ──────────────────────────────────────────────────────────
|
|
503
|
+
|
|
504
|
+
function call(ctx, method, path, { query, body, timeoutMs } = {}) {
|
|
505
|
+
return client.request(ctx.relay, method, path, {
|
|
506
|
+
...(query ? { query: compactQuery(query) } : {}),
|
|
507
|
+
...(body !== undefined ? { body } : {}),
|
|
508
|
+
...(timeoutMs ? { timeoutMs } : {}),
|
|
509
|
+
hops: ctx.hops,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function sessionPath(session, suffix = '') {
|
|
514
|
+
return `/api/conversation/${encodeURIComponent(session)}${suffix}`;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function loadPendingQuestions(ctx, conversationId) {
|
|
518
|
+
try {
|
|
519
|
+
const response = await call(ctx, 'GET', '/api/relay-questions', {
|
|
520
|
+
query: { status: 'pending', conversationId },
|
|
521
|
+
});
|
|
522
|
+
return (Array.isArray(response?.questions) ? response.questions : [])
|
|
523
|
+
.map(mapPendingQuestion)
|
|
524
|
+
.filter(Boolean);
|
|
525
|
+
} catch {
|
|
526
|
+
return [];
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Whether nothing is queued or running in the remote conversation. Uses the
|
|
532
|
+
* payload's `activeTurn` when the remote sends one; otherwise (it only has
|
|
533
|
+
* `inFlight`, which misses a row still waiting for its worker) the
|
|
534
|
+
* relay-wide queue counts, which are conservative: any queued work anywhere
|
|
535
|
+
* reads as "not idle".
|
|
536
|
+
*/
|
|
537
|
+
async function remoteConversationIdle(ctx, page) {
|
|
538
|
+
if (page?.inFlight) return false;
|
|
539
|
+
if (page?.activeTurn === true) return false;
|
|
540
|
+
if (page?.activeTurn === false) return true;
|
|
541
|
+
try {
|
|
542
|
+
const status = await call(ctx, 'GET', '/api/status');
|
|
543
|
+
const queued = nonNegativeInt(status?.pendingCount)
|
|
544
|
+
+ nonNegativeInt(status?.processingCount)
|
|
545
|
+
+ nonNegativeInt(status?.parkedCount);
|
|
546
|
+
return queued === 0;
|
|
547
|
+
} catch {
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function buildOrigin(ctx) {
|
|
553
|
+
const self = (await registry.selfIdentity?.()) || {};
|
|
554
|
+
return normalizeRemoteRelayOrigin({
|
|
555
|
+
relayId: self.relayId,
|
|
556
|
+
relayName: self.name || (await registry.selfName?.()),
|
|
557
|
+
relayUrl: self.publicUrl || '',
|
|
558
|
+
conversationId: ctx.caller.conversationId || ctx.conversationId,
|
|
559
|
+
conversationTitle: ctx.caller.title,
|
|
560
|
+
provider: ctx.caller.provider,
|
|
561
|
+
model: ctx.caller.model,
|
|
562
|
+
hops: ctx.hops,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* The rows after the sent message: `page` (read with `limit`), and while
|
|
568
|
+
* pages come back full, the ones after it, up to `maxPages` pages in all.
|
|
569
|
+
* `truncated` when there were more than that.
|
|
570
|
+
*/
|
|
571
|
+
async function rowsAfterMessage(ctx, session, page, limit, maxPages) {
|
|
572
|
+
const rows = Array.isArray(page?.messages) ? [...page.messages] : [];
|
|
573
|
+
let current = page;
|
|
574
|
+
let currentLimit = limit;
|
|
575
|
+
for (let pages = 1; ; pages += 1) {
|
|
576
|
+
const got = Array.isArray(current?.messages) ? current.messages : [];
|
|
577
|
+
const hasMoreNewer = current?.pageInfo?.hasMoreNewer;
|
|
578
|
+
const more = typeof hasMoreNewer === 'boolean' ? hasMoreNewer : got.length >= currentLimit;
|
|
579
|
+
const cursor = toText(got.at(-1)?.id);
|
|
580
|
+
if (!more || !cursor) return { rows, truncated: false };
|
|
581
|
+
if (pages >= maxPages) return { rows, truncated: true };
|
|
582
|
+
currentLimit = HISTORY_PAGE_MAX;
|
|
583
|
+
current = await call(ctx, 'GET', sessionPath(session), {
|
|
584
|
+
query: { afterMessageId: cursor, limit: currentLimit },
|
|
585
|
+
});
|
|
586
|
+
rows.push(...(Array.isArray(current?.messages) ? current.messages : []));
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Polls the remote transcript until `messageId` is answered, settles
|
|
592
|
+
* otherwise, or `waitSeconds` run out. One read per poll in the common case.
|
|
593
|
+
* The prompt is queued by then, so a failed read never surfaces as a bare
|
|
594
|
+
* error (which reads as "not sent" and invites a second send or a second
|
|
595
|
+
* session): the result stays `queued` / `running` with a note to wait again.
|
|
596
|
+
*/
|
|
597
|
+
async function waitForReply(ctx, { session, messageId, waitSeconds }) {
|
|
598
|
+
const seen = { inFlight: null };
|
|
599
|
+
try {
|
|
600
|
+
return await pollForReply(ctx, { session, messageId, waitSeconds, seen });
|
|
601
|
+
} catch (error) {
|
|
602
|
+
const remote = isRemoteRelayError(error);
|
|
603
|
+
const code = remote ? (toText(error.code) || REMOTE_RELAY_ERROR_CODES.offline) : REMOTE_RELAY_INTERNAL_ERROR_CODE;
|
|
604
|
+
if (!remote) {
|
|
605
|
+
try {
|
|
606
|
+
logger?.warn?.(`${LOG_PREFIX} wait failed conv=${shortId(ctx.conversationId)}: ${toText(error?.message || error).slice(0, 200)}`);
|
|
607
|
+
} catch {}
|
|
608
|
+
}
|
|
609
|
+
return {
|
|
610
|
+
session,
|
|
611
|
+
message_id: messageId,
|
|
612
|
+
status: seen.inFlight ? 'running' : 'queued',
|
|
613
|
+
progress: progressSnapshot(seen.inFlight, messageId) || { status: 'queued', text: '', activities: [] },
|
|
614
|
+
note: `The wait failed (${code}): the prompt was queued; call wait with this session and message_id to keep waiting. Do not send it again.`,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async function pollForReply(ctx, { session, messageId, waitSeconds, seen }) {
|
|
620
|
+
const deadline = now() + Math.max(0, waitSeconds) * 1000;
|
|
621
|
+
const base = { session, message_id: messageId };
|
|
622
|
+
let idleSeen = false;
|
|
623
|
+
let firstRead = true;
|
|
624
|
+
for (;;) {
|
|
625
|
+
// The confirming read before "cancelled" looks as far as the remote
|
|
626
|
+
// allows per page, for a reply that sits unusually far behind its
|
|
627
|
+
// message. It and the first read (a late wait) follow a full page with
|
|
628
|
+
// the pages after it; the polls in between watch the first page only.
|
|
629
|
+
const limit = idleSeen ? HISTORY_PAGE_MAX : WAIT_PAGE_LIMIT;
|
|
630
|
+
const page = await call(ctx, 'GET', sessionPath(session), {
|
|
631
|
+
query: { afterMessageId: messageId, limit },
|
|
632
|
+
});
|
|
633
|
+
const inFlight = isPlainObject(page?.inFlight) ? page.inFlight : null;
|
|
634
|
+
seen.inFlight = inFlight;
|
|
635
|
+
const after = await rowsAfterMessage(ctx, session, page, limit, firstRead || idleSeen ? AFTER_SCAN_PAGES : 1);
|
|
636
|
+
firstRead = false;
|
|
637
|
+
const conversationId = toText(page?.id) || session;
|
|
638
|
+
const settled = settleRemoteReply(after.rows, messageId);
|
|
639
|
+
if (settled && !settled.foldedWithoutReply) {
|
|
640
|
+
if (settled.status !== 'done' || !settled.reply?.messageId) return { ...base, ...settled };
|
|
641
|
+
const follow = remoteFollowUpState(after.rows, settled.reply.messageId, page, { sawBackgroundWork: seen.background === true });
|
|
642
|
+
if (follow.backgroundSeen) seen.background = true;
|
|
643
|
+
const latestReply = follow.latest ? replyOf(follow.latest, { followUp: true }) : settled.reply;
|
|
644
|
+
if (!follow.working) {
|
|
645
|
+
if (!follow.backgroundSeen) return { ...base, ...settled };
|
|
646
|
+
// After background work, a quiet moment is not the end yet: the
|
|
647
|
+
// worker publishes a new task set up to 2 s late, and a finished
|
|
648
|
+
// task opens its follow-up turn a moment after. Done only once the
|
|
649
|
+
// session stays quiet for the whole settle window.
|
|
650
|
+
if (!seen.quietSince) seen.quietSince = now();
|
|
651
|
+
if (now() - seen.quietSince >= FOLLOW_UP_SETTLE_MS) {
|
|
652
|
+
return {
|
|
653
|
+
...base,
|
|
654
|
+
status: 'done',
|
|
655
|
+
reply: latestReply,
|
|
656
|
+
...(follow.latest ? { firstReply: settled.reply } : {}),
|
|
657
|
+
note: follow.latest
|
|
658
|
+
? `The remote agent finished work it had continued in the background; this is its last follow-up reply (${follow.followUpCount} in all, read_session shows every one).`
|
|
659
|
+
: 'The remote agent\'s background work ended without a follow-up reply; the reply above is its only one.',
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
} else {
|
|
663
|
+
seen.quietSince = null;
|
|
664
|
+
}
|
|
665
|
+
if (inFlight) {
|
|
666
|
+
const pendingQuestions = await loadPendingQuestions(ctx, toText(page?.id) || session);
|
|
667
|
+
if (pendingQuestions.length) {
|
|
668
|
+
return {
|
|
669
|
+
...base,
|
|
670
|
+
status: 'waiting_for_answer',
|
|
671
|
+
reply: latestReply,
|
|
672
|
+
pendingQuestions,
|
|
673
|
+
progress: progressSnapshot(inFlight),
|
|
674
|
+
note: 'The remote agent answered, kept working in the background and now waits for an answer. Ask the user (unless they told you to decide), then call answer_question and wait again.',
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
const remainingForFollowUp = deadline - now();
|
|
679
|
+
if (remainingForFollowUp <= 0 || ctx.signal?.aborted) {
|
|
680
|
+
return {
|
|
681
|
+
...base,
|
|
682
|
+
status: 'running',
|
|
683
|
+
reply: latestReply,
|
|
684
|
+
...(follow.latest ? { firstReply: settled.reply } : {}),
|
|
685
|
+
background: { tasks: follow.taskCount, running: follow.tasks },
|
|
686
|
+
progress: progressSnapshot(inFlight) || { status: 'background', text: '', activities: [] },
|
|
687
|
+
note: 'The remote agent answered but keeps working in the background; the reply above is its latest so far. Call wait with this session and message_id for the follow-up.',
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
await sleep(Math.min(pollIntervalMs, remainingForFollowUp));
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (inFlight) {
|
|
695
|
+
idleSeen = false;
|
|
696
|
+
const pendingQuestions = await loadPendingQuestions(ctx, conversationId);
|
|
697
|
+
if (pendingQuestions.length) {
|
|
698
|
+
return {
|
|
699
|
+
...base,
|
|
700
|
+
status: 'waiting_for_answer',
|
|
701
|
+
pendingQuestions,
|
|
702
|
+
progress: progressSnapshot(inFlight, messageId),
|
|
703
|
+
note: 'The remote agent is waiting for an answer. Ask the user (unless they told you to decide), then call answer_question and wait again.',
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
} else if (await remoteConversationIdle(ctx, page)) {
|
|
707
|
+
if (settled?.foldedWithoutReply) {
|
|
708
|
+
return {
|
|
709
|
+
...base,
|
|
710
|
+
status: 'done',
|
|
711
|
+
note: 'The message was steered into a turn that has finished, but its reply was not found after the message; use read_session to see it.',
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
// A reply and the end of its turn are committed together, but the two
|
|
715
|
+
// reads above are not one snapshot: look once more before calling the
|
|
716
|
+
// message cancelled.
|
|
717
|
+
if (idleSeen && after.truncated) {
|
|
718
|
+
return {
|
|
719
|
+
...base,
|
|
720
|
+
status: 'done',
|
|
721
|
+
note: `Nothing is queued or running in the remote session, but no reply to this message was found in the ${after.rows.length} messages after it; use read_session to look further back.`,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
if (idleSeen) {
|
|
725
|
+
return {
|
|
726
|
+
...base,
|
|
727
|
+
status: 'cancelled',
|
|
728
|
+
note: 'Nothing is queued or running in the remote session and the message got no reply (it was cancelled or removed there).',
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
idleSeen = true;
|
|
732
|
+
continue;
|
|
733
|
+
} else {
|
|
734
|
+
idleSeen = false;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const remaining = deadline - now();
|
|
738
|
+
if (remaining <= 0 || ctx.signal?.aborted) {
|
|
739
|
+
return {
|
|
740
|
+
...base,
|
|
741
|
+
status: inFlight ? 'running' : 'queued',
|
|
742
|
+
progress: progressSnapshot(inFlight, messageId) || { status: 'queued', text: '', activities: [] },
|
|
743
|
+
note: 'Not finished yet. Call wait with this session and message_id to keep waiting.',
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
await sleep(Math.min(pollIntervalMs, remaining));
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// ─── Remote catalogue (relay_info, create_session) ─────────────────────────
|
|
751
|
+
|
|
752
|
+
async function loadCatalog(ctx) {
|
|
753
|
+
const [status, models, copilot, ...settings] = await Promise.allSettled([
|
|
754
|
+
call(ctx, 'GET', '/api/status'),
|
|
755
|
+
call(ctx, 'GET', '/api/models'),
|
|
756
|
+
call(ctx, 'GET', '/api/settings/copilot'),
|
|
757
|
+
...SETTINGS_PROVIDERS.map((provider) => call(ctx, 'GET', `/api/settings/${provider}`)),
|
|
758
|
+
]);
|
|
759
|
+
if (status.status === 'rejected') throw status.reason;
|
|
760
|
+
const value = (result) => (result.status === 'fulfilled' && isPlainObject(result.value) ? result.value : null);
|
|
761
|
+
return {
|
|
762
|
+
status: value(status) || {},
|
|
763
|
+
models: value(models),
|
|
764
|
+
copilot: value(copilot),
|
|
765
|
+
settings: Object.fromEntries(SETTINGS_PROVIDERS.map((provider, index) => [provider, value(settings[index])])),
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** `[{ provider, configured, defaultModel, models }]` in contract order. */
|
|
770
|
+
function buildProviders(catalog) {
|
|
771
|
+
const models = catalog.models || {};
|
|
772
|
+
const providersByModel = isPlainObject(models.providersByModel) ? models.providersByModel : {};
|
|
773
|
+
const catalogModels = Array.isArray(models.models) ? models.models.map(toText).filter(Boolean) : [];
|
|
774
|
+
const modelsLabelled = (label) => Object.entries(providersByModel)
|
|
775
|
+
.filter(([, providers]) => Array.isArray(providers) && providers.some((entry) => toText(entry).toLowerCase() === label))
|
|
776
|
+
.map(([model]) => model);
|
|
777
|
+
|
|
778
|
+
return REMOTE_RELAY_PROVIDERS.map((provider) => {
|
|
779
|
+
if (provider === 'github') {
|
|
780
|
+
// Copilot's catalogue is every model no other provider claims alone.
|
|
781
|
+
const githubModels = catalogModels.filter((model) => {
|
|
782
|
+
const providers = catalogProvidersFor(providersByModel, model);
|
|
783
|
+
return providers.length === 0 || providers.includes(CATALOG_PROVIDER_LABELS.github);
|
|
784
|
+
});
|
|
785
|
+
const defaultModel = toText(catalog.status.defaultModel) || toText(models.defaultModel) || toText(models.currentModel) || null;
|
|
786
|
+
return {
|
|
787
|
+
provider,
|
|
788
|
+
configured: true,
|
|
789
|
+
defaultModel,
|
|
790
|
+
models: uniqueTexts([...(defaultModel ? [defaultModel] : []), ...githubModels]),
|
|
791
|
+
...(toText(catalog.copilot?.engine) ? { engine: toText(catalog.copilot.engine) } : {}),
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
const settings = catalog.settings[provider];
|
|
795
|
+
if (!settings) return { provider, configured: false, defaultModel: null, models: [] };
|
|
796
|
+
// What POST /api/conversation/bootstrap requires, plus "not switched off".
|
|
797
|
+
const configured = settings.enabled === true
|
|
798
|
+
&& (provider === 'claude' || provider === 'grok' || settings.configured === true);
|
|
799
|
+
const defaultModel = toText(settings.model) || null;
|
|
800
|
+
const listed = provider === 'openai'
|
|
801
|
+
? modelsLabelled(CATALOG_PROVIDER_LABELS.openai)
|
|
802
|
+
: (Array.isArray(settings.models) ? settings.models : []);
|
|
803
|
+
return {
|
|
804
|
+
provider,
|
|
805
|
+
configured,
|
|
806
|
+
defaultModel,
|
|
807
|
+
models: configured ? uniqueTexts([...(defaultModel ? [defaultModel] : []), ...listed]) : [],
|
|
808
|
+
};
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// ─── Actions ───────────────────────────────────────────────────────────────
|
|
813
|
+
|
|
814
|
+
async function relayInfo(ctx) {
|
|
815
|
+
const [identity, catalog] = await Promise.all([
|
|
816
|
+
call(ctx, 'GET', '/api/relay/identity').catch(() => null),
|
|
817
|
+
loadCatalog(ctx),
|
|
818
|
+
]);
|
|
819
|
+
const status = catalog.status;
|
|
820
|
+
const providers = buildProviders(catalog);
|
|
821
|
+
const remoteRelays = isPlainObject(identity?.remoteRelays) ? identity.remoteRelays : null;
|
|
822
|
+
const configured = providers.filter((entry) => entry.configured).map((entry) => entry.provider);
|
|
823
|
+
const version = toText(status.version) || toText(identity?.version) || null;
|
|
824
|
+
return {
|
|
825
|
+
summary: `relay_info → ${ctx.relay.name}: OAR ${version || '?'}, providers ${configured.join(', ') || 'none'}`,
|
|
826
|
+
name: toText(identity?.name) || ctx.relay.name,
|
|
827
|
+
url: ctx.relay.url,
|
|
828
|
+
openUrl: remoteConversationUrl(ctx.relay.url, ''),
|
|
829
|
+
version,
|
|
830
|
+
platform: toText(status.platform) || toText(identity?.platform) || null,
|
|
831
|
+
protocol: remoteRelays ? nonNegativeInt(remoteRelays.protocol) : 0,
|
|
832
|
+
acceptsAgentPrompts: remoteRelays ? remoteRelays.inbound !== false : null,
|
|
833
|
+
providers,
|
|
834
|
+
workspaces: {
|
|
835
|
+
default: toText(status.defaultSessionWorkspaceRootPath) || toText(status.workspaceRootPath) || null,
|
|
836
|
+
current: toText(status.workspaceRootPath) || null,
|
|
837
|
+
recent: Array.isArray(status.recentWorkspaceRoots) ? status.recentWorkspaceRoots.map(toText).filter(Boolean) : [],
|
|
838
|
+
},
|
|
839
|
+
relayModes: Array.isArray(status.supportedRelayModes) ? status.supportedRelayModes : [],
|
|
840
|
+
defaultRelayMode: toText(status.defaultRelayMode) || null,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
async function liveWorkerConversationIds(ctx) {
|
|
845
|
+
try {
|
|
846
|
+
const status = await call(ctx, 'GET', '/api/status');
|
|
847
|
+
const workers = Array.isArray(status?.sessionWorker?.workers) ? status.sessionWorker.workers : [];
|
|
848
|
+
return new Set(workers
|
|
849
|
+
.filter((worker) => WORKING_WORKER_STATUSES.has(toText(worker?.status).toLowerCase()))
|
|
850
|
+
.map((worker) => toText(worker?.conversationId))
|
|
851
|
+
.filter(Boolean));
|
|
852
|
+
} catch {
|
|
853
|
+
return new Set();
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function mapSession(row, workerIds) {
|
|
858
|
+
const id = toText(row?.id);
|
|
859
|
+
const via = toText(row?.origin?.relayName);
|
|
860
|
+
return {
|
|
861
|
+
id,
|
|
862
|
+
title: toText(row?.title),
|
|
863
|
+
provider: toText(row?.runtimeProviderType) || 'github',
|
|
864
|
+
model: toText(row?.runtimeProviderModel) || toText(row?.runtimeModel) || toText(row?.preferredModel) || null,
|
|
865
|
+
active: row?.activeTurn === true || !!workerIds?.has(id),
|
|
866
|
+
updatedAt: row?.updatedAt || null,
|
|
867
|
+
messageCount: nonNegativeInt(row?.messageCount),
|
|
868
|
+
cwd: toText(row?.currentWorkspaceRootPath) || null,
|
|
869
|
+
...(via ? { via } : {}),
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
async function listSessions(ctx, args) {
|
|
874
|
+
const { scope, limit } = args;
|
|
875
|
+
const query = toText(args.query).toLowerCase();
|
|
876
|
+
// recent = the newest page, active = working sessions among the newest
|
|
877
|
+
// pages, all = the whole list page by page (only it honours the cursor).
|
|
878
|
+
let cursor = null;
|
|
879
|
+
if (scope === 'all' && args.cursor) {
|
|
880
|
+
cursor = decodeCursor(args.cursor, ['beforeConversationId', 'beforeUpdatedAt']);
|
|
881
|
+
if (!cursor) throw invalidInput('cursor is not a nextCursor from list_sessions');
|
|
882
|
+
}
|
|
883
|
+
const workerIds = scope === 'active' ? await liveWorkerConversationIds(ctx) : null;
|
|
884
|
+
const filtering = !!query || scope === 'active';
|
|
885
|
+
const keep = (row) => (!query || toText(row?.title).toLowerCase().includes(query))
|
|
886
|
+
&& (scope !== 'active' || row?.activeTurn === true || workerIds.has(toText(row?.id)));
|
|
887
|
+
|
|
888
|
+
const sessions = [];
|
|
889
|
+
let hasMore = false;
|
|
890
|
+
let scanned = 0;
|
|
891
|
+
for (let page = 0; page < (filtering ? LIST_SCAN_PAGES : 1); page += 1) {
|
|
892
|
+
const response = await call(ctx, 'GET', '/api/conversations', {
|
|
893
|
+
query: {
|
|
894
|
+
limit: filtering ? LIST_PAGE_MAX : limit,
|
|
895
|
+
beforeConversationId: cursor?.beforeConversationId,
|
|
896
|
+
beforeUpdatedAt: cursor?.beforeUpdatedAt,
|
|
897
|
+
},
|
|
898
|
+
});
|
|
899
|
+
const rows = Array.isArray(response?.conversations) ? response.conversations : [];
|
|
900
|
+
let consumed = 0;
|
|
901
|
+
for (const row of rows) {
|
|
902
|
+
consumed += 1;
|
|
903
|
+
if (keep(row)) sessions.push(mapSession(row, workerIds));
|
|
904
|
+
if (sessions.length >= limit) break;
|
|
905
|
+
}
|
|
906
|
+
scanned += consumed;
|
|
907
|
+
const last = rows[consumed - 1];
|
|
908
|
+
if (last) cursor = { beforeConversationId: toText(last.id), beforeUpdatedAt: toText(last.updatedAt) };
|
|
909
|
+
hasMore = consumed < rows.length || response?.pageInfo?.hasMore === true;
|
|
910
|
+
if (sessions.length >= limit || !hasMore || !rows.length) break;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const nextCursor = scope === 'all' && hasMore && cursor ? encodeCursor(cursor) : null;
|
|
914
|
+
const note = filtering && hasMore && sessions.length < limit && scope !== 'all'
|
|
915
|
+
? `Searched the newest ${scanned} sessions only; use scope "all" with the cursor to search further.`
|
|
916
|
+
: null;
|
|
917
|
+
return {
|
|
918
|
+
summary: `list_sessions → ${ctx.relay.name}: ${sessions.length} session${sessions.length === 1 ? '' : 's'} (${scope}${query ? `, "${clipLine(args.query, 40)}"` : ''})`,
|
|
919
|
+
scope,
|
|
920
|
+
sessions,
|
|
921
|
+
nextCursor,
|
|
922
|
+
...(note ? { note } : {}),
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function messageText(row) {
|
|
927
|
+
const text = String(row?.text ?? '');
|
|
928
|
+
// The header line is ours (or another relay's): the badge fields say the
|
|
929
|
+
// same, so the agent reads the prompt itself.
|
|
930
|
+
return row?.role === 'user' && row?.origin ? stripRemotePromptHeader(text) : text;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Newest first within `maxChars`. Each message is capped at its share of
|
|
935
|
+
* the budget, but never below perMessageChars: `last:1, max_chars:20000`
|
|
936
|
+
* returns a 9000-character reply whole, ten messages in the default 12000
|
|
937
|
+
* still get 4000 each. A trimmed message keeps its head and its tail.
|
|
938
|
+
*/
|
|
939
|
+
function shapeMessages(rows, { maxChars, includeActivity }) {
|
|
940
|
+
const list = Array.isArray(rows) ? rows.filter(Boolean) : [];
|
|
941
|
+
const out = [];
|
|
942
|
+
const perMessageMax = Math.max(REMOTE_RELAY_LIMITS.perMessageChars, Math.floor(maxChars / Math.max(1, list.length)));
|
|
943
|
+
let budget = maxChars;
|
|
944
|
+
let omitted = 0;
|
|
945
|
+
for (let index = list.length - 1; index >= 0; index -= 1) {
|
|
946
|
+
const row = list[index];
|
|
947
|
+
const perMessage = clipMiddle(messageText(row), perMessageMax);
|
|
948
|
+
let text = perMessage.text;
|
|
949
|
+
let truncated = perMessage.truncated;
|
|
950
|
+
if (text.length > budget) {
|
|
951
|
+
if (budget < MIN_TRIMMED_MESSAGE_CHARS && out.length) {
|
|
952
|
+
omitted = index + 1;
|
|
953
|
+
break;
|
|
954
|
+
}
|
|
955
|
+
text = clipMiddle(text, Math.max(budget, MIN_TRIMMED_MESSAGE_CHARS)).text;
|
|
956
|
+
truncated = true;
|
|
957
|
+
}
|
|
958
|
+
budget -= text.length;
|
|
959
|
+
const role = toText(row.role) || 'assistant';
|
|
960
|
+
const via = toText(row?.origin?.relayName);
|
|
961
|
+
const activities = includeActivity && role === 'assistant' ? activityTexts(row.activities) : [];
|
|
962
|
+
out.unshift({
|
|
963
|
+
id: toText(row.id),
|
|
964
|
+
role,
|
|
965
|
+
time: row.timestamp || null,
|
|
966
|
+
...(toText(row.kind) ? { kind: toText(row.kind) } : {}),
|
|
967
|
+
...(role === 'assistant' && toText(row.model) ? { model: toText(row.model) } : {}),
|
|
968
|
+
...(role === 'assistant' && toText(row.sourceMessageId) ? { replyTo: toText(row.sourceMessageId) } : {}),
|
|
969
|
+
...(via ? { via } : {}),
|
|
970
|
+
text,
|
|
971
|
+
...(truncated ? { truncated: true } : {}),
|
|
972
|
+
...(activities.length ? { activities } : {}),
|
|
973
|
+
});
|
|
974
|
+
if (budget <= 0 && index > 0) {
|
|
975
|
+
omitted = index;
|
|
976
|
+
break;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
return { messages: out, omitted };
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
async function readSession(ctx, args) {
|
|
983
|
+
let before = null;
|
|
984
|
+
if (args.before) {
|
|
985
|
+
before = decodeCursor(args.before, ['beforeMessageId', 'beforeTimestamp']);
|
|
986
|
+
if (!before) throw invalidInput('before is not an olderCursor from read_session');
|
|
987
|
+
}
|
|
988
|
+
const conversation = await call(ctx, 'GET', sessionPath(args.session), {
|
|
989
|
+
query: { limit: args.last, beforeMessageId: before?.beforeMessageId, beforeTimestamp: before?.beforeTimestamp },
|
|
990
|
+
});
|
|
991
|
+
const conversationId = toText(conversation?.id) || args.session;
|
|
992
|
+
const pendingQuestions = await loadPendingQuestions(ctx, conversationId);
|
|
993
|
+
const { messages, omitted } = shapeMessages(conversation?.messages, {
|
|
994
|
+
maxChars: args.max_chars,
|
|
995
|
+
includeActivity: args.include_activity,
|
|
996
|
+
});
|
|
997
|
+
let olderCursor = null;
|
|
998
|
+
if (omitted > 0 && messages.length) {
|
|
999
|
+
olderCursor = encodeCursor({ beforeMessageId: messages[0].id, beforeTimestamp: toText(messages[0].time) });
|
|
1000
|
+
} else if (conversation?.pageInfo?.hasMoreOlder && isPlainObject(conversation.pageInfo.olderCursor)) {
|
|
1001
|
+
olderCursor = encodeCursor({
|
|
1002
|
+
beforeMessageId: toText(conversation.pageInfo.olderCursor.beforeMessageId),
|
|
1003
|
+
beforeTimestamp: toText(conversation.pageInfo.olderCursor.beforeTimestamp),
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
|
|
1007
|
+
const via = toText(conversation?.origin?.relayName);
|
|
1008
|
+
const inFlight = progressSnapshot(conversation?.inFlight);
|
|
1009
|
+
return {
|
|
1010
|
+
summary: `read_session → ${ctx.relay.name} session ${shortId(conversationId)}: ${messages.length} message${messages.length === 1 ? '' : 's'}${inFlight ? ', turn running' : ''}${pendingQuestions.length ? `, ${pendingQuestions.length} question${pendingQuestions.length === 1 ? '' : 's'} pending` : ''}`,
|
|
1011
|
+
session: {
|
|
1012
|
+
id: conversationId,
|
|
1013
|
+
title: toText(conversation?.title),
|
|
1014
|
+
provider: toText(runtime.providerType) || 'github',
|
|
1015
|
+
model: toText(runtime.providerModel) || toText(conversation?.preferredModel) || toText(runtime.model) || null,
|
|
1016
|
+
mode: toText(conversation?.preferredRelayMode) || null,
|
|
1017
|
+
cwd: toText(conversation?.currentWorkspaceRootPath) || null,
|
|
1018
|
+
archived: conversation?.archived === true,
|
|
1019
|
+
...(via ? { via } : {}),
|
|
1020
|
+
openUrl: remoteConversationUrl(ctx.relay.url, conversationId),
|
|
1021
|
+
},
|
|
1022
|
+
messages,
|
|
1023
|
+
...(inFlight ? { inFlight } : {}),
|
|
1024
|
+
pendingQuestions,
|
|
1025
|
+
...(olderCursor ? { olderCursor } : {}),
|
|
1026
|
+
...(omitted > 0 ? { note: `${omitted} older message${omitted === 1 ? '' : 's'} left out to stay within max_chars; pass olderCursor as before to read them.` } : {}),
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function outcomeSummary(action, ctx, outcome) {
|
|
1031
|
+
const questions = Array.isArray(outcome.pendingQuestions) ? outcome.pendingQuestions.length : 0;
|
|
1032
|
+
return `${action} → ${ctx.relay.name} session ${shortId(outcome.session)}: ${outcome.status}`
|
|
1033
|
+
+ (questions ? `, ${questions} question${questions === 1 ? '' : 's'} pending` : '');
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
async function waitAction(ctx, args) {
|
|
1037
|
+
// An unknown message id would read as "no reply, nothing running" forever.
|
|
1038
|
+
const around = await call(ctx, 'GET', sessionPath(args.session), {
|
|
1039
|
+
query: { aroundMessageId: args.message_id, limit: 1 },
|
|
1040
|
+
});
|
|
1041
|
+
const found = (Array.isArray(around?.messages) ? around.messages : []).some((row) => toText(row?.id) === args.message_id);
|
|
1042
|
+
if (!found) {
|
|
1043
|
+
throw new DispatchFailure(404, REMOTE_RELAY_ERROR_CODES.notFound,
|
|
1044
|
+
`Message ${args.message_id} is not in session ${args.session} on relay "${ctx.relay.name}".`);
|
|
1045
|
+
}
|
|
1046
|
+
const session = toText(around?.id) || args.session;
|
|
1047
|
+
const outcome = await waitForReply(ctx, { session, messageId: args.message_id, waitSeconds: args.wait_seconds });
|
|
1048
|
+
return { summary: outcomeSummary('wait', ctx, outcome), ...outcome };
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* Queues the prompt. A send whose connection fails may still have landed,
|
|
1053
|
+
* and the remote dedups on messageId, so it is replayed once with the same
|
|
1054
|
+
* id: a 409 DUPLICATE_MESSAGE_ID then means the first attempt got through.
|
|
1055
|
+
* When the replay fails too, nobody knows; the error carries the session
|
|
1056
|
+
* and message id so the agent checks with wait instead of sending again.
|
|
1057
|
+
*/
|
|
1058
|
+
async function postPrompt(ctx, { session, text, origin, model, mode }) {
|
|
1059
|
+
const messageId = String(randomUUID());
|
|
1060
|
+
const body = {
|
|
1061
|
+
conversationId: session,
|
|
1062
|
+
text: withRemotePromptHeader(text, origin),
|
|
1063
|
+
messageId,
|
|
1064
|
+
origin,
|
|
1065
|
+
...(model ? { model } : {}),
|
|
1066
|
+
...(mode ? { relayMode: mode } : {}),
|
|
1067
|
+
};
|
|
1068
|
+
let sent;
|
|
1069
|
+
try {
|
|
1070
|
+
sent = await call(ctx, 'POST', '/api/message', { body });
|
|
1071
|
+
} catch (error) {
|
|
1072
|
+
if (!isTransientRemoteError(error)) throw error;
|
|
1073
|
+
try {
|
|
1074
|
+
sent = await call(ctx, 'POST', '/api/message', { body });
|
|
1075
|
+
} catch (retryError) {
|
|
1076
|
+
if (isOwnDuplicateMessage(retryError, messageId)) return { duplicate: false, messageId };
|
|
1077
|
+
if (!isTransientRemoteError(retryError)) throw retryError;
|
|
1078
|
+
throw new DispatchFailure(502, toText(retryError.code) || REMOTE_RELAY_ERROR_CODES.offline,
|
|
1079
|
+
`${toText(retryError.message) || `Relay "${ctx.relay.name}" did not answer`}; the prompt may or may not have been queued.`,
|
|
1080
|
+
{
|
|
1081
|
+
session,
|
|
1082
|
+
message_id: messageId,
|
|
1083
|
+
note: 'Call wait with this session and message_id to find out before sending it again: it answers not found when the prompt never arrived.',
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
if (sent?.duplicate === true) {
|
|
1088
|
+
return {
|
|
1089
|
+
duplicate: true,
|
|
1090
|
+
messageId: toText(sent.duplicateOfMessageId) || null,
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
return { duplicate: false, messageId: toText(sent?.messageId) || messageId };
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
async function deliverAndWait(action, ctx, { session, sent, waitSeconds, extra = {} }) {
|
|
1097
|
+
const openUrl = remoteConversationUrl(ctx.relay.url, session);
|
|
1098
|
+
if (sent.duplicate) {
|
|
1099
|
+
const outcome = {
|
|
1100
|
+
status: 'duplicate',
|
|
1101
|
+
session,
|
|
1102
|
+
message_id: sent.messageId,
|
|
1103
|
+
note: 'The remote relay took this as a repeat of an identical message sent moments ago and did not queue it again. Use wait with this message_id to follow the original.',
|
|
1104
|
+
};
|
|
1105
|
+
return { summary: outcomeSummary(action, ctx, outcome), ...outcome, ...extra, openUrl };
|
|
1106
|
+
}
|
|
1107
|
+
if (waitSeconds <= 0) {
|
|
1108
|
+
const outcome = { status: 'queued', session, message_id: sent.messageId };
|
|
1109
|
+
return { summary: outcomeSummary(action, ctx, outcome), ...outcome, ...extra, openUrl };
|
|
1110
|
+
}
|
|
1111
|
+
const outcome = await waitForReply(ctx, { session, messageId: sent.messageId, waitSeconds });
|
|
1112
|
+
return { summary: outcomeSummary(action, ctx, outcome), ...outcome, ...extra, openUrl };
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
async function send(ctx, args) {
|
|
1116
|
+
const conversation = await call(ctx, 'GET', sessionPath(args.session), { query: { limit: 1 } });
|
|
1117
|
+
const session = toText(conversation?.id) || args.session;
|
|
1118
|
+
if (args.if_busy === 'fail' && (conversation?.inFlight || conversation?.activeTurn === true)) {
|
|
1119
|
+
throw new DispatchFailure(409, REMOTE_RELAY_ERROR_CODES.busy,
|
|
1120
|
+
`Session ${session} on relay "${ctx.relay.name}" is busy with a turn; not sent (if_busy "fail").`,
|
|
1121
|
+
{ session, progress: progressSnapshot(conversation?.inFlight) });
|
|
1122
|
+
}
|
|
1123
|
+
const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
|
|
1124
|
+
const provider = toText(runtime.providerType).toLowerCase() || 'github';
|
|
1125
|
+
// Without a model the remote falls back to its catalogue's current one and
|
|
1126
|
+
// silently switches the session; keep the model the session already uses.
|
|
1127
|
+
// Grok pins its model at bootstrap and refuses any other, so it gets none.
|
|
1128
|
+
const model = args.model
|
|
1129
|
+
|| (provider === 'grok' ? '' : (toText(conversation?.preferredModel) || toText(runtime.providerModel) || toText(runtime.model)));
|
|
1130
|
+
const mode = args.mode || ctx.caller.mode || toText(conversation?.preferredRelayMode) || '';
|
|
1131
|
+
const origin = await buildOrigin(ctx);
|
|
1132
|
+
const sent = await postPrompt(ctx, { session, text: args.text, origin, model, mode });
|
|
1133
|
+
return deliverAndWait('send', ctx, { session, sent, waitSeconds: args.wait_seconds });
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
async function createSession(ctx, args) {
|
|
1137
|
+
const catalog = await loadCatalog(ctx);
|
|
1138
|
+
const providers = buildProviders(catalog);
|
|
1139
|
+
const configured = providers.filter((entry) => entry.configured).map((entry) => entry.provider);
|
|
1140
|
+
const provider = args.provider || normalizeProvider(ctx.caller.provider) || 'github';
|
|
1141
|
+
const entry = providers.find((candidate) => candidate.provider === provider);
|
|
1142
|
+
if (!entry?.configured) {
|
|
1143
|
+
throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.providerUnavailable,
|
|
1144
|
+
`Relay "${ctx.relay.name}" has no ${provider} provider set up. Providers there: ${configured.join(', ') || 'none'}. Pass provider to pick one.`,
|
|
1145
|
+
{ providers: configured });
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
let model = args.model || '';
|
|
1149
|
+
let modelSource = model ? 'requested' : '';
|
|
1150
|
+
let note = null;
|
|
1151
|
+
if (!model) {
|
|
1152
|
+
model = matchOfferedModel(ctx.caller.model, entry.models);
|
|
1153
|
+
modelSource = model ? 'same as this session' : '';
|
|
1154
|
+
}
|
|
1155
|
+
if (!model) {
|
|
1156
|
+
model = entry.defaultModel || entry.models[0] || '';
|
|
1157
|
+
modelSource = 'remote default';
|
|
1158
|
+
if (toText(ctx.caller.model)) {
|
|
1159
|
+
note = `${provider} on relay "${ctx.relay.name}" does not offer ${ctx.caller.model}; the session uses its default model ${model || '(none)'}.`;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
const mode = args.mode || ctx.caller.mode || toText(catalog.status.defaultRelayMode) || '';
|
|
1163
|
+
const title = args.title || firstLine(args.text, TITLE_MAX) || 'Remote session';
|
|
1164
|
+
const origin = await buildOrigin(ctx);
|
|
1165
|
+
|
|
1166
|
+
const bootstrap = await call(ctx, 'POST', '/api/conversation/bootstrap', {
|
|
1167
|
+
body: {
|
|
1168
|
+
providerType: provider,
|
|
1169
|
+
...(model ? { model } : {}),
|
|
1170
|
+
...(mode ? { relayMode: mode } : {}),
|
|
1171
|
+
title,
|
|
1172
|
+
...(args.cwd ? { cwd: args.cwd } : {}),
|
|
1173
|
+
origin,
|
|
1174
|
+
},
|
|
1175
|
+
});
|
|
1176
|
+
const session = toText(bootstrap?.conversationId);
|
|
1177
|
+
if (!session) {
|
|
1178
|
+
throw new DispatchFailure(502, `${REMOTE_RELAY_ERROR_CODES.httpPrefix}502`,
|
|
1179
|
+
`Relay "${ctx.relay.name}" did not return the new session's id.`);
|
|
1180
|
+
}
|
|
1181
|
+
const boundModel = toText(bootstrap?.selectedModel) || model;
|
|
1182
|
+
const sent = await postPrompt(ctx, { session, text: args.text, origin, model: boundModel, mode });
|
|
1183
|
+
return deliverAndWait('create_session', ctx, {
|
|
1184
|
+
session,
|
|
1185
|
+
sent,
|
|
1186
|
+
waitSeconds: args.wait_seconds,
|
|
1187
|
+
extra: {
|
|
1188
|
+
provider: toText(bootstrap?.selectedProviderType) || provider,
|
|
1189
|
+
model: boundModel || null,
|
|
1190
|
+
modelSource,
|
|
1191
|
+
mode: toText(bootstrap?.preferredRelayMode) || mode || null,
|
|
1192
|
+
title,
|
|
1193
|
+
...(toText(bootstrap?.workspaceRootWarning) ? { cwdWarning: toText(bootstrap.workspaceRootWarning) } : {}),
|
|
1194
|
+
...(note ? { modelNote: note } : {}),
|
|
1195
|
+
},
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/** The answer body the remote's answer route expects for this question. */
|
|
1200
|
+
function buildAnswer(question, args) {
|
|
1201
|
+
const fields = schemaFields(question.requestSchema);
|
|
1202
|
+
if (fields.length > 1) {
|
|
1203
|
+
let structured = null;
|
|
1204
|
+
try {
|
|
1205
|
+
structured = JSON.parse(toText(args.answer));
|
|
1206
|
+
} catch {}
|
|
1207
|
+
if (!isPlainObject(structured)) {
|
|
1208
|
+
throw invalidInput(`This question has several fields (${fields.map((field) => field.name).join(', ')}); pass answer as a JSON object with those keys.`);
|
|
1209
|
+
}
|
|
1210
|
+
return { structuredAnswer: structured, text: JSON.stringify(structured) };
|
|
1211
|
+
}
|
|
1212
|
+
const options = Array.isArray(question.choices) ? question.choices.map(toText).filter(Boolean) : [];
|
|
1213
|
+
const labels = [];
|
|
1214
|
+
for (const choice of args.choices || []) {
|
|
1215
|
+
const match = options.find((option) => option.toLowerCase() === choice.toLowerCase()) || '';
|
|
1216
|
+
if (!match && question.allowFreeform === false) {
|
|
1217
|
+
throw invalidInput(`"${clipLine(choice, 80)}" is not an option of this question. Options: ${options.join(', ')}.`);
|
|
1218
|
+
}
|
|
1219
|
+
const label = match || choice;
|
|
1220
|
+
if (!labels.includes(label)) labels.push(label);
|
|
1221
|
+
}
|
|
1222
|
+
// The card's own format: the picked labels, then any typed text, ", "-joined.
|
|
1223
|
+
const extra = toText(args.answer);
|
|
1224
|
+
if (extra && !labels.includes(extra)) labels.push(extra);
|
|
1225
|
+
const text = labels.join(', ');
|
|
1226
|
+
return { answer: text, text };
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
async function answerQuestion(ctx, args) {
|
|
1230
|
+
const fetched = await call(ctx, 'GET', `/api/relay-question/${encodeURIComponent(args.question_id)}`);
|
|
1231
|
+
const question = isPlainObject(fetched?.question) ? fetched.question : null;
|
|
1232
|
+
if (!question) {
|
|
1233
|
+
throw new DispatchFailure(404, REMOTE_RELAY_ERROR_CODES.notFound,
|
|
1234
|
+
`Question ${args.question_id} was not found on relay "${ctx.relay.name}".`);
|
|
1235
|
+
}
|
|
1236
|
+
const session = toText(question.conversationId) || null;
|
|
1237
|
+
if (isRemoteRelayApprovalQuestion(question)) {
|
|
1238
|
+
throw new DispatchFailure(403, REMOTE_RELAY_ERROR_CODES.forbidden,
|
|
1239
|
+
`Question ${args.question_id} is relay "${ctx.relay.name}"'s approval of its own agent's call to another relay; only its user can answer it.`,
|
|
1240
|
+
{ session });
|
|
1241
|
+
}
|
|
1242
|
+
if (toText(question.status) && toText(question.status) !== 'pending') {
|
|
1243
|
+
throw new DispatchFailure(409, `${REMOTE_RELAY_ERROR_CODES.httpPrefix}409`,
|
|
1244
|
+
`Question ${args.question_id} on relay "${ctx.relay.name}" is already ${toText(question.status).replace(/_/g, ' ')}.`,
|
|
1245
|
+
{ session });
|
|
1246
|
+
}
|
|
1247
|
+
const answer = buildAnswer(question, args);
|
|
1248
|
+
// The answer route checks the session the question belongs to; the card
|
|
1249
|
+
// sends the stored one back, and so do we.
|
|
1250
|
+
const sdkSessionId = toText(question.sdkSessionId);
|
|
1251
|
+
await call(ctx, 'POST', `/api/relay-question/${encodeURIComponent(args.question_id)}/answer`, {
|
|
1252
|
+
body: {
|
|
1253
|
+
...(answer.structuredAnswer ? { structuredAnswer: answer.structuredAnswer } : { answer: answer.answer }),
|
|
1254
|
+
...(sdkSessionId ? { sdk_session_id: sdkSessionId } : {}),
|
|
1255
|
+
},
|
|
1256
|
+
});
|
|
1257
|
+
const messageId = toText(question.messageId) || null;
|
|
1258
|
+
return {
|
|
1259
|
+
summary: `answer_question → ${ctx.relay.name} question ${shortId(args.question_id)}: answered`,
|
|
1260
|
+
status: 'answered',
|
|
1261
|
+
question_id: args.question_id,
|
|
1262
|
+
session,
|
|
1263
|
+
message_id: messageId,
|
|
1264
|
+
answer: clipLine(answer.text, 500),
|
|
1265
|
+
note: messageId ? 'Call wait with this session and message_id to follow the turn.' : null,
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
async function stopTurn(ctx, args) {
|
|
1270
|
+
const response = await call(ctx, 'POST', sessionPath(args.session, '/cancel-turn'), { body: {} });
|
|
1271
|
+
const acknowledgement = toText(response?.acknowledgement) || (response?.queued ? 'stop-queued' : 'unknown');
|
|
1272
|
+
const stopRequested = response?.queued === true;
|
|
1273
|
+
const notes = {
|
|
1274
|
+
'no-active-turn': 'Nothing was running in that session.',
|
|
1275
|
+
'active-turn-unbound': 'A turn is running but it is not bound to a worker the relay can stop.',
|
|
1276
|
+
'already-requested': 'A stop was already requested for the running turn.',
|
|
1277
|
+
};
|
|
1278
|
+
return {
|
|
1279
|
+
summary: `stop → ${ctx.relay.name} session ${shortId(args.session)}: ${stopRequested ? 'stop requested' : acknowledgement}`,
|
|
1280
|
+
session: args.session,
|
|
1281
|
+
stopRequested,
|
|
1282
|
+
acknowledgement,
|
|
1283
|
+
message_id: toText(response?.activeMessageId) || null,
|
|
1284
|
+
...(notes[acknowledgement] ? { note: notes[acknowledgement] } : {}),
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
async function archiveSession(ctx, args) {
|
|
1289
|
+
const response = await call(ctx, 'POST', sessionPath(args.session, '/archive'), { body: {} });
|
|
1290
|
+
const gone = response?.alreadyDeleted === true;
|
|
1291
|
+
return {
|
|
1292
|
+
summary: `archive → ${ctx.relay.name} session ${shortId(args.session)}: ${gone ? 'already deleted' : 'archived'}`,
|
|
1293
|
+
session: args.session,
|
|
1294
|
+
archived: !gone,
|
|
1295
|
+
...(gone ? { note: 'The session was already deleted there.' } : {}),
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
const ACTIONS = Object.freeze({
|
|
1300
|
+
relay_info: relayInfo,
|
|
1301
|
+
list_sessions: listSessions,
|
|
1302
|
+
read_session: readSession,
|
|
1303
|
+
wait: waitAction,
|
|
1304
|
+
send,
|
|
1305
|
+
create_session: createSession,
|
|
1306
|
+
answer_question: answerQuestion,
|
|
1307
|
+
stop: stopTurn,
|
|
1308
|
+
archive: archiveSession,
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
// ─── Errors ────────────────────────────────────────────────────────────────
|
|
1312
|
+
|
|
1313
|
+
function mapRemoteError(error, relay, { action, args }) {
|
|
1314
|
+
const C = REMOTE_RELAY_ERROR_CODES;
|
|
1315
|
+
const code = toText(error?.code);
|
|
1316
|
+
const status = Number(error?.status) || null;
|
|
1317
|
+
const message = toText(error?.message) || `Relay "${relay.name}" did not answer as expected`;
|
|
1318
|
+
const remoteBody = isPlainObject(error?.remoteBody) ? error.remoteBody : null;
|
|
1319
|
+
const extra = { relay: relay.name };
|
|
1320
|
+
if (code === C.offline) return failure(502, C.offline, message, extra);
|
|
1321
|
+
if (code === C.unauthorized || status === 401) {
|
|
1322
|
+
return failure(502, C.unauthorized, `${message}. Check this relay in Settings → Relays (token).`, extra);
|
|
1323
|
+
}
|
|
1324
|
+
if (code === C.inboundDisabled || toText(remoteBody?.code) === C.inboundDisabled) {
|
|
1325
|
+
return failure(403, C.inboundDisabled, message, extra);
|
|
1326
|
+
}
|
|
1327
|
+
if (code === C.notFound || status === 404) {
|
|
1328
|
+
const what = action === 'answer_question'
|
|
1329
|
+
? `Question ${args.question_id}`
|
|
1330
|
+
: (args.session ? `Session ${args.session}` : '');
|
|
1331
|
+
return failure(404, C.notFound, what ? `${what} was not found on relay "${relay.name}".` : message, extra);
|
|
1332
|
+
}
|
|
1333
|
+
const remoteCode = toText(remoteBody?.code) || null;
|
|
1334
|
+
const supportedModels = Array.isArray(remoteBody?.supportedModels) ? remoteBody.supportedModels.slice(0, 50) : null;
|
|
1335
|
+
const details = {
|
|
1336
|
+
...extra,
|
|
1337
|
+
...(remoteCode ? { remoteCode } : {}),
|
|
1338
|
+
...(supportedModels ? { supportedModels } : {}),
|
|
1339
|
+
};
|
|
1340
|
+
if (code.startsWith(C.httpPrefix) && status >= 400 && status < 500) return failure(status, code, message, details);
|
|
1341
|
+
return failure(502, code || `${C.httpPrefix}502`, message, details);
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// ─── Pipeline ──────────────────────────────────────────────────────────────
|
|
1345
|
+
|
|
1346
|
+
async function listRelays(conversationId) {
|
|
1347
|
+
const relays = (await registry.list()) || [];
|
|
1348
|
+
const listed = relays.map((relay) => ({
|
|
1349
|
+
name: relay.name,
|
|
1350
|
+
url: relay.url,
|
|
1351
|
+
online: toText(relay.lastStatus).toLowerCase() === 'online',
|
|
1352
|
+
version: relay.version || null,
|
|
1353
|
+
permission: relay.permission,
|
|
1354
|
+
unlocked: isUnlocked(conversationId, relay.id),
|
|
1355
|
+
}));
|
|
1356
|
+
const unlocked = listed.filter((relay) => relay.unlocked).length;
|
|
1357
|
+
return {
|
|
1358
|
+
status: 200,
|
|
1359
|
+
body: {
|
|
1360
|
+
ok: true,
|
|
1361
|
+
summary: `list_relays: ${listed.length} relay${listed.length === 1 ? '' : 's'}, ${unlocked} unlocked`,
|
|
1362
|
+
self: { name: (await registry.selfName?.()) || null },
|
|
1363
|
+
relays: listed,
|
|
1364
|
+
hint: 'Mention @name in the chat to unlock a relay.',
|
|
1365
|
+
},
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
async function runRelayAction({ conversationId, action, args, req, signal }) {
|
|
1370
|
+
const resolved = await registry.resolve(args.relay);
|
|
1371
|
+
if (!resolved?.relay) {
|
|
1372
|
+
const names = Array.isArray(resolved?.names) ? resolved.names : [];
|
|
1373
|
+
const error = resolved?.error === 'ambiguous'
|
|
1374
|
+
? `"${args.relay}" matches several relays (${names.join(', ')}); use the full name.`
|
|
1375
|
+
: `No paired relay is called "${args.relay}". Known relays: ${names.join(', ') || 'none'}.`;
|
|
1376
|
+
return failure(404, REMOTE_RELAY_ERROR_CODES.unknown, error, { relays: names });
|
|
1377
|
+
}
|
|
1378
|
+
const relay = resolved.relay;
|
|
1379
|
+
const write = isRemoteRelayWriteAction(action);
|
|
1380
|
+
const finish = (result, session) => {
|
|
1381
|
+
if (write) {
|
|
1382
|
+
const ok = result.body?.ok === true;
|
|
1383
|
+
auditWrite({
|
|
1384
|
+
conversationId,
|
|
1385
|
+
action,
|
|
1386
|
+
relay,
|
|
1387
|
+
session: session || result.body?.session || args.session,
|
|
1388
|
+
outcome: ok ? (toText(result.body.status) || 'ok') : result.body?.code,
|
|
1389
|
+
ok,
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
return result;
|
|
1393
|
+
};
|
|
1394
|
+
|
|
1395
|
+
if (!isUnlocked(conversationId, relay.id)) {
|
|
1396
|
+
return finish(failure(403, REMOTE_RELAY_ERROR_CODES.locked,
|
|
1397
|
+
`Relay "${relay.name}" is locked in this conversation. Ask the user to mention @${relay.name} (or its name) in a message to allow work there.`,
|
|
1398
|
+
{ relay: relay.name }));
|
|
1399
|
+
}
|
|
1400
|
+
if (!remoteRelayPermissionAllows(relay.permission, action)) {
|
|
1401
|
+
return finish(failure(403, REMOTE_RELAY_ERROR_CODES.forbidden,
|
|
1402
|
+
`Relay "${relay.name}" lets agents ${PERMISSION_DESCRIPTIONS[relay.permission] || 'do less'} (permission "${relay.permission}"); ${action} needs "${REMOTE_RELAY_ACTION_PERMISSION[action]}". The user can change this in Settings → Relays.`,
|
|
1403
|
+
{ relay: relay.name, permission: relay.permission }));
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
const caller = { conversationId, ...((await getCallerContext(conversationId, req)) || {}) };
|
|
1407
|
+
caller.mode = toText(caller.mode).toLowerCase();
|
|
1408
|
+
const hops = nonNegativeInt(caller.hops) + 1;
|
|
1409
|
+
if (hops > REMOTE_RELAY_LIMITS.hopLimit) {
|
|
1410
|
+
return finish(failure(403, REMOTE_RELAY_ERROR_CODES.hopLimit,
|
|
1411
|
+
`This turn was started by another relay's agent (hop ${hops - 1}); forwarding it again would pass the limit of ${REMOTE_RELAY_LIMITS.hopLimit} hops.`,
|
|
1412
|
+
{ relay: relay.name }));
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
const retryAfterSeconds = consumeRateLimit(conversationId, write);
|
|
1416
|
+
if (retryAfterSeconds !== null) {
|
|
1417
|
+
return finish(failure(429, REMOTE_RELAY_ERROR_CODES.rateLimited,
|
|
1418
|
+
`Too many remote relay calls from this conversation (at most ${REMOTE_RELAY_LIMITS.callsPerMinute} a minute, ${REMOTE_RELAY_LIMITS.writesPerMinute} of them writes). Try again in ${retryAfterSeconds} s.`,
|
|
1419
|
+
{ relay: relay.name, retryAfterSeconds }));
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
adjustInflight(conversationId, 1);
|
|
1423
|
+
try {
|
|
1424
|
+
if (write && REMOTE_RELAY_APPROVAL_MODES.includes(caller.mode)) {
|
|
1425
|
+
const decision = await requestApproval({
|
|
1426
|
+
conversationId,
|
|
1427
|
+
callerContext: caller,
|
|
1428
|
+
relay: relayTarget(relay),
|
|
1429
|
+
action,
|
|
1430
|
+
args,
|
|
1431
|
+
summary: summarizeRemoteRelayCall({ action, ...args, relay: relay.name }),
|
|
1432
|
+
signal,
|
|
1433
|
+
});
|
|
1434
|
+
const approved = decision === true || decision?.approved === true;
|
|
1435
|
+
if (!approved) {
|
|
1436
|
+
const code = toText(decision?.code) || REMOTE_RELAY_ERROR_CODES.approvalDenied;
|
|
1437
|
+
const error = toText(decision?.error)
|
|
1438
|
+
|| `The user did not allow this ${action.replace(/_/g, ' ')} on relay "${relay.name}".`;
|
|
1439
|
+
return finish(failure(403, code, error, { relay: relay.name }));
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
const ctx = { relay, caller, hops, conversationId, signal };
|
|
1444
|
+
const body = await ACTIONS[action](ctx, args);
|
|
1445
|
+
const { summary, ...rest } = body;
|
|
1446
|
+
return finish({
|
|
1447
|
+
status: 200,
|
|
1448
|
+
body: {
|
|
1449
|
+
ok: true,
|
|
1450
|
+
relay: relay.name,
|
|
1451
|
+
summary: summary || summarizeRemoteRelayCall({ action, ...args, relay: relay.name }),
|
|
1452
|
+
...rest,
|
|
1453
|
+
},
|
|
1454
|
+
});
|
|
1455
|
+
} catch (error) {
|
|
1456
|
+
if (error instanceof DispatchFailure) {
|
|
1457
|
+
return finish(failure(error.status, error.code, error.message, { relay: relay.name, ...error.extra }));
|
|
1458
|
+
}
|
|
1459
|
+
if (isRemoteRelayError(error)) return finish(mapRemoteError(error, relay, { action, args }));
|
|
1460
|
+
throw error;
|
|
1461
|
+
} finally {
|
|
1462
|
+
adjustInflight(conversationId, -1);
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/**
|
|
1467
|
+
* One tool call. Never throws: resolves to `{ status, body }` for the route
|
|
1468
|
+
* to send as is. `signal` (optional) aborts approval and reply waits when
|
|
1469
|
+
* the caller hangs up.
|
|
1470
|
+
*/
|
|
1471
|
+
async function dispatch({ conversationId, action, args, req, signal } = {}) {
|
|
1472
|
+
const validation = validateRemoteRelayToolInput({ ...(isPlainObject(args) ? args : {}), action });
|
|
1473
|
+
if (!validation.ok) return failure(400, validation.code, validation.error);
|
|
1474
|
+
const requestedConversationId = toText(conversationId);
|
|
1475
|
+
let callerConversationId = requestedConversationId;
|
|
1476
|
+
try {
|
|
1477
|
+
callerConversationId = toText(resolveConversationId(requestedConversationId)) || requestedConversationId;
|
|
1478
|
+
} catch {}
|
|
1479
|
+
if (!callerConversationId) {
|
|
1480
|
+
return failure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, 'conversationId is required');
|
|
1481
|
+
}
|
|
1482
|
+
try {
|
|
1483
|
+
if (validation.action === 'list_relays') return await listRelays(callerConversationId);
|
|
1484
|
+
return await runRelayAction({
|
|
1485
|
+
conversationId: callerConversationId,
|
|
1486
|
+
action: validation.action,
|
|
1487
|
+
args: validation.args,
|
|
1488
|
+
req,
|
|
1489
|
+
signal,
|
|
1490
|
+
});
|
|
1491
|
+
} catch (error) {
|
|
1492
|
+
try {
|
|
1493
|
+
logger?.warn?.(`${LOG_PREFIX} ${validation.action} failed conv=${shortId(callerConversationId)}: ${toText(error?.message || error).slice(0, 200)}`);
|
|
1494
|
+
} catch {}
|
|
1495
|
+
return failure(500, REMOTE_RELAY_INTERNAL_ERROR_CODE, 'The remote relay tool failed on this relay; see its server log.');
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
return { dispatch, inflight };
|
|
1500
|
+
}
|