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