@oar-sh/oar 0.9.15 → 0.9.16
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/CHANGELOG.md +85 -0
- package/README.md +9 -9
- package/package.json +1 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
- package/server/claude-worker/claude-session-process.mjs +4 -4
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2 -2
- package/server/db-schema.mjs +52 -0
- package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
- package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
- package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
- package/server/migrations/0022-relay-schedules.mjs +142 -0
- package/server/migrations/0023-relay-asks.mjs +141 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/bootstrap.js +61 -3
- package/server/public/app/context-usage-view.mjs +17 -0
- package/server/public/app/conversation-group-order.mjs +35 -0
- package/server/public/app/inbox-ui.js +356 -0
- package/server/public/app/inbox-view.mjs +370 -0
- package/server/public/app/journal-view.js +23 -2
- package/server/public/app/relay-event-subscriptions-ui.js +4 -2
- package/server/public/app/relay-event-view.mjs +33 -7
- package/server/public/app/relay-schedule-view.mjs +119 -0
- package/server/public/app/relay-schedules-ui.js +172 -0
- package/server/public/app/socket-handlers.js +27 -3
- package/server/public/app/store.js +18 -5
- package/server/public/index.html +159 -2
- package/server/relay-tools.md +18 -3
- package/server/repositories/conversation-group-repository.mjs +97 -11
- package/server/repositories/remote-relay-repository.mjs +20 -2
- package/server/repositories/session-repository.mjs +7 -1
- package/server/routes/ask-user-routes.mjs +57 -3
- package/server/routes/cache-routes.mjs +6 -0
- package/server/routes/conversation-group-routes.mjs +17 -2
- package/server/routes/messages-routes.mjs +3 -3
- package/server/routes/relay-ask-routes.mjs +387 -0
- package/server/routes/relay-event-routes.mjs +3 -1
- package/server/routes/relay-schedule-routes.mjs +57 -0
- package/server/routes/sessions-routes.mjs +568 -42
- package/server/server-runtime.mjs +80 -1
- package/server/services/cache-rebuild-service.mjs +19 -2
- package/server/services/conversation-preferences-service.mjs +17 -0
- package/server/services/relay-ask-link-service.mjs +174 -0
- package/server/services/relay-ask-service.mjs +604 -0
- package/server/services/relay-event-service.mjs +589 -73
- package/server/services/relay-schedule-service.mjs +596 -0
- package/server/services/remote-relay-caller-context.mjs +89 -20
- package/server/services/remote-relay-dispatcher.mjs +956 -30
- package/shared/relay-asks.mjs +145 -0
- package/shared/relay-events.mjs +268 -18
- package/shared/relay-schedules.mjs +128 -0
- package/shared/remote-relay-contract.mjs +553 -25
- package/shared/remote-relay-tool-core.mjs +16 -5
|
@@ -5,20 +5,25 @@ import { randomUUID as nodeRandomUUID } from 'crypto';
|
|
|
5
5
|
import { isFailureNoteText } from '../../shared/failure-note-text.mjs';
|
|
6
6
|
import {
|
|
7
7
|
REMOTE_RELAY_APPROVAL_MODES,
|
|
8
|
+
REMOTE_RELAY_ASK_ACTIONS,
|
|
8
9
|
REMOTE_RELAY_CLOUD_PROVIDER,
|
|
9
10
|
REMOTE_RELAY_EFFORT_PATTERN,
|
|
10
11
|
REMOTE_RELAY_ERROR_CODES,
|
|
12
|
+
REMOTE_RELAY_GROUP_LIMITS,
|
|
11
13
|
REMOTE_RELAY_LIMITS,
|
|
12
14
|
REMOTE_RELAY_LOCAL_ALIAS,
|
|
13
15
|
REMOTE_RELAY_LOCAL_ID,
|
|
14
16
|
REMOTE_RELAY_PROVIDERS,
|
|
17
|
+
REMOTE_RELAY_SESSION_SETTINGS,
|
|
18
|
+
formatSessionGroupList,
|
|
15
19
|
isRemoteRelayApprovalQuestion,
|
|
16
20
|
isRemoteRelayPromptAction,
|
|
17
21
|
isRemoteRelayWriteAction,
|
|
18
22
|
normalizeRemoteRelayOrigin,
|
|
19
23
|
remoteConversationUrl,
|
|
24
|
+
remoteRelayActionPermission,
|
|
20
25
|
remoteRelayPermissionAllows,
|
|
21
|
-
|
|
26
|
+
resolveSessionGroupReference,
|
|
22
27
|
stripRemotePromptHeader,
|
|
23
28
|
summarizeRemoteRelayCall,
|
|
24
29
|
validateRemoteRelayToolInput,
|
|
@@ -37,6 +42,8 @@ import {
|
|
|
37
42
|
isMissingRelayEventRoute,
|
|
38
43
|
relayWithoutCrossRelayEventsText,
|
|
39
44
|
} from './relay-event-link-service.mjs';
|
|
45
|
+
import { RELAY_ASK_KIND, relayQuestionKind } from '../../shared/relay-asks.mjs';
|
|
46
|
+
import { RELAY_ASK_REMOTE_PATHS, isMissingRelayAskRoute, relayWithoutAsksText } from './relay-ask-link-service.mjs';
|
|
40
47
|
|
|
41
48
|
// The one place a `remote_relay` tool call is decided and carried out (plan
|
|
42
49
|
// §5.6). Every provider adapter ends up at POST /api/remote-relays/tool, whose
|
|
@@ -51,9 +58,16 @@ import {
|
|
|
51
58
|
// pairing, mention, permission or hop; instead:
|
|
52
59
|
//
|
|
53
60
|
// setting on → no acting on the caller's own conversation → rate limit →
|
|
54
|
-
// create_session only: one level (a created session creates none
|
|
55
|
-
//
|
|
56
|
-
//
|
|
61
|
+
// create_session only: one level (a created session creates none, unless
|
|
62
|
+
// the owner allowed it for that session), at most localActiveSessions
|
|
63
|
+
// created sessions at work, the user's Allow once per conversation →
|
|
64
|
+
// approval (ask/plan) → execute
|
|
65
|
+
//
|
|
66
|
+
// update_session, on either kind of target: a session the calling
|
|
67
|
+
// conversation started may be changed; any other (the owner's, another
|
|
68
|
+
// agent's, the caller's creator) needs the owner's Allow once per calling
|
|
69
|
+
// conversation and target (remembered as an unlock), and the target holds to
|
|
70
|
+
// the same rule itself (its settings route refuses without owner_approved).
|
|
57
71
|
//
|
|
58
72
|
// Its actions run through the same code as a paired relay's: `call()` hands
|
|
59
73
|
// them to the loopback client, which reaches this relay's own routes
|
|
@@ -109,7 +123,15 @@ const CLOUD_CREATE_NOTE = 'create_session needs repo (https://github.com/owner/r
|
|
|
109
123
|
// while the provider is switched off.
|
|
110
124
|
const CLOUD_DISABLED_CODE = 'claude_cloud_disabled';
|
|
111
125
|
// The actions an agent must not aim at the conversation it runs in.
|
|
112
|
-
const OWN_SESSION_ACTIONS = new Set(['send', 'stop', 'archive', 'wait']);
|
|
126
|
+
const OWN_SESSION_ACTIONS = new Set(['send', 'stop', 'archive', 'wait', 'update_session']);
|
|
127
|
+
// The route update_session reaches on the target, and the codes it answers.
|
|
128
|
+
const SESSION_SETTINGS_SUFFIX = '/settings';
|
|
129
|
+
const SESSION_SETTINGS_CODES = Object.freeze({
|
|
130
|
+
notFound: 'SESSION_NOT_FOUND',
|
|
131
|
+
busy: 'SESSION_BUSY',
|
|
132
|
+
groupNotFound: 'SESSION_GROUP_NOT_FOUND',
|
|
133
|
+
groupAmbiguous: 'SESSION_GROUP_AMBIGUOUS',
|
|
134
|
+
});
|
|
113
135
|
// How /api/models `providersByModel` names each provider.
|
|
114
136
|
const CATALOG_PROVIDER_LABELS = Object.freeze({
|
|
115
137
|
github: 'github-copilot',
|
|
@@ -436,10 +458,26 @@ function relayTarget(relay) {
|
|
|
436
458
|
function mapPendingQuestion(question) {
|
|
437
459
|
if (!isPlainObject(question) || !toText(question.id)) return null;
|
|
438
460
|
if (isRemoteRelayApprovalQuestion(question)) return null;
|
|
461
|
+
const kind = relayQuestionKind(question);
|
|
462
|
+
if (kind === RELAY_ASK_KIND) {
|
|
463
|
+
// A question that does not block: the user's to answer, the session works on.
|
|
464
|
+
return {
|
|
465
|
+
id: toText(question.id),
|
|
466
|
+
kind,
|
|
467
|
+
question: toText(question.question || question.prompt),
|
|
468
|
+
options: (Array.isArray(question.options) ? question.options : []).map((option) => toText(option)).filter(Boolean),
|
|
469
|
+
...(toText(question.recommendation) ? { recommendation: toText(question.recommendation) } : {}),
|
|
470
|
+
...(question.urgency === 'now' ? { urgency: 'now' } : {}),
|
|
471
|
+
...(isPlainObject(question.askedBy) ? { askedBy: question.askedBy } : {}),
|
|
472
|
+
createdAt: question.createdAt || null,
|
|
473
|
+
note: 'Asked with ask: it does not block the session, and only the user answers it.',
|
|
474
|
+
};
|
|
475
|
+
}
|
|
439
476
|
const context = isPlainObject(question.context) ? question.context : {};
|
|
440
477
|
const fields = schemaFields(question.requestSchema);
|
|
441
478
|
return {
|
|
442
479
|
id: toText(question.id),
|
|
480
|
+
kind,
|
|
443
481
|
question: toText(question.prompt),
|
|
444
482
|
header: toText(context.header) || null,
|
|
445
483
|
options: Array.isArray(question.choices) ? question.choices.map((choice) => toText(choice)).filter(Boolean) : [],
|
|
@@ -627,6 +665,15 @@ export function createRemoteRelayDispatcher({
|
|
|
627
665
|
// The event link (relay-event-link-service.mjs): fetches the events of
|
|
628
666
|
// subscriptions on paired relays. Without it those stay refused.
|
|
629
667
|
relayEventLink = null,
|
|
668
|
+
// The relay schedule service (relay-schedule-service.mjs): schedule and its
|
|
669
|
+
// siblings. Without it those actions answer that the relay lacks them.
|
|
670
|
+
relaySchedules = null,
|
|
671
|
+
// The ask service (relay-ask-service.mjs): ask and its siblings, and the
|
|
672
|
+
// link that looks for the answers of asks put on paired relays
|
|
673
|
+
// (relay-ask-link-service.mjs). Without the service those actions answer
|
|
674
|
+
// that the relay lacks them.
|
|
675
|
+
relayAsks = null,
|
|
676
|
+
relayAskLink = null,
|
|
630
677
|
} = {}) {
|
|
631
678
|
const inflightByConversation = new Map();
|
|
632
679
|
const callTimesByConversation = new Map();
|
|
@@ -644,6 +691,9 @@ export function createRemoteRelayDispatcher({
|
|
|
644
691
|
const localApprovalsByConversation = new Map();
|
|
645
692
|
// The same for the first subscribe of a conversation (by approval key).
|
|
646
693
|
const watchApprovalsByKey = new Map();
|
|
694
|
+
// And for the first update_session of a conversation on a session the
|
|
695
|
+
// conversation did not start there (by approval key).
|
|
696
|
+
const sessionSettingsApprovalsByKey = new Map();
|
|
647
697
|
|
|
648
698
|
// ─── Bookkeeping ───────────────────────────────────────────────────────────
|
|
649
699
|
|
|
@@ -691,6 +741,26 @@ export function createRemoteRelayDispatcher({
|
|
|
691
741
|
return fresh;
|
|
692
742
|
}
|
|
693
743
|
|
|
744
|
+
/**
|
|
745
|
+
* A schedule another conversation set comes due in a session: that is a
|
|
746
|
+
* prompt of the conversation that set it, counted by its loop guard like
|
|
747
|
+
* a send. False (the firing is held) once the guard wants the user first;
|
|
748
|
+
* it lets the firing go again after the user wrote in that conversation,
|
|
749
|
+
* or allowed its agent's next prompt on the card.
|
|
750
|
+
*/
|
|
751
|
+
function admitScheduledFiring({ createdBy } = {}) {
|
|
752
|
+
const requested = toText(createdBy);
|
|
753
|
+
if (!requested) return false;
|
|
754
|
+
let creator = requested;
|
|
755
|
+
try {
|
|
756
|
+
creator = toText(resolveConversationId(requested)) || requested;
|
|
757
|
+
} catch {}
|
|
758
|
+
const kept = agentPrompts(creator);
|
|
759
|
+
if (kept.count >= REMOTE_RELAY_LIMITS.agentPromptsBeforeAsking) return false;
|
|
760
|
+
kept.count += 1;
|
|
761
|
+
return true;
|
|
762
|
+
}
|
|
763
|
+
|
|
694
764
|
/** `{ enabled, maxWaitSeconds }`; off and the default wait when unreadable. */
|
|
695
765
|
function agentSessions() {
|
|
696
766
|
let settings = null;
|
|
@@ -790,14 +860,20 @@ export function createRemoteRelayDispatcher({
|
|
|
790
860
|
return `/api/conversation/${encodeURIComponent(session)}${suffix}`;
|
|
791
861
|
}
|
|
792
862
|
|
|
793
|
-
|
|
863
|
+
/**
|
|
864
|
+
* The questions a remote session has open. `includeAsks` (read_session):
|
|
865
|
+
* the ones that don't block as well (kind "ask"); a relay without them
|
|
866
|
+
* ignores it. The waits leave them out: the session works on, and only the
|
|
867
|
+
* user answers them.
|
|
868
|
+
*/
|
|
869
|
+
async function loadPendingQuestions(ctx, conversationId, { includeAsks = false } = {}) {
|
|
794
870
|
try {
|
|
795
871
|
const response = await call(ctx, 'GET', '/api/relay-questions', {
|
|
796
|
-
query: { status: 'pending', conversationId },
|
|
872
|
+
query: { status: 'pending', conversationId, ...(includeAsks ? { includeAsks: 1 } : {}) },
|
|
797
873
|
});
|
|
798
874
|
return (Array.isArray(response?.questions) ? response.questions : [])
|
|
799
875
|
.map(mapPendingQuestion)
|
|
800
|
-
.filter(
|
|
876
|
+
.filter((question) => question && (includeAsks || question.kind !== RELAY_ASK_KIND));
|
|
801
877
|
} catch {
|
|
802
878
|
return [];
|
|
803
879
|
}
|
|
@@ -1105,6 +1181,157 @@ export function createRemoteRelayDispatcher({
|
|
|
1105
1181
|
});
|
|
1106
1182
|
}
|
|
1107
1183
|
|
|
1184
|
+
// ─── Session groups (relay_info, create_session `group`) ───────────────────
|
|
1185
|
+
|
|
1186
|
+
// The target's read-only group list: id, name and member count only.
|
|
1187
|
+
const GROUPS_PATH = '/api/conversation-groups';
|
|
1188
|
+
// The target's bootstrap refuses a group with these codes.
|
|
1189
|
+
const BOOTSTRAP_GROUP_CODES = Object.freeze({
|
|
1190
|
+
notFound: 'SESSION_GROUP_NOT_FOUND',
|
|
1191
|
+
ambiguous: 'SESSION_GROUP_AMBIGUOUS',
|
|
1192
|
+
unavailable: 'SESSION_GROUPS_UNAVAILABLE',
|
|
1193
|
+
invalid: 'SESSION_GROUP_INVALID',
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
function groupWhere(ctx) {
|
|
1197
|
+
return ctx.relay.local ? 'this relay' : `relay "${ctx.relay.name}"`;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/** A text that starts with groupWhere(), as the start of a sentence. */
|
|
1201
|
+
function sentence(text) {
|
|
1202
|
+
return `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
function mapGroupList(response) {
|
|
1206
|
+
return (Array.isArray(response?.groups) ? response.groups : [])
|
|
1207
|
+
.filter(isPlainObject)
|
|
1208
|
+
.map((group) => ({ id: toText(group.id), name: clipLine(group.name, 80) || 'Group', memberCount: nonNegativeInt(group.memberCount) }))
|
|
1209
|
+
.filter((group) => group.id);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* Why the target's group list could not be read: `{ status, code, text }`.
|
|
1214
|
+
* A relay without the list (404) predates groups on create_session.
|
|
1215
|
+
*/
|
|
1216
|
+
function groupListProblem(ctx, error) {
|
|
1217
|
+
const C = REMOTE_RELAY_ERROR_CODES;
|
|
1218
|
+
const where = groupWhere(ctx);
|
|
1219
|
+
const code = toText(error?.code);
|
|
1220
|
+
const status = Number(error?.status) || 0;
|
|
1221
|
+
if (code === C.notFound || status === 404) {
|
|
1222
|
+
return { status: 400, code: C.unsupported, text: `The target relay doesn't support groups on create_session yet: ${where} runs an older OAR (its group list answered 404).` };
|
|
1223
|
+
}
|
|
1224
|
+
if (code === C.unauthorized || status === 401) {
|
|
1225
|
+
return { status: 502, code: C.unauthorized, text: `${where} rejected this relay's token when asked for its session groups (401).` };
|
|
1226
|
+
}
|
|
1227
|
+
if (status === 403 || code === C.inboundDisabled) {
|
|
1228
|
+
return { status: 403, code: code || `${C.httpPrefix}403`, text: `${where} refused to list its session groups (403).` };
|
|
1229
|
+
}
|
|
1230
|
+
if (status === 503 && toText(error?.remoteBody?.error).includes('Session groups are not available')) {
|
|
1231
|
+
return { status: 400, code: C.unsupported, text: `Session groups are not available on ${where} (its storage for them is missing).` };
|
|
1232
|
+
}
|
|
1233
|
+
if (code === C.offline) {
|
|
1234
|
+
const detail = toText(error?.detail) || 'no answer';
|
|
1235
|
+
return { status: 502, code: C.offline, text: `${where} did not answer when asked for its session groups (${detail}).` };
|
|
1236
|
+
}
|
|
1237
|
+
if (status >= 500) {
|
|
1238
|
+
return { status: 502, code: code || `${C.httpPrefix}${status}`, text: `${where} failed to list its session groups (HTTP ${status}).` };
|
|
1239
|
+
}
|
|
1240
|
+
return { status: 502, code: code || `${C.httpPrefix}502`, text: `${where} could not list its session groups (${toText(error?.message) || 'unexpected answer'}).` };
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* The refusal for a group the target does not have, or has several of by
|
|
1245
|
+
* that name. `total` is how many there are when `groups` came capped (the
|
|
1246
|
+
* target's own refusal).
|
|
1247
|
+
*/
|
|
1248
|
+
function unknownGroupFailure(ctx, requested, found, groups, total = null) {
|
|
1249
|
+
const where = groupWhere(ctx);
|
|
1250
|
+
const asked = clipLine(requested, 80).replace(/"/g, "'");
|
|
1251
|
+
const ambiguous = found.status === 'ambiguous';
|
|
1252
|
+
const listed = ambiguous ? found.matches : groups;
|
|
1253
|
+
const max = REMOTE_RELAY_GROUP_LIMITS.refusalMax;
|
|
1254
|
+
const count = Number.isInteger(total) && total > listed.length ? total : listed.length;
|
|
1255
|
+
const names = formatSessionGroupList(listed, max, count);
|
|
1256
|
+
let text;
|
|
1257
|
+
if (ambiguous) text = `"${asked}" names several session groups on ${where}: ${names}. Pass the id.`;
|
|
1258
|
+
else if (groups.length) text = sentence(`${where} has no session group "${asked}". Groups there: ${names}.`);
|
|
1259
|
+
else text = sentence(`${where} has no session groups.`);
|
|
1260
|
+
return new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unknownGroup, `${text} No session was created.`, {
|
|
1261
|
+
group: requested,
|
|
1262
|
+
groups: listed.slice(0, max).map((group) => ({ id: group.id, name: group.name })),
|
|
1263
|
+
...(count > max ? { groupsTotal: count } : {}),
|
|
1264
|
+
note: groups.length
|
|
1265
|
+
? 'Pass one of these ids or names (relay_info lists them), or leave group out. create_session takes an existing group only; update_session can put the session into a new group of that name afterwards. Renaming groups is the owner\'s.'
|
|
1266
|
+
: 'Leave group out; update_session can put the session into a new group of that name afterwards.',
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* The group a create_session names, looked up on the target before anyone
|
|
1272
|
+
* is asked: `{ id, name }`. Any failure to read the target's groups refuses
|
|
1273
|
+
* the call (a DispatchFailure), as an unknown or ambiguous group does.
|
|
1274
|
+
*/
|
|
1275
|
+
async function settleRequestedGroup(ctx, requested) {
|
|
1276
|
+
let response;
|
|
1277
|
+
try {
|
|
1278
|
+
response = await call(ctx, 'GET', GROUPS_PATH);
|
|
1279
|
+
} catch (error) {
|
|
1280
|
+
if (!isRemoteRelayError(error)) throw error;
|
|
1281
|
+
const problem = groupListProblem(ctx, error);
|
|
1282
|
+
throw new DispatchFailure(problem.status, problem.code, `${sentence(problem.text)} No session was created.`, {
|
|
1283
|
+
group: requested,
|
|
1284
|
+
note: 'Leave group out (the owner can group the session by hand), or try again once that relay lists its groups.',
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
const groups = mapGroupList(response);
|
|
1288
|
+
const found = resolveSessionGroupReference(groups, requested);
|
|
1289
|
+
if (found.status === 'found') return { id: found.group.id, name: found.group.name };
|
|
1290
|
+
throw unknownGroupFailure(ctx, requested, found, groups);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/** A bootstrap refusal of the group (it vanished after the lookup), as the agent reads it; else null. */
|
|
1294
|
+
function bootstrapGroupFailure(ctx, error, requested) {
|
|
1295
|
+
if (!requested || !isRemoteRelayError(error)) return null;
|
|
1296
|
+
const body = isPlainObject(error.remoteBody) ? error.remoteBody : null;
|
|
1297
|
+
const remoteCode = toText(body?.code);
|
|
1298
|
+
if (remoteCode === BOOTSTRAP_GROUP_CODES.notFound || remoteCode === BOOTSTRAP_GROUP_CODES.ambiguous) {
|
|
1299
|
+
const groups = mapGroupList(body);
|
|
1300
|
+
const found = remoteCode === BOOTSTRAP_GROUP_CODES.ambiguous ? { status: 'ambiguous', matches: groups } : { status: 'unknown' };
|
|
1301
|
+
const total = Number.isInteger(body?.groupsTotal) ? body.groupsTotal : null;
|
|
1302
|
+
return unknownGroupFailure(ctx, requested, found, groups, total);
|
|
1303
|
+
}
|
|
1304
|
+
if (remoteCode === BOOTSTRAP_GROUP_CODES.unavailable) {
|
|
1305
|
+
return new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unsupported,
|
|
1306
|
+
`Session groups are not available on ${groupWhere(ctx)} (its storage for them is missing). No session was created.`, { group: requested });
|
|
1307
|
+
}
|
|
1308
|
+
if (remoteCode === BOOTSTRAP_GROUP_CODES.invalid) {
|
|
1309
|
+
return new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, `${toText(body?.error) || 'group is not valid'}. No session was created.`, { group: requested });
|
|
1310
|
+
}
|
|
1311
|
+
return null;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/** The `group` of a bootstrap answer in list_sessions' shape, or null. */
|
|
1315
|
+
function bootstrapGroupField(value) {
|
|
1316
|
+
if (!isPlainObject(value) || !toText(value.id)) return null;
|
|
1317
|
+
return { id: toText(value.id), name: toText(value.name) || null, role: toText(value.role) === 'head' ? 'head' : 'member' };
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/** relay_info's `groups` (latest activity first, capped), or a note why there are none. */
|
|
1321
|
+
async function relayGroups(ctx) {
|
|
1322
|
+
try {
|
|
1323
|
+
const groups = mapGroupList(await call(ctx, 'GET', GROUPS_PATH));
|
|
1324
|
+
const max = REMOTE_RELAY_GROUP_LIMITS.listMax;
|
|
1325
|
+
return {
|
|
1326
|
+
groups: groups.slice(0, max),
|
|
1327
|
+
...(groups.length > max ? { groupsTotal: groups.length } : {}),
|
|
1328
|
+
};
|
|
1329
|
+
} catch (error) {
|
|
1330
|
+
if (!isRemoteRelayError(error)) throw error;
|
|
1331
|
+
return { groups: null, groupsNote: `${sentence(groupListProblem(ctx, error).text)} create_session cannot take group there.` };
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1108
1335
|
// ─── Actions ───────────────────────────────────────────────────────────────
|
|
1109
1336
|
|
|
1110
1337
|
// Plan usage as the agent may see it: an allow-list of fields, so a field
|
|
@@ -1225,9 +1452,10 @@ export function createRemoteRelayDispatcher({
|
|
|
1225
1452
|
}
|
|
1226
1453
|
|
|
1227
1454
|
async function relayInfo(ctx) {
|
|
1228
|
-
const [identity, catalog] = await Promise.all([
|
|
1455
|
+
const [identity, catalog, groups] = await Promise.all([
|
|
1229
1456
|
call(ctx, 'GET', '/api/relay/identity').catch(() => null),
|
|
1230
1457
|
loadCatalog(ctx),
|
|
1458
|
+
relayGroups(ctx),
|
|
1231
1459
|
]);
|
|
1232
1460
|
const status = catalog.status;
|
|
1233
1461
|
const providers = buildProviders(catalog);
|
|
@@ -1247,7 +1475,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1247
1475
|
// let in by the agent-sessions setting, which is on or this would not run.
|
|
1248
1476
|
acceptsAgentPrompts: ctx.relay.local ? true : (remoteRelays ? remoteRelays.inbound !== false : null),
|
|
1249
1477
|
...(ctx.relay.local ? {
|
|
1250
|
-
sessionRules: `create_session: at most ${REMOTE_RELAY_LIMITS.localActiveSessions} sessions you started may work at the same time; a session an agent created cannot create sessions; sessions run on this relay's accounts.`,
|
|
1478
|
+
sessionRules: `create_session: at most ${REMOTE_RELAY_LIMITS.localActiveSessions} sessions you started may work at the same time; a session an agent created cannot create sessions unless the owner allowed it for that session (mayCreateSessions); sessions run on this relay's accounts. update_session: sessions you started freely, any other (the owner's, another agent's, your creator's) after the owner's Allow (once per conversation).`,
|
|
1251
1479
|
} : {}),
|
|
1252
1480
|
providers,
|
|
1253
1481
|
workspaces: {
|
|
@@ -1257,6 +1485,8 @@ export function createRemoteRelayDispatcher({
|
|
|
1257
1485
|
},
|
|
1258
1486
|
relayModes: Array.isArray(status.supportedRelayModes) ? status.supportedRelayModes : [],
|
|
1259
1487
|
defaultRelayMode: toText(status.defaultRelayMode) || null,
|
|
1488
|
+
// The session groups a create_session there may name (`group`).
|
|
1489
|
+
...groups,
|
|
1260
1490
|
};
|
|
1261
1491
|
}
|
|
1262
1492
|
|
|
@@ -1305,6 +1535,8 @@ export function createRemoteRelayDispatcher({
|
|
|
1305
1535
|
...(via ? { via } : {}),
|
|
1306
1536
|
...(createdBy ? { createdBy } : {}),
|
|
1307
1537
|
...(createdBy && ctx?.relay?.local && createdBy === ctx.conversationId ? { startedByYou: true } : {}),
|
|
1538
|
+
// The owner let this agent-started session's agents start sessions.
|
|
1539
|
+
...(row?.mayCreateSessions === true ? { mayCreateSessions: true } : {}),
|
|
1308
1540
|
// The session group it sits in (sessions an agent started here are
|
|
1309
1541
|
// grouped under that agent's session; the owner can group by hand).
|
|
1310
1542
|
...sessionGroupField(row, groups),
|
|
@@ -1511,7 +1743,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1511
1743
|
query: { limit: args.last, beforeMessageId: before?.beforeMessageId, beforeTimestamp: before?.beforeTimestamp, groupMembers: 1 },
|
|
1512
1744
|
});
|
|
1513
1745
|
const conversationId = toText(conversation?.id) || args.session;
|
|
1514
|
-
const pendingQuestions = await loadPendingQuestions(ctx, conversationId);
|
|
1746
|
+
const pendingQuestions = await loadPendingQuestions(ctx, conversationId, { includeAsks: true });
|
|
1515
1747
|
const { messages, omitted } = shapeMessages(conversation?.messages, {
|
|
1516
1748
|
maxChars: args.max_chars,
|
|
1517
1749
|
includeActivity: args.include_activity,
|
|
@@ -1558,6 +1790,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1558
1790
|
active: conversation?.activeTurn === true || isBusyState(conversation?.sessionState),
|
|
1559
1791
|
...sessionActivityFields(conversation, { console: true }),
|
|
1560
1792
|
...(via ? { via } : {}),
|
|
1793
|
+
...(conversation?.mayCreateSessions === true ? { mayCreateSessions: true } : {}),
|
|
1561
1794
|
...(group ? { group } : {}),
|
|
1562
1795
|
openUrl: remoteConversationUrl(ctx.relay.url, conversationId) || null,
|
|
1563
1796
|
},
|
|
@@ -1681,7 +1914,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1681
1914
|
`Message ${args.message_id} is not in session ${args.session} on relay "${ctx.relay.name}".`);
|
|
1682
1915
|
}
|
|
1683
1916
|
const session = toText(around?.id) || args.session;
|
|
1684
|
-
const outcome = await
|
|
1917
|
+
const outcome = await waitForOwnReply(ctx, { session, messageId: args.message_id, waitSeconds: args.wait_seconds });
|
|
1685
1918
|
return { summary: outcomeSummary('wait', ctx, outcome), ...outcome };
|
|
1686
1919
|
}
|
|
1687
1920
|
|
|
@@ -1804,14 +2037,48 @@ export function createRemoteRelayDispatcher({
|
|
|
1804
2037
|
};
|
|
1805
2038
|
return { summary: outcomeSummary(action, ctx, outcome), ...outcome, ...extra, openUrl };
|
|
1806
2039
|
}
|
|
2040
|
+
noteOwnPrompt(ctx, session, sent.messageId);
|
|
1807
2041
|
if (waitSeconds <= 0) {
|
|
1808
2042
|
const outcome = { status: 'queued', session, message_id: sent.messageId };
|
|
1809
2043
|
return { summary: outcomeSummary(action, ctx, outcome), ...outcome, ...extra, openUrl };
|
|
1810
2044
|
}
|
|
1811
|
-
const outcome = await
|
|
2045
|
+
const outcome = await waitForOwnReply(ctx, { session, messageId: sent.messageId, waitSeconds });
|
|
1812
2046
|
return { summary: outcomeSummary(action, ctx, outcome), ...outcome, ...extra, openUrl };
|
|
1813
2047
|
}
|
|
1814
2048
|
|
|
2049
|
+
/**
|
|
2050
|
+
* The event service learns of this conversation's own prompts: a turn_end
|
|
2051
|
+
* of one whose finished reply it waited for is not delivered to it again.
|
|
2052
|
+
*/
|
|
2053
|
+
function noteOwnPrompt(ctx, session, messageId) {
|
|
2054
|
+
try {
|
|
2055
|
+
relayEvents?.noteOwnPrompt?.({
|
|
2056
|
+
watcherConversationId: ctx.conversationId,
|
|
2057
|
+
// This relay's own sessions: the service's default.
|
|
2058
|
+
...(ctx.relay.local ? {} : { relayId: ctx.relay.id }),
|
|
2059
|
+
sessionId: session,
|
|
2060
|
+
messageId,
|
|
2061
|
+
});
|
|
2062
|
+
} catch {}
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
/** waitForReply, with that turn's turn_end held while it runs and dropped once the reply came. */
|
|
2066
|
+
async function waitForOwnReply(ctx, { session, messageId, waitSeconds }) {
|
|
2067
|
+
let token = null;
|
|
2068
|
+
try {
|
|
2069
|
+
token = relayEvents?.beginOwnWait?.({ watcherConversationId: ctx.conversationId, messageId, waitMs: waitSeconds * 1000 }) || null;
|
|
2070
|
+
} catch {}
|
|
2071
|
+
let outcome = null;
|
|
2072
|
+
try {
|
|
2073
|
+
outcome = await waitForReply(ctx, { session, messageId, waitSeconds });
|
|
2074
|
+
return outcome;
|
|
2075
|
+
} finally {
|
|
2076
|
+
try {
|
|
2077
|
+
relayEvents?.endOwnWait?.(token, { received: outcome?.status === 'done' && !!outcome?.reply });
|
|
2078
|
+
} catch {}
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
|
|
1815
2082
|
async function send(ctx, args) {
|
|
1816
2083
|
const conversation = await call(ctx, 'GET', sessionPath(args.session), { query: { limit: 1 } });
|
|
1817
2084
|
const session = toText(conversation?.id) || args.session;
|
|
@@ -1863,7 +2130,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1863
2130
|
* Claude alias resolved) and what the agent should know about it, the
|
|
1864
2131
|
* model left unchecked for want of a list included.
|
|
1865
2132
|
*/
|
|
1866
|
-
function settleRequestedModel(ctx, catalog, provider, requested, { creating, extraModels = [] }) {
|
|
2133
|
+
function settleRequestedModel(ctx, catalog, provider, requested, { creating, extraModels = [], updating = false }) {
|
|
1867
2134
|
const entry = catalog ? listProviders(catalog).find((candidate) => candidate.provider === provider) : null;
|
|
1868
2135
|
const offered = catalog ? offeredModelList(catalog, provider) : null;
|
|
1869
2136
|
const check = checkRequestedProviderModel({
|
|
@@ -1879,7 +2146,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1879
2146
|
const label = providerModelLabel(provider);
|
|
1880
2147
|
if (check.status === 'unknown') {
|
|
1881
2148
|
throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unknownModel,
|
|
1882
|
-
`${unknownProviderModelText({ model: requested, provider, suggestions: check.suggestions, models: check.models, where })} ${creating ? 'No session was created.' : 'Nothing was sent.'}`,
|
|
2149
|
+
`${unknownProviderModelText({ model: requested, provider, suggestions: check.suggestions, models: check.models, where })} ${creating ? 'No session was created.' : (updating ? 'Nothing was changed.' : 'Nothing was sent.')}`,
|
|
1883
2150
|
{
|
|
1884
2151
|
provider,
|
|
1885
2152
|
model: requested,
|
|
@@ -1891,7 +2158,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1891
2158
|
if (check.status === 'unchecked') {
|
|
1892
2159
|
return {
|
|
1893
2160
|
model: requested,
|
|
1894
|
-
note: `Model '${requested}' could not be checked: ${label} ${where} has no model list yet (its model discovery has not run or failed), so ${creating ? 'the session was created' : 'the prompt was sent
|
|
2161
|
+
note: `Model '${requested}' could not be checked: ${label} ${where} has no model list yet (its model discovery has not run or failed), so ${creating ? 'the session was created with it' : (updating ? 'the session was set to it' : 'the prompt was sent with it')} as asked.`,
|
|
1895
2162
|
};
|
|
1896
2163
|
}
|
|
1897
2164
|
const tierNote = check.tierDropped ? ` ${label} ${where} lists no "[1m]" id for it, so it runs with the standard context.` : '';
|
|
@@ -2008,8 +2275,13 @@ export function createRemoteRelayDispatcher({
|
|
|
2008
2275
|
// What New Chat sends for a Claude Cloud conversation; the bootstrap
|
|
2009
2276
|
// validates it and refuses with its own codes.
|
|
2010
2277
|
...(cloud ? { cloudSource: { repoUrl: args.repo, ...(args.branch ? { branch: args.branch } : {}) } } : {}),
|
|
2278
|
+
// The group the lookup found (its id), else the agent's words; the
|
|
2279
|
+
// target resolves it again and refuses before creating anything.
|
|
2280
|
+
...(args.group ? { group: ctx.group?.id || args.group } : {}),
|
|
2011
2281
|
origin,
|
|
2012
2282
|
},
|
|
2283
|
+
}).catch((error) => {
|
|
2284
|
+
throw bootstrapGroupFailure(ctx, error, args.group) || error;
|
|
2013
2285
|
});
|
|
2014
2286
|
let bootstrap;
|
|
2015
2287
|
try {
|
|
@@ -2032,6 +2304,16 @@ export function createRemoteRelayDispatcher({
|
|
|
2032
2304
|
throw new DispatchFailure(502, `${REMOTE_RELAY_ERROR_CODES.httpPrefix}502`,
|
|
2033
2305
|
`Relay "${ctx.relay.name}" did not return the new session's id.`);
|
|
2034
2306
|
}
|
|
2307
|
+
// A relay that takes group answers the key (null outside a group); one
|
|
2308
|
+
// that predates it created the session and passed group over.
|
|
2309
|
+
const groupReported = isPlainObject(bootstrap) && Object.prototype.hasOwnProperty.call(bootstrap, 'group');
|
|
2310
|
+
const group = groupReported ? bootstrapGroupField(bootstrap.group) : null;
|
|
2311
|
+
let groupWarning = null;
|
|
2312
|
+
if (args.group && !groupReported) {
|
|
2313
|
+
groupWarning = `The session was created WITHOUT a group: ${groupWhere(ctx)} ignored group "${clipLine(args.group, 80)}" (its OAR is older and does not take group on create_session). Tell the user; the owner can move the session into the group by hand.`;
|
|
2314
|
+
} else if (args.group && (!group || (ctx.group?.id && group.id !== ctx.group.id))) {
|
|
2315
|
+
groupWarning = `The session was created, but not in group "${clipLine(args.group, 80)}": ${groupWhere(ctx)} put it ${group ? `in "${group.name || group.id}" (${group.id})` : 'in no group'}. Tell the user.`;
|
|
2316
|
+
}
|
|
2035
2317
|
const boundModel = toText(bootstrap?.selectedModel) || model;
|
|
2036
2318
|
// A relay that swapped the asked model for another (one that predates
|
|
2037
2319
|
// these checks does for Copilot) is named, never passed over in silence.
|
|
@@ -2060,7 +2342,7 @@ export function createRemoteRelayDispatcher({
|
|
|
2060
2342
|
note: 'Use send with this session and an effort it takes (or none); do not create another session.',
|
|
2061
2343
|
});
|
|
2062
2344
|
}
|
|
2063
|
-
|
|
2345
|
+
const result = await deliverAndWait('create_session', ctx, {
|
|
2064
2346
|
session,
|
|
2065
2347
|
sent,
|
|
2066
2348
|
waitSeconds: args.wait_seconds,
|
|
@@ -2074,8 +2356,18 @@ export function createRemoteRelayDispatcher({
|
|
|
2074
2356
|
...(toText(bootstrap?.workspaceRootWarning) ? { cwdWarning: toText(bootstrap.workspaceRootWarning) } : {}),
|
|
2075
2357
|
...(note ? { modelNote: note } : {}),
|
|
2076
2358
|
...effortResult(choice),
|
|
2359
|
+
// The session group it went into, as list_sessions shows it.
|
|
2360
|
+
group,
|
|
2361
|
+
...(groupWarning ? { groupWarning } : {}),
|
|
2077
2362
|
},
|
|
2078
2363
|
});
|
|
2364
|
+
if (!groupWarning) return result;
|
|
2365
|
+
// Never a plain success: the summary and the note say what is missing.
|
|
2366
|
+
return {
|
|
2367
|
+
...result,
|
|
2368
|
+
summary: `${result.summary} (no group: ${groupReported ? 'not applied' : 'target too old'})`,
|
|
2369
|
+
note: [groupWarning, result.note].map(toText).filter(Boolean).join(' '),
|
|
2370
|
+
};
|
|
2079
2371
|
}
|
|
2080
2372
|
|
|
2081
2373
|
/** The provider a new session gets: the one asked for, else the calling session's. */
|
|
@@ -2164,6 +2456,11 @@ export function createRemoteRelayDispatcher({
|
|
|
2164
2456
|
`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.`,
|
|
2165
2457
|
{ session });
|
|
2166
2458
|
}
|
|
2459
|
+
if (toText(question.kind) === RELAY_ASK_KIND) {
|
|
2460
|
+
throw new DispatchFailure(403, REMOTE_RELAY_ERROR_CODES.forbidden,
|
|
2461
|
+
`Question ${args.question_id} was asked with ask: it is the user's to answer, in the Inbox or that session, not an agent's. Leave it to the user.`,
|
|
2462
|
+
{ session });
|
|
2463
|
+
}
|
|
2167
2464
|
if (toText(question.status) && toText(question.status) !== 'pending') {
|
|
2168
2465
|
throw new DispatchFailure(409, `${REMOTE_RELAY_ERROR_CODES.httpPrefix}409`,
|
|
2169
2466
|
`Question ${args.question_id} on relay "${ctx.relay.name}" is already ${toText(question.status).replace(/_/g, ' ')}.`,
|
|
@@ -2221,6 +2518,244 @@ export function createRemoteRelayDispatcher({
|
|
|
2221
2518
|
};
|
|
2222
2519
|
}
|
|
2223
2520
|
|
|
2521
|
+
// ─── update_session ────────────────────────────────────────────────────────
|
|
2522
|
+
|
|
2523
|
+
/** The settings of a call (`args` without relay and session), as the target's route takes them. */
|
|
2524
|
+
function requestedSettings(args) {
|
|
2525
|
+
const settings = {};
|
|
2526
|
+
for (const key of REMOTE_RELAY_SESSION_SETTINGS) {
|
|
2527
|
+
if (Object.prototype.hasOwnProperty.call(args, key)) settings[key] = args[key];
|
|
2528
|
+
}
|
|
2529
|
+
return settings;
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
/**
|
|
2533
|
+
* A refusal of the target's settings route, as the agent reads it (a
|
|
2534
|
+
* DispatchFailure), or null for one mapRemoteError handles (offline, token,
|
|
2535
|
+
* inbound switch). A 404 without the route's own code is a relay that
|
|
2536
|
+
* predates update_session.
|
|
2537
|
+
*/
|
|
2538
|
+
function sessionSettingsFailure(ctx, error, session) {
|
|
2539
|
+
if (!isRemoteRelayError(error)) return null;
|
|
2540
|
+
const C = REMOTE_RELAY_ERROR_CODES;
|
|
2541
|
+
const body = isPlainObject(error.remoteBody) ? error.remoteBody : {};
|
|
2542
|
+
const remoteCode = toText(body.code);
|
|
2543
|
+
const status = Number(error.status) || 0;
|
|
2544
|
+
const where = ctx.relay.local ? 'this relay' : `relay "${ctx.relay.name}"`;
|
|
2545
|
+
const text = toText(body.error) || toText(error.message);
|
|
2546
|
+
// What the target wrote before the step that failed.
|
|
2547
|
+
const changed = Array.isArray(body.changed) ? body.changed.map(toText).filter(Boolean) : [];
|
|
2548
|
+
const failed = (failureStatus, code, message, extra = {}) => new DispatchFailure(failureStatus, code, message, { session, ...extra, changed });
|
|
2549
|
+
if (remoteCode === 'PROCESS_LIST_UNREADABLE') {
|
|
2550
|
+
return failed(503, `${C.httpPrefix}503`, text, { remoteCode, note: 'That relay could not read its process list for a moment: try the change again shortly.' });
|
|
2551
|
+
}
|
|
2552
|
+
if (status === 404 || toText(error.code) === C.notFound) {
|
|
2553
|
+
if (remoteCode === SESSION_SETTINGS_CODES.notFound) {
|
|
2554
|
+
return new DispatchFailure(404, C.notFound, `Session ${session} was not found on ${where}.`, { session });
|
|
2555
|
+
}
|
|
2556
|
+
return new DispatchFailure(400, C.unsupported,
|
|
2557
|
+
`${sentence(where)} cannot change a session's settings for agents yet: it runs an older OAR (no update_session there). Nothing was changed.`, { session });
|
|
2558
|
+
}
|
|
2559
|
+
if (remoteCode === C.permission) {
|
|
2560
|
+
return failed(403, C.permission, `${text} Nothing was changed.`);
|
|
2561
|
+
}
|
|
2562
|
+
if (remoteCode === SESSION_SETTINGS_CODES.busy) {
|
|
2563
|
+
return failed(409, C.busy, text, { reason: toText(body.reason) || null });
|
|
2564
|
+
}
|
|
2565
|
+
if (remoteCode === SESSION_SETTINGS_CODES.groupNotFound || remoteCode === SESSION_SETTINGS_CODES.groupAmbiguous) {
|
|
2566
|
+
return failed(400, C.unknownGroup, text, {
|
|
2567
|
+
groups: mapGroupList(body).slice(0, REMOTE_RELAY_GROUP_LIMITS.refusalMax).map((group) => ({ id: group.id, name: group.name })),
|
|
2568
|
+
...(Number.isInteger(body.groupsTotal) ? { groupsTotal: body.groupsTotal } : {}),
|
|
2569
|
+
note: 'Pass one of these ids, a name for a new group, or "none".',
|
|
2570
|
+
});
|
|
2571
|
+
}
|
|
2572
|
+
if (status === 403 && remoteCode && remoteCode !== C.inboundDisabled) {
|
|
2573
|
+
return failed(403, remoteCode, text || `${sentence(where)} refused the change.`);
|
|
2574
|
+
}
|
|
2575
|
+
if (status >= 400 && status < 500 && status !== 401 && remoteCode !== C.inboundDisabled) {
|
|
2576
|
+
return failed(status, status === 429 ? C.rateLimited : C.invalidInput, text || `${sentence(where)} refused the change.`, remoteCode ? { remoteCode } : {});
|
|
2577
|
+
}
|
|
2578
|
+
// A step that failed on that relay after an earlier one wrote (a CLI that
|
|
2579
|
+
// would not restart in the new folder): what was written comes along.
|
|
2580
|
+
if (status >= 500 && (changed.length || toText(body.error))) {
|
|
2581
|
+
return failed(502, `${C.httpPrefix}${status}`, text || `${sentence(where)} failed to make the change.`, remoteCode ? { remoteCode } : {});
|
|
2582
|
+
}
|
|
2583
|
+
return null;
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
async function sessionSettingsCall(ctx, session, body) {
|
|
2587
|
+
try {
|
|
2588
|
+
// The origin names the calling conversation: the target changes freely
|
|
2589
|
+
// only a session that conversation started.
|
|
2590
|
+
const origin = await buildOrigin(ctx);
|
|
2591
|
+
return await call(ctx, 'PATCH', sessionPath(session, SESSION_SETTINGS_SUFFIX), { body: { ...body, origin }, timeoutMs: 60_000 });
|
|
2592
|
+
} catch (error) {
|
|
2593
|
+
throw sessionSettingsFailure(ctx, error, session) || error;
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
/**
|
|
2598
|
+
* What an update_session would change, checked before anyone is asked:
|
|
2599
|
+
* the model against the target's list (as send checks it), the effort
|
|
2600
|
+
* against what that model takes, then the target's own dry run (groups,
|
|
2601
|
+
* folder, its rule). Throws a DispatchFailure for anything refused.
|
|
2602
|
+
*/
|
|
2603
|
+
async function planSessionUpdate(ctx, args) {
|
|
2604
|
+
const conversation = await call(ctx, 'GET', sessionPath(args.session), { query: { limit: 1 } });
|
|
2605
|
+
const session = toText(conversation?.id) || args.session;
|
|
2606
|
+
const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
|
|
2607
|
+
const provider = toText(runtime.providerType).toLowerCase() || 'github';
|
|
2608
|
+
const where = `on relay "${ctx.relay.name}"`;
|
|
2609
|
+
const settings = requestedSettings(args);
|
|
2610
|
+
const notes = [];
|
|
2611
|
+
let catalog = null;
|
|
2612
|
+
if (settings.model || settings.effort) catalog = await loadCatalog(ctx).catch(() => null);
|
|
2613
|
+
const ownModel = toText(conversation?.preferredModel) || toText(runtime.providerModel) || toText(runtime.model);
|
|
2614
|
+
if (settings.model) {
|
|
2615
|
+
if (provider === 'grok' && ownModel && settings.model.toLowerCase() !== ownModel.toLowerCase()) {
|
|
2616
|
+
throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput,
|
|
2617
|
+
`A Grok session keeps the model it was started with (${ownModel}); start a new session for another one. Nothing was changed.`, { session });
|
|
2618
|
+
}
|
|
2619
|
+
const settled = settleRequestedModel(ctx, catalog, provider, settings.model, {
|
|
2620
|
+
creating: false,
|
|
2621
|
+
updating: true,
|
|
2622
|
+
extraModels: [
|
|
2623
|
+
...(Array.isArray(catalog?.settings?.[provider]?.availableModels) ? catalog.settings[provider].availableModels : []),
|
|
2624
|
+
runtime.providerModel,
|
|
2625
|
+
runtime.model,
|
|
2626
|
+
],
|
|
2627
|
+
});
|
|
2628
|
+
settings.model = settled.model;
|
|
2629
|
+
if (settled.note) notes.push(settled.note);
|
|
2630
|
+
// A Copilot model named with its effort brings that effort.
|
|
2631
|
+
if (!settings.effort && normalizeEffort(settled.effort)) settings.effort = normalizeEffort(settled.effort);
|
|
2632
|
+
}
|
|
2633
|
+
if (settings.effort) {
|
|
2634
|
+
if (provider === CLOUD_PROVIDER) {
|
|
2635
|
+
throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput,
|
|
2636
|
+
`A Claude Cloud session takes no reasoning effort. Nothing was changed.`, { session });
|
|
2637
|
+
}
|
|
2638
|
+
const model = settings.model || ownModel;
|
|
2639
|
+
const offered = catalog ? catalogEffortsFor(catalog.models, provider, model) : null;
|
|
2640
|
+
if (offered && offered.length && !offered.includes(settings.effort)) {
|
|
2641
|
+
throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput,
|
|
2642
|
+
`${model || provider} ${where} does not take effort "${settings.effort}"${supportedEffortsText(offered)}. Nothing was changed.`,
|
|
2643
|
+
{ session, supportedEfforts: offered });
|
|
2644
|
+
}
|
|
2645
|
+
if (!offered) notes.push(`Effort "${settings.effort}" could not be checked against ${model || provider} ${where} (no list of its efforts); it was set as asked.`);
|
|
2646
|
+
}
|
|
2647
|
+
const dry = await sessionSettingsCall(ctx, session, { settings, dry_run: true });
|
|
2648
|
+
if (!Array.isArray(dry?.changed)) {
|
|
2649
|
+
throw new DispatchFailure(502, `${REMOTE_RELAY_ERROR_CODES.httpPrefix}502`, `Relay "${ctx.relay.name}" did not answer the check of the change as expected. Nothing was changed.`, { session });
|
|
2650
|
+
}
|
|
2651
|
+
return {
|
|
2652
|
+
session,
|
|
2653
|
+
title: toText(conversation?.title) || null,
|
|
2654
|
+
provider,
|
|
2655
|
+
// Whether the owner must allow it: every session the calling
|
|
2656
|
+
// conversation did not start itself (the target decides).
|
|
2657
|
+
approvalNeeded: dry.approvalNeeded !== false,
|
|
2658
|
+
changed: dry.changed.map(toText).filter(Boolean),
|
|
2659
|
+
current: isPlainObject(dry.settings) ? dry.settings : null,
|
|
2660
|
+
group: isPlainObject(dry.group) ? dry.group : null,
|
|
2661
|
+
settings,
|
|
2662
|
+
notes: [...notes, ...(Array.isArray(dry.notes) ? dry.notes.map(toText).filter(Boolean) : [])],
|
|
2663
|
+
};
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
/** The `settings` of an answer as the agent reads it: the target's, the group in list_sessions' shape. */
|
|
2667
|
+
function settingsResult(settings) {
|
|
2668
|
+
if (!isPlainObject(settings)) return null;
|
|
2669
|
+
return { ...settings, group: bootstrapGroupField(settings.group) };
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
async function updateSession(ctx, args) {
|
|
2673
|
+
const plan = ctx.updatePlan || await planSessionUpdate(ctx, args);
|
|
2674
|
+
const openUrl = remoteConversationUrl(ctx.relay.url, plan.session) || null;
|
|
2675
|
+
const where = `${ctx.relay.name} session ${shortId(plan.session)}`;
|
|
2676
|
+
if (!plan.changed.length) {
|
|
2677
|
+
return {
|
|
2678
|
+
summary: `update_session → ${where}: nothing to change`,
|
|
2679
|
+
session: plan.session,
|
|
2680
|
+
settings: settingsResult(plan.current),
|
|
2681
|
+
changed: [],
|
|
2682
|
+
openUrl,
|
|
2683
|
+
note: [...plan.notes, 'The session already has these settings; nothing was changed.'].join(' '),
|
|
2684
|
+
};
|
|
2685
|
+
}
|
|
2686
|
+
const answer = await sessionSettingsCall(ctx, plan.session, { settings: plan.settings, owner_approved: plan.approvalNeeded });
|
|
2687
|
+
const changed = Array.isArray(answer?.changed) ? answer.changed.map(toText).filter(Boolean) : plan.changed;
|
|
2688
|
+
const notes = [...plan.notes.filter((note) => !(answer?.notes || []).includes(note)), ...(Array.isArray(answer?.notes) ? answer.notes.map(toText).filter(Boolean) : [])];
|
|
2689
|
+
if (changed.includes('mode')) {
|
|
2690
|
+
notes.push('This mode lasts until the next send that names another mode or comes from a caller in another mode: a send without mode passes the caller\'s own mode.');
|
|
2691
|
+
}
|
|
2692
|
+
return {
|
|
2693
|
+
summary: `update_session → ${where}: ${changed.join(', ') || 'nothing changed'}`,
|
|
2694
|
+
session: plan.session,
|
|
2695
|
+
settings: settingsResult(answer?.settings),
|
|
2696
|
+
changed,
|
|
2697
|
+
...(typeof answer?.relaunched === 'boolean' ? { relaunched: answer.relaunched } : {}),
|
|
2698
|
+
...(isPlainObject(answer?.groupCreated) ? { groupCreated: { id: toText(answer.groupCreated.id), name: toText(answer.groupCreated.name) || null } } : {}),
|
|
2699
|
+
openUrl,
|
|
2700
|
+
...(notes.length ? { note: notes.join(' ') } : {}),
|
|
2701
|
+
};
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
// The owner's Allow for a conversation to change sessions on a relay that it
|
|
2705
|
+
// did not start itself, stored as an unlock under `<relay id>:session-settings`.
|
|
2706
|
+
function sessionSettingsApprovalKey(relayId) {
|
|
2707
|
+
return `${toText(relayId) || REMOTE_RELAY_LOCAL_ID}:session-settings`;
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
/**
|
|
2711
|
+
* Asked on the first update_session of a conversation that aims at a
|
|
2712
|
+
* session on that relay it did not start itself (the owner's, another
|
|
2713
|
+
* agent's, its own creator's); remembered once allowed.
|
|
2714
|
+
* Parallel calls share the open card. Resolves `{ approved, asked }` or
|
|
2715
|
+
* `{ approved:false, code, error }`.
|
|
2716
|
+
*/
|
|
2717
|
+
async function ensureSessionSettingsApproval(ctx, plan, args) {
|
|
2718
|
+
const { conversationId } = ctx;
|
|
2719
|
+
const key = sessionSettingsApprovalKey(ctx.relay.id);
|
|
2720
|
+
if (hasApprovalKey(conversationId, key)) return { approved: true, asked: false };
|
|
2721
|
+
const openKey = `${conversationId}:${key}`;
|
|
2722
|
+
const open = sessionSettingsApprovalsByKey.get(openKey);
|
|
2723
|
+
if (open) {
|
|
2724
|
+
const shared = await open;
|
|
2725
|
+
return shared.approved ? { approved: true, asked: false } : shared;
|
|
2726
|
+
}
|
|
2727
|
+
const asking = (async () => {
|
|
2728
|
+
const decision = await requestApproval({
|
|
2729
|
+
conversationId,
|
|
2730
|
+
callerContext: ctx.caller,
|
|
2731
|
+
relay: relayTarget(ctx.relay),
|
|
2732
|
+
action: 'update_session',
|
|
2733
|
+
args,
|
|
2734
|
+
summary: summarizeRemoteRelayCall({ action: 'update_session', ...args, relay: ctx.relay.name }),
|
|
2735
|
+
signal: ctx.signal,
|
|
2736
|
+
sessionSettings: { session: plan.session, title: plan.title, changed: plan.changed },
|
|
2737
|
+
});
|
|
2738
|
+
if (decision !== true && decision?.approved !== true) {
|
|
2739
|
+
const where = ctx.relay.local ? 'this relay' : `relay "${ctx.relay.name}"`;
|
|
2740
|
+
return {
|
|
2741
|
+
approved: false,
|
|
2742
|
+
code: toText(decision?.code) === REMOTE_RELAY_ERROR_CODES.noTurn ? REMOTE_RELAY_ERROR_CODES.noTurn : REMOTE_RELAY_ERROR_CODES.permission,
|
|
2743
|
+
error: toText(decision?.error)
|
|
2744
|
+
? `${toText(decision.error)} Session ${plan.session} on ${where} was not started by this conversation; its settings were not changed.`
|
|
2745
|
+
: `The owner did not allow this conversation to change sessions on ${where} that it did not start; session ${plan.session} was not changed. Sessions this conversation started there can still be changed.`,
|
|
2746
|
+
};
|
|
2747
|
+
}
|
|
2748
|
+
recordApprovalKeys(conversationId, [key], ctx.caller?.processingRowId);
|
|
2749
|
+
return { approved: true, asked: true };
|
|
2750
|
+
})();
|
|
2751
|
+
sessionSettingsApprovalsByKey.set(openKey, asking);
|
|
2752
|
+
try {
|
|
2753
|
+
return await asking;
|
|
2754
|
+
} finally {
|
|
2755
|
+
sessionSettingsApprovalsByKey.delete(openKey);
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2224
2759
|
// ─── Event subscriptions ───────────────────────────────────────────────────
|
|
2225
2760
|
|
|
2226
2761
|
const RELAY_EVENT_ERROR_STATUS = Object.freeze({
|
|
@@ -2287,7 +2822,7 @@ export function createRemoteRelayDispatcher({
|
|
|
2287
2822
|
repository?.recordUnlock?.(conversationId, key, messageId || null);
|
|
2288
2823
|
} catch (error) {
|
|
2289
2824
|
try {
|
|
2290
|
-
logger?.warn?.(`${LOG_PREFIX}
|
|
2825
|
+
logger?.warn?.(`${LOG_PREFIX} approval ${key} not stored conv=${shortId(conversationId)}: ${toText(error?.message || error).slice(0, 200)}`);
|
|
2291
2826
|
} catch {}
|
|
2292
2827
|
}
|
|
2293
2828
|
}
|
|
@@ -2336,6 +2871,15 @@ export function createRemoteRelayDispatcher({
|
|
|
2336
2871
|
}
|
|
2337
2872
|
}
|
|
2338
2873
|
|
|
2874
|
+
/** A subscribe's options as the approval card names them. */
|
|
2875
|
+
function watchOptions(args) {
|
|
2876
|
+
return {
|
|
2877
|
+
...(args.digest_minutes ? { digestMinutes: args.digest_minutes } : {}),
|
|
2878
|
+
...(args.quiet_hours ? { quietHours: args.quiet_hours } : {}),
|
|
2879
|
+
...(args.exclude?.length ? { excludeCount: args.exclude.length } : {}),
|
|
2880
|
+
};
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2339
2883
|
async function subscribeAction(ctx, args) {
|
|
2340
2884
|
if (!ctx.relay.local) return subscribeRemoteAction(ctx, args);
|
|
2341
2885
|
const service = eventService(ctx);
|
|
@@ -2362,7 +2906,7 @@ export function createRemoteRelayDispatcher({
|
|
|
2362
2906
|
if (all || ids.length) {
|
|
2363
2907
|
const decision = await ensureWatchApproval(ctx, {
|
|
2364
2908
|
all,
|
|
2365
|
-
watch: { kind: 'subscribe', all, sessions: titles, events, once: args.once === true, heartbeatMinutes: args.heartbeat_minutes ?? null },
|
|
2909
|
+
watch: { kind: 'subscribe', all, sessions: titles, events, once: args.once === true, heartbeatMinutes: args.heartbeat_minutes ?? null, ...watchOptions(args) },
|
|
2366
2910
|
});
|
|
2367
2911
|
if (!decision.approved) throw new DispatchFailure(403, decision.code, decision.error);
|
|
2368
2912
|
}
|
|
@@ -2375,6 +2919,9 @@ export function createRemoteRelayDispatcher({
|
|
|
2375
2919
|
match: args.match || '',
|
|
2376
2920
|
stalledMinutes: args.stalled_minutes ?? null,
|
|
2377
2921
|
heartbeatMinutes: args.heartbeat_minutes ?? null,
|
|
2922
|
+
digestMinutes: args.digest_minutes ?? null,
|
|
2923
|
+
quietHours: args.quiet_hours ?? null,
|
|
2924
|
+
exclude: args.exclude ?? null,
|
|
2378
2925
|
}));
|
|
2379
2926
|
const what = all ? 'all sessions' : (ids.length ? `${ids.length} session${ids.length === 1 ? '' : 's'}` : 'relay_restarted');
|
|
2380
2927
|
return {
|
|
@@ -2402,6 +2949,12 @@ export function createRemoteRelayDispatcher({
|
|
|
2402
2949
|
const service = eventService(ctx);
|
|
2403
2950
|
const all = args.scope === 'all';
|
|
2404
2951
|
relayEventCall(() => service.checkWatcherCapacity(ctx.conversationId));
|
|
2952
|
+
if ((args.digest_minutes || args.quiet_hours || args.exclude?.length) && service.areOptionsAvailable?.() === false) {
|
|
2953
|
+
throw new DispatchFailure(503, REMOTE_RELAY_ERROR_CODES.unsupported,
|
|
2954
|
+
'digest_minutes, quiet_hours and exclude are not available on this relay right now (see its server log).');
|
|
2955
|
+
}
|
|
2956
|
+
// Digest and quiet hours are applied here, where the events are
|
|
2957
|
+
// delivered; exclude is also the watched relay's (all_idle, stalled).
|
|
2405
2958
|
const request = {
|
|
2406
2959
|
watcher: { conversationId: ctx.conversationId, title: toText(ctx.caller?.title) || null },
|
|
2407
2960
|
subscriber: { name: toText(await registry.selfName?.()) || null },
|
|
@@ -2412,6 +2965,7 @@ export function createRemoteRelayDispatcher({
|
|
|
2412
2965
|
...(args.match ? { match: args.match } : {}),
|
|
2413
2966
|
...(args.stalled_minutes !== undefined ? { stalled_minutes: args.stalled_minutes } : {}),
|
|
2414
2967
|
...(args.heartbeat_minutes !== undefined ? { heartbeat_minutes: args.heartbeat_minutes } : {}),
|
|
2968
|
+
...(args.exclude?.length ? { exclude: args.exclude } : {}),
|
|
2415
2969
|
};
|
|
2416
2970
|
const checked = await relayEventRemoteCall(ctx, RELAY_EVENT_REMOTE_SUBSCRIBE_PATH, { ...request, dry_run: true });
|
|
2417
2971
|
const preview = isPlainObject(checked?.subscription) ? checked.subscription : {};
|
|
@@ -2424,6 +2978,7 @@ export function createRemoteRelayDispatcher({
|
|
|
2424
2978
|
events: Array.isArray(preview.events) ? preview.events : (args.events || [...RELAY_EVENT_DEFAULT_TYPES]),
|
|
2425
2979
|
once: args.once === true,
|
|
2426
2980
|
heartbeatMinutes: args.heartbeat_minutes ?? null,
|
|
2981
|
+
...watchOptions(args),
|
|
2427
2982
|
},
|
|
2428
2983
|
});
|
|
2429
2984
|
if (!decision.approved) throw new DispatchFailure(403, decision.code, decision.error);
|
|
@@ -2439,6 +2994,9 @@ export function createRemoteRelayDispatcher({
|
|
|
2439
2994
|
relayId: ctx.relay.id,
|
|
2440
2995
|
relayName: ctx.relay.name,
|
|
2441
2996
|
remote,
|
|
2997
|
+
digestMinutes: args.digest_minutes ?? null,
|
|
2998
|
+
quietHours: args.quiet_hours ?? null,
|
|
2999
|
+
exclude: args.exclude ?? null,
|
|
2442
3000
|
}));
|
|
2443
3001
|
} catch (error) {
|
|
2444
3002
|
// Not kept here: end it there too, so it does not wait for a fetch that never comes.
|
|
@@ -2550,7 +3108,266 @@ export function createRemoteRelayDispatcher({
|
|
|
2550
3108
|
};
|
|
2551
3109
|
}
|
|
2552
3110
|
|
|
3111
|
+
// ─── Schedules ─────────────────────────────────────────────────────────────
|
|
3112
|
+
|
|
3113
|
+
const RELAY_SCHEDULE_ERROR_STATUS = Object.freeze({
|
|
3114
|
+
invalid: [400, REMOTE_RELAY_ERROR_CODES.invalidInput],
|
|
3115
|
+
notFound: [404, REMOTE_RELAY_ERROR_CODES.notFound],
|
|
3116
|
+
limit: [409, REMOTE_RELAY_ERROR_CODES.scheduleLimit],
|
|
3117
|
+
unavailable: [503, REMOTE_RELAY_ERROR_CODES.unsupported],
|
|
3118
|
+
});
|
|
3119
|
+
const SCHEDULE_NOTE = 'At its time the relay queues the text into the session as a message that starts with "[relay schedule"; it is the relay\'s automated message, never the user\'s, and approves nothing. It survives relay restarts; unschedule with this schedule_id ends it.';
|
|
3120
|
+
const SCHEDULE_OTHER_NOTE = 'Each firing counts as one of your prompts to other agents: once you have sent as many as you may before the user writes, it waits (heldForApproval in list_schedules) until the user writes in this conversation.';
|
|
3121
|
+
|
|
3122
|
+
/** The service for a call on this relay; a paired relay keeps its own schedules. */
|
|
3123
|
+
function scheduleService(ctx) {
|
|
3124
|
+
if (!ctx.relay.local) {
|
|
3125
|
+
throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unsupported,
|
|
3126
|
+
`Schedules are kept by the relay you run on: use relay "${REMOTE_RELAY_LOCAL_ALIAS}" (or leave relay out). For a session on relay "${ctx.relay.name}", ask an agent there to schedule it.`);
|
|
3127
|
+
}
|
|
3128
|
+
if (!relaySchedules?.isAvailable?.()) {
|
|
3129
|
+
throw new DispatchFailure(503, REMOTE_RELAY_ERROR_CODES.unsupported,
|
|
3130
|
+
'Schedules are not available on this relay right now (see its server log).');
|
|
3131
|
+
}
|
|
3132
|
+
return relaySchedules;
|
|
3133
|
+
}
|
|
3134
|
+
|
|
3135
|
+
function relayScheduleCall(fn) {
|
|
3136
|
+
try {
|
|
3137
|
+
return fn();
|
|
3138
|
+
} catch (error) {
|
|
3139
|
+
const mapped = RELAY_SCHEDULE_ERROR_STATUS[toText(error?.code)];
|
|
3140
|
+
if (mapped) throw new DispatchFailure(mapped[0], mapped[1], toText(error.message), isPlainObject(error.extra) ? error.extra : {});
|
|
3141
|
+
throw error;
|
|
3142
|
+
}
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3145
|
+
/** The conversation a schedule call names (`session`), as this relay knows it; the caller's own by default. */
|
|
3146
|
+
function scheduleSession(ctx, args) {
|
|
3147
|
+
if (!toText(args.session) || isOwnConversation(ctx, args.session)) return ctx.conversationId;
|
|
3148
|
+
try {
|
|
3149
|
+
return toText(resolveConversationId(args.session)) || toText(args.session);
|
|
3150
|
+
} catch {
|
|
3151
|
+
return toText(args.session);
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
/**
|
|
3156
|
+
* The provenance a schedule's messages carry, so that a schedule never
|
|
3157
|
+
* passes a prompt on with fewer hops than the turn that set it: for another
|
|
3158
|
+
* session the origin a send from this turn would carry; for the caller's
|
|
3159
|
+
* own conversation the origin of the prompt this turn acts on (none when it
|
|
3160
|
+
* acts on the user's words alone).
|
|
3161
|
+
*/
|
|
3162
|
+
async function scheduleOrigin(ctx, session) {
|
|
3163
|
+
if (session !== ctx.conversationId) return buildOrigin(ctx);
|
|
3164
|
+
if (!(ctx.hops > 0)) return null;
|
|
3165
|
+
const acting = isPlainObject(ctx.caller?.origin) ? normalizeRemoteRelayOrigin({ ...ctx.caller.origin, hops: ctx.hops }) : null;
|
|
3166
|
+
return acting || buildOrigin(ctx);
|
|
3167
|
+
}
|
|
3168
|
+
|
|
3169
|
+
async function scheduleAction(ctx, args) {
|
|
3170
|
+
const service = scheduleService(ctx);
|
|
3171
|
+
const session = scheduleSession(ctx, args);
|
|
3172
|
+
const origin = await scheduleOrigin(ctx, session);
|
|
3173
|
+
const schedule = relayScheduleCall(() => service.create({
|
|
3174
|
+
conversationId: session,
|
|
3175
|
+
createdBy: ctx.conversationId,
|
|
3176
|
+
text: args.text,
|
|
3177
|
+
at: args.at ?? null,
|
|
3178
|
+
everyMinutes: args.every_minutes ?? null,
|
|
3179
|
+
once: args.once ?? null,
|
|
3180
|
+
title: args.title || '',
|
|
3181
|
+
origin,
|
|
3182
|
+
}));
|
|
3183
|
+
const when = schedule.everyMinutes
|
|
3184
|
+
? `every ${schedule.everyMinutes} min${schedule.once ? ', once' : ''}`
|
|
3185
|
+
: 'once';
|
|
3186
|
+
const forOther = session !== ctx.conversationId;
|
|
3187
|
+
return {
|
|
3188
|
+
summary: `schedule → ${ctx.relay.name}${forOther ? ` session ${shortId(session)}` : ''}: ${when}, next ${schedule.nextFireAt} (${schedule.id})`,
|
|
3189
|
+
schedule_id: schedule.id,
|
|
3190
|
+
schedule,
|
|
3191
|
+
note: forOther ? `${SCHEDULE_NOTE} ${SCHEDULE_OTHER_NOTE}` : SCHEDULE_NOTE,
|
|
3192
|
+
};
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
async function listSchedulesAction(ctx, args) {
|
|
3196
|
+
const service = scheduleService(ctx);
|
|
3197
|
+
const session = scheduleSession(ctx, args);
|
|
3198
|
+
const schedules = relayScheduleCall(() => service.list({ conversationId: session }));
|
|
3199
|
+
const active = schedules.filter((schedule) => !schedule.endedAt).length;
|
|
3200
|
+
return {
|
|
3201
|
+
summary: `list_schedules → ${ctx.relay.name}${session === ctx.conversationId ? '' : ` session ${shortId(session)}`}: ${active} active`,
|
|
3202
|
+
session,
|
|
3203
|
+
schedules,
|
|
3204
|
+
...(schedules.length > active ? { note: 'Schedules with endedAt ended within the last hour (endReason says why).' } : {}),
|
|
3205
|
+
};
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
async function unscheduleAction(ctx, args) {
|
|
3209
|
+
const service = scheduleService(ctx);
|
|
3210
|
+
const schedule = relayScheduleCall(() => service.unschedule({ scheduleId: args.schedule_id, byConversationId: ctx.conversationId }));
|
|
3211
|
+
return {
|
|
3212
|
+
summary: `unschedule → ${ctx.relay.name}: ${schedule.id} ended`,
|
|
3213
|
+
schedule,
|
|
3214
|
+
};
|
|
3215
|
+
}
|
|
3216
|
+
|
|
3217
|
+
// ─── Questions that don't block ────────────────────────────────────────────
|
|
3218
|
+
|
|
3219
|
+
const RELAY_ASK_ERROR_STATUS = Object.freeze({
|
|
3220
|
+
invalid: [400, REMOTE_RELAY_ERROR_CODES.invalidInput],
|
|
3221
|
+
notFound: [404, REMOTE_RELAY_ERROR_CODES.notFound],
|
|
3222
|
+
closed: [409, `${REMOTE_RELAY_ERROR_CODES.httpPrefix}409`],
|
|
3223
|
+
forbidden: [403, REMOTE_RELAY_ERROR_CODES.forbidden],
|
|
3224
|
+
limit: [409, REMOTE_RELAY_ERROR_CODES.questionLimit],
|
|
3225
|
+
unavailable: [503, REMOTE_RELAY_ERROR_CODES.unsupported],
|
|
3226
|
+
});
|
|
3227
|
+
const ASK_NOTE = 'The user sees it as a card in that session and in the Inbox. When they answer or dismiss it you get a relay event '
|
|
3228
|
+
+ 'question_answered (question_id, answer, choice, dismissed, via: the relay it was answered on) without any subscription: a message that starts with "[relay event", '
|
|
3229
|
+
+ 'automated, but its answer is the user\'s own reply to this question. Keep working on what does not depend on it and do not ask it '
|
|
3230
|
+
+ 'again meanwhile; withdraw with this question_id takes it back.';
|
|
3231
|
+
|
|
3232
|
+
function askService() {
|
|
3233
|
+
if (!relayAsks?.isAvailable?.()) {
|
|
3234
|
+
throw new DispatchFailure(503, REMOTE_RELAY_ERROR_CODES.unsupported,
|
|
3235
|
+
'Questions that don\'t block are not available on this relay right now (see its server log); use your own question tool instead.');
|
|
3236
|
+
}
|
|
3237
|
+
return relayAsks;
|
|
3238
|
+
}
|
|
3239
|
+
|
|
3240
|
+
function relayAskCall(fn) {
|
|
3241
|
+
try {
|
|
3242
|
+
return fn();
|
|
3243
|
+
} catch (error) {
|
|
3244
|
+
const mapped = RELAY_ASK_ERROR_STATUS[toText(error?.code)];
|
|
3245
|
+
if (mapped) throw new DispatchFailure(mapped[0], mapped[1], toText(error.message), isPlainObject(error.extra) ? error.extra : {});
|
|
3246
|
+
throw error;
|
|
3247
|
+
}
|
|
3248
|
+
}
|
|
3249
|
+
|
|
3250
|
+
/** The conversation an ask call names (`session`), as this relay knows it; the caller's own by default. */
|
|
3251
|
+
function askSession(ctx, args) {
|
|
3252
|
+
if (!toText(args.session) || isOwnConversation(ctx, args.session)) return ctx.conversationId;
|
|
3253
|
+
try {
|
|
3254
|
+
return toText(resolveConversationId(args.session)) || toText(args.session);
|
|
3255
|
+
} catch {
|
|
3256
|
+
return toText(args.session);
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
function askInput(args) {
|
|
3261
|
+
return {
|
|
3262
|
+
question: args.question,
|
|
3263
|
+
options: args.options || [],
|
|
3264
|
+
recommendation: args.recommendation || '',
|
|
3265
|
+
detail: args.detail || '',
|
|
3266
|
+
urgency: args.urgency || 'normal',
|
|
3267
|
+
};
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
/** A call to a paired relay's ask routes; an older relay without them answers a clear error. */
|
|
3271
|
+
async function relayAskRemoteCall(ctx, path, body) {
|
|
3272
|
+
try {
|
|
3273
|
+
return await call(ctx, 'POST', path, { body });
|
|
3274
|
+
} catch (error) {
|
|
3275
|
+
if (isMissingRelayAskRoute(error)) {
|
|
3276
|
+
throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unsupported, relayWithoutAsksText(ctx.relay.name));
|
|
3277
|
+
}
|
|
3278
|
+
throw error;
|
|
3279
|
+
}
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
async function askAction(ctx, args) {
|
|
3283
|
+
const service = askService();
|
|
3284
|
+
if (!ctx.relay.local) {
|
|
3285
|
+
if (!toText(args.session)) {
|
|
3286
|
+
throw invalidInput(`ask on relay "${ctx.relay.name}" needs session (an id from list_sessions there): your own questions go to "${REMOTE_RELAY_LOCAL_ALIAS}".`);
|
|
3287
|
+
}
|
|
3288
|
+
const response = await relayAskRemoteCall(ctx, RELAY_ASK_REMOTE_PATHS.ask, {
|
|
3289
|
+
session: args.session,
|
|
3290
|
+
...askInput(args),
|
|
3291
|
+
asker: {
|
|
3292
|
+
conversationId: ctx.conversationId,
|
|
3293
|
+
title: toText(ctx.caller?.title) || null,
|
|
3294
|
+
// Shown there when that relay has no name for this one (a pairing one way round).
|
|
3295
|
+
relayName: toText(await registry.selfName?.()) || null,
|
|
3296
|
+
},
|
|
3297
|
+
});
|
|
3298
|
+
const remote = isPlainObject(response?.ask) ? response.ask : null;
|
|
3299
|
+
if (!toText(remote?.id)) {
|
|
3300
|
+
throw new DispatchFailure(502, `${REMOTE_RELAY_ERROR_CODES.httpPrefix}502`, `Relay "${ctx.relay.name}" did not return the question.`);
|
|
3301
|
+
}
|
|
3302
|
+
const ask = relayAskCall(() => service.addMirror({ relayId: ctx.relay.id, createdBy: ctx.conversationId, remote }));
|
|
3303
|
+
try { relayAskLink?.noteAsked?.(ctx.relay.id); } catch {}
|
|
3304
|
+
return {
|
|
3305
|
+
summary: `ask → ${ctx.relay.name} session ${shortId(ask.session.id)}: question ${ask.id} pending`,
|
|
3306
|
+
question_id: ask.id,
|
|
3307
|
+
status: 'pending',
|
|
3308
|
+
question: ask,
|
|
3309
|
+
note: `${ASK_NOTE} This relay looks for the answer on relay "${ctx.relay.name}" every few seconds; the session there is told too.`,
|
|
3310
|
+
};
|
|
3311
|
+
}
|
|
3312
|
+
const session = askSession(ctx, args);
|
|
3313
|
+
const ask = relayAskCall(() => service.create({ conversationId: session, createdBy: ctx.conversationId, ...askInput(args) }));
|
|
3314
|
+
const forOther = session !== ctx.conversationId;
|
|
3315
|
+
return {
|
|
3316
|
+
summary: `ask → ${ctx.relay.name}${forOther ? ` session ${shortId(session)}` : ''}: question ${ask.id} pending${ask.urgency === 'now' ? ' (now)' : ''}`,
|
|
3317
|
+
question_id: ask.id,
|
|
3318
|
+
status: 'pending',
|
|
3319
|
+
question: ask,
|
|
3320
|
+
note: forOther ? `${ASK_NOTE} Session ${session} gets the answer too.` : ASK_NOTE,
|
|
3321
|
+
};
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
async function listQuestionsAction(ctx, args) {
|
|
3325
|
+
const service = askService();
|
|
3326
|
+
if (!ctx.relay.local) {
|
|
3327
|
+
// This conversation's asks there, as this relay last learnt them: fetched now (briefly waited for).
|
|
3328
|
+
if (service.pendingMirrorIds(ctx.relay.id).length) await relayAskLink?.fetchNow?.(ctx.relay.id)?.catch?.(() => {});
|
|
3329
|
+
const asks = service.listOf(ctx.conversationId)
|
|
3330
|
+
.filter((ask) => ask.relayId === ctx.relay.id && (!toText(args.session) || ask.session.id === toText(args.session)));
|
|
3331
|
+
return {
|
|
3332
|
+
summary: `list_questions → ${ctx.relay.name}: ${asks.filter((ask) => ask.status === 'pending').length} pending`,
|
|
3333
|
+
questions: asks,
|
|
3334
|
+
note: 'These are the questions this conversation asked there. read_session there lists every pending question of a session, asks (kind ask) included.',
|
|
3335
|
+
};
|
|
3336
|
+
}
|
|
3337
|
+
const session = askSession(ctx, args);
|
|
3338
|
+
const asks = service.listOf(session).filter((ask) => !ask.relayId || session === ctx.conversationId);
|
|
3339
|
+
const pending = asks.filter((ask) => ask.status === 'pending').length;
|
|
3340
|
+
return {
|
|
3341
|
+
summary: `list_questions → ${ctx.relay.name}${session === ctx.conversationId ? '' : ` session ${shortId(session)}`}: ${pending} pending`,
|
|
3342
|
+
session,
|
|
3343
|
+
questions: asks,
|
|
3344
|
+
...(asks.length > pending ? { note: 'Questions that are not pending were answered, dismissed or withdrawn within the last day.' } : {}),
|
|
3345
|
+
};
|
|
3346
|
+
}
|
|
3347
|
+
|
|
3348
|
+
async function withdrawAction(ctx, args) {
|
|
3349
|
+
const service = askService();
|
|
3350
|
+
if (!ctx.relay.local) {
|
|
3351
|
+
const mirror = service.getMirror(ctx.relay.id, args.question_id);
|
|
3352
|
+
if (!mirror || mirror.askedBy?.conversationId !== ctx.conversationId) {
|
|
3353
|
+
throw new DispatchFailure(404, REMOTE_RELAY_ERROR_CODES.notFound,
|
|
3354
|
+
`This conversation asked no question ${args.question_id} on relay "${ctx.relay.name}"; list_questions there shows the ones it did.`);
|
|
3355
|
+
}
|
|
3356
|
+
await relayAskRemoteCall(ctx, RELAY_ASK_REMOTE_PATHS.withdraw, { id: args.question_id });
|
|
3357
|
+
const ask = service.closeMirror(ctx.relay.id, args.question_id, 'withdrawn');
|
|
3358
|
+
return { summary: `withdraw → ${ctx.relay.name}: question ${args.question_id} withdrawn`, question: ask };
|
|
3359
|
+
}
|
|
3360
|
+
const ask = relayAskCall(() => service.withdraw(args.question_id, { byConversationId: ctx.conversationId }));
|
|
3361
|
+
return { summary: `withdraw → ${ctx.relay.name}: question ${ask.id} withdrawn`, question: ask };
|
|
3362
|
+
}
|
|
3363
|
+
|
|
2553
3364
|
const ACTIONS = Object.freeze({
|
|
3365
|
+
ask: askAction,
|
|
3366
|
+
list_questions: listQuestionsAction,
|
|
3367
|
+
withdraw: withdrawAction,
|
|
3368
|
+
schedule: scheduleAction,
|
|
3369
|
+
list_schedules: listSchedulesAction,
|
|
3370
|
+
unschedule: unscheduleAction,
|
|
2554
3371
|
subscribe: subscribeAction,
|
|
2555
3372
|
unsubscribe: unsubscribeAction,
|
|
2556
3373
|
list_subscriptions: listSubscriptionsAction,
|
|
@@ -2563,6 +3380,7 @@ export function createRemoteRelayDispatcher({
|
|
|
2563
3380
|
wait: waitAction,
|
|
2564
3381
|
send,
|
|
2565
3382
|
create_session: createSession,
|
|
3383
|
+
update_session: updateSession,
|
|
2566
3384
|
answer_question: answerQuestion,
|
|
2567
3385
|
stop: stopTurn,
|
|
2568
3386
|
archive: archiveSession,
|
|
@@ -2684,7 +3502,7 @@ export function createRemoteRelayDispatcher({
|
|
|
2684
3502
|
* arrive while the card is open share it and its answer. Resolves
|
|
2685
3503
|
* `{ approved:true, asked }` or `{ approved:false, code?, error? }`.
|
|
2686
3504
|
*/
|
|
2687
|
-
async function ensureLocalSessionApproval({ conversationId, caller, relay, action, args, signal }) {
|
|
3505
|
+
async function ensureLocalSessionApproval({ conversationId, caller, relay, action, args, signal, group = null }) {
|
|
2688
3506
|
const remembered = () => {
|
|
2689
3507
|
try {
|
|
2690
3508
|
return repository.hasLocalSessionApproval(conversationId) === true;
|
|
@@ -2708,6 +3526,7 @@ export function createRemoteRelayDispatcher({
|
|
|
2708
3526
|
summary: summarizeRemoteRelayCall({ action, ...args, relay: relay.name }),
|
|
2709
3527
|
signal,
|
|
2710
3528
|
localSessions: true,
|
|
3529
|
+
...(group ? { group } : {}),
|
|
2711
3530
|
});
|
|
2712
3531
|
if (decision !== true && decision?.approved !== true) {
|
|
2713
3532
|
return {
|
|
@@ -2754,9 +3573,15 @@ export function createRemoteRelayDispatcher({
|
|
|
2754
3573
|
'Sessions on this relay are not available to agents right now: its storage for them could not be read (see the server log).',
|
|
2755
3574
|
{ relay: relay.name });
|
|
2756
3575
|
}
|
|
2757
|
-
|
|
3576
|
+
// One level, unless the owner allowed this session's agents to start
|
|
3577
|
+
// sessions themselves; the cap and the Allow below still apply.
|
|
3578
|
+
let ownerAllowed = false;
|
|
3579
|
+
try {
|
|
3580
|
+
ownerAllowed = repository.mayCreateSessions?.(conversationId) === true;
|
|
3581
|
+
} catch {}
|
|
3582
|
+
if (origin && !ownerAllowed) {
|
|
2758
3583
|
return failure(403, REMOTE_RELAY_ERROR_CODES.nestedSession,
|
|
2759
|
-
'A session that an agent created cannot create sessions. Do the work in this session, or say in your reply what the agent that prompted you should start.',
|
|
3584
|
+
'A session that an agent created cannot create sessions (unless the relay\'s owner allows it for this session). Do the work in this session, or say in your reply what the agent that prompted you should start.',
|
|
2760
3585
|
{ relay: relay.name });
|
|
2761
3586
|
}
|
|
2762
3587
|
const limit = REMOTE_RELAY_LIMITS.localActiveSessions;
|
|
@@ -2779,7 +3604,17 @@ export function createRemoteRelayDispatcher({
|
|
|
2779
3604
|
if (!target.relay) return target.failure;
|
|
2780
3605
|
const relay = target.relay;
|
|
2781
3606
|
const local = relay.local === true;
|
|
2782
|
-
|
|
3607
|
+
// A schedule for another session prompts it later: it is gated like a
|
|
3608
|
+
// send (approval in ask and plan mode, the write limit, the audit line).
|
|
3609
|
+
// One for the caller's own conversation prompts nobody else.
|
|
3610
|
+
const ownSchedule = action === 'schedule' && (!toText(args.session) || isOwnConversation({ conversationId }, args.session));
|
|
3611
|
+
// An ask on behalf of another session puts a card there and wakes it with
|
|
3612
|
+
// the answer: a write too (hop limit, approval in ask and plan mode, the
|
|
3613
|
+
// write limit, the audit line), but no prompt of an agent's: the loop
|
|
3614
|
+
// guard does not count it, the answer is the user's. An ask about the
|
|
3615
|
+
// caller's own conversation never gets here (isOwnAskCall).
|
|
3616
|
+
const behalfAsk = action === 'ask' && !!toText(args.session) && !isOwnConversation({ conversationId }, args.session);
|
|
3617
|
+
const write = isRemoteRelayWriteAction(action) || (action === 'schedule' && !ownSchedule) || behalfAsk;
|
|
2783
3618
|
const finish = (result, session) => {
|
|
2784
3619
|
if (write) {
|
|
2785
3620
|
const ok = result.body?.ok === true;
|
|
@@ -2803,9 +3638,9 @@ export function createRemoteRelayDispatcher({
|
|
|
2803
3638
|
`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.`,
|
|
2804
3639
|
{ relay: relay.name }));
|
|
2805
3640
|
}
|
|
2806
|
-
if (!local && !remoteRelayPermissionAllows(relay.permission, action)) {
|
|
3641
|
+
if (!local && !remoteRelayPermissionAllows(relay.permission, action, { ownSession: ownSchedule })) {
|
|
2807
3642
|
return finish(failure(403, REMOTE_RELAY_ERROR_CODES.forbidden,
|
|
2808
|
-
`Relay "${relay.name}" lets agents ${PERMISSION_DESCRIPTIONS[relay.permission] || 'do less'} (permission "${relay.permission}"); ${action} needs "${
|
|
3643
|
+
`Relay "${relay.name}" lets agents ${PERMISSION_DESCRIPTIONS[relay.permission] || 'do less'} (permission "${relay.permission}"); ${action} needs "${remoteRelayActionPermission(action, { ownSession: ownSchedule })}". The user can change this in Settings → Relays.`,
|
|
2809
3644
|
{ relay: relay.name, permission: relay.permission }));
|
|
2810
3645
|
}
|
|
2811
3646
|
if (local && OWN_SESSION_ACTIONS.has(action) && isOwnConversation({ conversationId }, args.session)) {
|
|
@@ -2848,6 +3683,20 @@ export function createRemoteRelayDispatcher({
|
|
|
2848
3683
|
const problem = createSessionProblem(caller, args);
|
|
2849
3684
|
if (problem) return finish(failure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, problem, { relay: relay.name }));
|
|
2850
3685
|
}
|
|
3686
|
+
// The group a create_session names, looked up on the target before anyone
|
|
3687
|
+
// is asked, like the model: the card names it, and an unknown group (or a
|
|
3688
|
+
// target whose groups cannot be read) refuses the call, nothing created.
|
|
3689
|
+
let group = null;
|
|
3690
|
+
if (action === 'create_session' && args.group) {
|
|
3691
|
+
try {
|
|
3692
|
+
group = await settleRequestedGroup({ relay, caller, hops, conversationId, signal }, args.group);
|
|
3693
|
+
} catch (error) {
|
|
3694
|
+
if (error instanceof DispatchFailure) {
|
|
3695
|
+
return finish(failure(error.status, error.code, error.message, { relay: relay.name, ...error.extra }));
|
|
3696
|
+
}
|
|
3697
|
+
throw error;
|
|
3698
|
+
}
|
|
3699
|
+
}
|
|
2851
3700
|
if ((action === 'create_session' || action === 'send') && args.model) {
|
|
2852
3701
|
const early = { relay, caller, hops, conversationId, signal };
|
|
2853
3702
|
try {
|
|
@@ -2872,6 +3721,21 @@ export function createRemoteRelayDispatcher({
|
|
|
2872
3721
|
settledModel = null;
|
|
2873
3722
|
}
|
|
2874
3723
|
}
|
|
3724
|
+
// What an update_session would change, settled on the target before
|
|
3725
|
+
// anyone is asked: an unknown model or group, a busy session or a change
|
|
3726
|
+
// to nothing never raises a card.
|
|
3727
|
+
let updatePlan = null;
|
|
3728
|
+
if (action === 'update_session') {
|
|
3729
|
+
try {
|
|
3730
|
+
updatePlan = await planSessionUpdate({ relay, caller, hops, conversationId, signal }, args);
|
|
3731
|
+
} catch (error) {
|
|
3732
|
+
if (error instanceof DispatchFailure) {
|
|
3733
|
+
return finish(failure(error.status, error.code, error.message, { relay: relay.name, ...error.extra }));
|
|
3734
|
+
}
|
|
3735
|
+
if (isRemoteRelayError(error)) return finish(mapRemoteError(error, relay, { action, args }));
|
|
3736
|
+
throw error;
|
|
3737
|
+
}
|
|
3738
|
+
}
|
|
2875
3739
|
|
|
2876
3740
|
const localCreate = local && action === 'create_session';
|
|
2877
3741
|
let holdsLocalCreate = false;
|
|
@@ -2889,10 +3753,12 @@ export function createRemoteRelayDispatcher({
|
|
|
2889
3753
|
|
|
2890
3754
|
adjustInflight(conversationId, 1);
|
|
2891
3755
|
try {
|
|
2892
|
-
|
|
3756
|
+
// A schedule for another session prompts it later: the loop guard counts
|
|
3757
|
+
// it now, like a send (and each of its firings once more, admitScheduledFiring).
|
|
3758
|
+
const prompting = isRemoteRelayPromptAction(action) || (action === 'schedule' && !ownSchedule);
|
|
2893
3759
|
let asked = false;
|
|
2894
3760
|
if (localCreate) {
|
|
2895
|
-
const decision = await ensureLocalSessionApproval({ conversationId, caller, relay, action, args, signal });
|
|
3761
|
+
const decision = await ensureLocalSessionApproval({ conversationId, caller, relay, action, args, signal, group });
|
|
2896
3762
|
if (!decision.approved) {
|
|
2897
3763
|
return finish(failure(403, decision.code || REMOTE_RELAY_ERROR_CODES.approvalDenied, decision.error, { relay: relay.name }));
|
|
2898
3764
|
}
|
|
@@ -2903,6 +3769,16 @@ export function createRemoteRelayDispatcher({
|
|
|
2903
3769
|
agentPrompts(conversationId).count = 0;
|
|
2904
3770
|
}
|
|
2905
3771
|
}
|
|
3772
|
+
// A session this conversation did not start: the owner's Allow, once per conversation
|
|
3773
|
+
// and target. The card this call raised is this call's approval too.
|
|
3774
|
+
const updatesNothing = !!updatePlan && !updatePlan.changed.length;
|
|
3775
|
+
if (updatePlan && !updatesNothing && updatePlan.approvalNeeded) {
|
|
3776
|
+
const decision = await ensureSessionSettingsApproval({ relay, caller, conversationId, signal }, updatePlan, args);
|
|
3777
|
+
if (!decision.approved) {
|
|
3778
|
+
return finish(failure(403, decision.code, decision.error, { relay: relay.name, session: updatePlan.session }));
|
|
3779
|
+
}
|
|
3780
|
+
if (decision.asked) asked = true;
|
|
3781
|
+
}
|
|
2906
3782
|
if (!asked && prompting && agentPrompts(conversationId).count >= REMOTE_RELAY_LIMITS.agentPromptsBeforeAsking) {
|
|
2907
3783
|
// Agents that prompt each other can go on without the user for good:
|
|
2908
3784
|
// from time to time the user is asked, in any relay mode.
|
|
@@ -2915,6 +3791,7 @@ export function createRemoteRelayDispatcher({
|
|
|
2915
3791
|
summary: summarizeRemoteRelayCall({ action, ...args, relay: relay.name }),
|
|
2916
3792
|
signal,
|
|
2917
3793
|
agentPrompts: agentPrompts(conversationId).count,
|
|
3794
|
+
...(group ? { group } : {}),
|
|
2918
3795
|
});
|
|
2919
3796
|
const approved = decision === true || decision?.approved === true;
|
|
2920
3797
|
if (!approved) {
|
|
@@ -2926,7 +3803,7 @@ export function createRemoteRelayDispatcher({
|
|
|
2926
3803
|
agentPrompts(conversationId).count = 0;
|
|
2927
3804
|
asked = true;
|
|
2928
3805
|
}
|
|
2929
|
-
if (!asked && write && REMOTE_RELAY_APPROVAL_MODES.includes(caller.mode)) {
|
|
3806
|
+
if (!asked && write && !updatesNothing && REMOTE_RELAY_APPROVAL_MODES.includes(caller.mode)) {
|
|
2930
3807
|
const decision = await requestApproval({
|
|
2931
3808
|
conversationId,
|
|
2932
3809
|
callerContext: caller,
|
|
@@ -2935,6 +3812,7 @@ export function createRemoteRelayDispatcher({
|
|
|
2935
3812
|
args,
|
|
2936
3813
|
summary: summarizeRemoteRelayCall({ action, ...args, relay: relay.name }),
|
|
2937
3814
|
signal,
|
|
3815
|
+
...(group ? { group } : {}),
|
|
2938
3816
|
});
|
|
2939
3817
|
const approved = decision === true || decision?.approved === true;
|
|
2940
3818
|
if (!approved) {
|
|
@@ -2945,7 +3823,7 @@ export function createRemoteRelayDispatcher({
|
|
|
2945
3823
|
}
|
|
2946
3824
|
}
|
|
2947
3825
|
|
|
2948
|
-
const ctx = { relay, caller, hops, conversationId, signal, onPromptQueued: releaseLocalCreate, ...(catalog ? { catalog } : {}), ...(settledModel ? { settledModel } : {}) };
|
|
3826
|
+
const ctx = { relay, caller, hops, conversationId, signal, onPromptQueued: releaseLocalCreate, ...(catalog ? { catalog } : {}), ...(settledModel ? { settledModel } : {}), ...(group ? { group } : {}), ...(updatePlan ? { updatePlan } : {}) };
|
|
2949
3827
|
const body = await ACTIONS[action](ctx, args);
|
|
2950
3828
|
if (prompting) agentPrompts(conversationId).count += 1;
|
|
2951
3829
|
const { summary, ...rest } = body;
|
|
@@ -2972,6 +3850,43 @@ export function createRemoteRelayDispatcher({
|
|
|
2972
3850
|
}
|
|
2973
3851
|
}
|
|
2974
3852
|
|
|
3853
|
+
/**
|
|
3854
|
+
* Whether a call is an ask action about the caller's own conversation on
|
|
3855
|
+
* this relay: an ask or list_questions without another session, or a
|
|
3856
|
+
* withdraw here. Such a call touches nothing but the conversation's own
|
|
3857
|
+
* questions, so it needs neither the agent sessions setting nor a mention.
|
|
3858
|
+
*/
|
|
3859
|
+
async function isOwnAskCall(conversationId, action, args) {
|
|
3860
|
+
if (!REMOTE_RELAY_ASK_ACTIONS.includes(action)) return false;
|
|
3861
|
+
const query = toText(args?.relay).replace(/^@/, '').toLowerCase();
|
|
3862
|
+
if (query !== REMOTE_RELAY_LOCAL_ALIAS) {
|
|
3863
|
+
const resolved = await registry.resolve(args.relay);
|
|
3864
|
+
if (resolved?.relay || resolved?.error === 'ambiguous') return false;
|
|
3865
|
+
const selfName = toText(await registry.selfName?.()).toLowerCase();
|
|
3866
|
+
if (!selfName || selfName !== query) return false;
|
|
3867
|
+
}
|
|
3868
|
+
return action === 'withdraw' || !toText(args.session) || isOwnConversation({ conversationId }, args.session);
|
|
3869
|
+
}
|
|
3870
|
+
|
|
3871
|
+
/** An ask action on the caller's own conversation (isOwnAskCall): the rate limit, then the action. */
|
|
3872
|
+
async function runOwnAskAction({ conversationId, action, args, req, signal }) {
|
|
3873
|
+
const relay = await localTarget();
|
|
3874
|
+
const retryAfterSeconds = consumeRateLimit(conversationId, false);
|
|
3875
|
+
if (retryAfterSeconds !== null) {
|
|
3876
|
+
return failure(429, REMOTE_RELAY_ERROR_CODES.rateLimited,
|
|
3877
|
+
`Too many remote relay calls from this conversation (at most ${REMOTE_RELAY_LIMITS.callsPerMinute} a minute). Try again in ${retryAfterSeconds} s.`,
|
|
3878
|
+
{ relay: relay.name, retryAfterSeconds });
|
|
3879
|
+
}
|
|
3880
|
+
const caller = { conversationId, ...((await getCallerContext(conversationId, req)) || {}) };
|
|
3881
|
+
try {
|
|
3882
|
+
const { summary, ...rest } = await ACTIONS[action]({ relay, caller, hops: nonNegativeInt(caller.hops), conversationId, signal }, args);
|
|
3883
|
+
return { status: 200, body: { ok: true, relay: relay.name, summary, ...rest } };
|
|
3884
|
+
} catch (error) {
|
|
3885
|
+
if (error instanceof DispatchFailure) return failure(error.status, error.code, error.message, { relay: relay.name, ...error.extra });
|
|
3886
|
+
throw error;
|
|
3887
|
+
}
|
|
3888
|
+
}
|
|
3889
|
+
|
|
2975
3890
|
/**
|
|
2976
3891
|
* One tool call. Never throws: resolves to `{ status, body }` for the route
|
|
2977
3892
|
* to send as is. `signal` (optional) aborts approval and reply waits when
|
|
@@ -2982,6 +3897,14 @@ export function createRemoteRelayDispatcher({
|
|
|
2982
3897
|
// target: the waiting is done here, by polling the target.
|
|
2983
3898
|
const { maxWaitSeconds } = agentSessions();
|
|
2984
3899
|
const validation = validateRemoteRelayToolInput({ ...(isPlainObject(args) ? args : {}), action }, { maxWaitSeconds });
|
|
3900
|
+
const result = await runValidated({ conversationId, req, signal }, validation);
|
|
3901
|
+
// The arguments the action does not take, named on the answer (a call
|
|
3902
|
+
// that came through a worker's tool had them named there already).
|
|
3903
|
+
if (!validation.ignored?.length || !isPlainObject(result?.body)) return result;
|
|
3904
|
+
return { ...result, body: { ...result.body, ignored: validation.ignored } };
|
|
3905
|
+
}
|
|
3906
|
+
|
|
3907
|
+
async function runValidated({ conversationId, req, signal }, validation) {
|
|
2985
3908
|
if (!validation.ok) return failure(400, validation.code, validation.error);
|
|
2986
3909
|
const requestedConversationId = toText(conversationId);
|
|
2987
3910
|
let callerConversationId = requestedConversationId;
|
|
@@ -2996,6 +3919,9 @@ export function createRemoteRelayDispatcher({
|
|
|
2996
3919
|
: '';
|
|
2997
3920
|
try {
|
|
2998
3921
|
if (validation.action === 'list_relays') return await listRelays(callerConversationId);
|
|
3922
|
+
if (await isOwnAskCall(callerConversationId, validation.action, validation.args)) {
|
|
3923
|
+
return await runOwnAskAction({ conversationId: callerConversationId, action: validation.action, args: validation.args, req, signal });
|
|
3924
|
+
}
|
|
2999
3925
|
return await runRelayAction({
|
|
3000
3926
|
conversationId: callerConversationId,
|
|
3001
3927
|
action: validation.action,
|
|
@@ -3012,5 +3938,5 @@ export function createRemoteRelayDispatcher({
|
|
|
3012
3938
|
}
|
|
3013
3939
|
}
|
|
3014
3940
|
|
|
3015
|
-
return { dispatch, inflight };
|
|
3941
|
+
return { dispatch, inflight, admitScheduledFiring };
|
|
3016
3942
|
}
|