@oar-sh/oar 0.9.11 → 0.9.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +332 -0
- package/README.md +10 -10
- package/package.json +1 -1
- package/server/README.md +201 -12
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
- package/server/claude-worker/claude-session-process.mjs +179 -0
- package/server/claude-worker/claude-session-worker.mjs +17 -0
- package/server/claude-worker/task-output-discovery.mjs +152 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
- package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
- package/server/copilot-worker/copilot-task-settle.mjs +96 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/acp-host-services.mjs +48 -6
- package/server/grok-worker/grok-background-tasks.mjs +248 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
- package/server/grok-worker/grok-session-worker.mjs +33 -1
- package/server/grok-worker/grok-turn-runner.mjs +11 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/migrations/0018-background-task-rows.mjs +101 -0
- package/server/public/app/action-confirmations.js +12 -15
- package/server/public/app/api-client.js +70 -3
- package/server/public/app/background-tasks-view.mjs +334 -16
- package/server/public/app/bootstrap.js +102 -5
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-turn-state.mjs +1 -1
- package/server/public/app/conversation-view.js +462 -8
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +259 -67
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
- package/server/public/app/relay-restart-note-view.mjs +36 -0
- package/server/public/app/relay-restart-ui.js +568 -0
- package/server/public/app/settings-modal.js +162 -2
- package/server/public/app/socket-handlers.js +59 -7
- package/server/public/app/store.js +25 -0
- package/server/public/app/subagent-viewer.mjs +7 -1
- package/server/public/app/task-console-render.mjs +401 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +730 -0
- package/server/public/app/task-output-text.mjs +243 -0
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
- package/server/public/app/transcript-jump-bottom.mjs +68 -11
- package/server/public/app/transcript-reading-anchor.mjs +256 -0
- package/server/public/app/transcript-reload-freshness.mjs +17 -1
- package/server/public/app/transcript-viewport-anchor.mjs +20 -1
- package/server/public/index.html +306 -6
- package/server/relay-tools.md +6 -0
- package/server/repositories/message-repository.mjs +11 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/routes/messages-routes.mjs +397 -23
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +208 -66
- package/server/server-runtime.mjs +269 -137
- package/server/services/about-service.mjs +13 -12
- package/server/services/background-task-persistence.mjs +133 -0
- package/server/services/background-task-store.mjs +470 -6
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/conversation-model-fallback.mjs +57 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/preview-proxy-server.mjs +14 -1
- package/server/services/process-list-service.mjs +2 -1
- package/server/services/provider-model-selection.mjs +195 -0
- package/server/services/push-dispatch-service.mjs +9 -2
- package/server/services/relay-event-service.mjs +27 -9
- package/server/services/relay-prompt-sanitizer.mjs +18 -4
- package/server/services/relay-restart-picture.mjs +209 -0
- package/server/services/relay-restart-queue.mjs +380 -0
- package/server/services/relay-shutdown-gate.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +316 -20
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-activity-tracker.mjs +6 -2
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +761 -0
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/relay-events.mjs +55 -2
- package/shared/remote-relay-contract.mjs +47 -10
- package/shared/task-output-text.mjs +850 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
|
@@ -27,6 +27,9 @@ import {
|
|
|
27
27
|
import { schemaFields } from '../../shared/question-schema.mjs';
|
|
28
28
|
import { ANSWERED_ELSEWHERE_KIND } from '../../shared/steer-settle-markers.mjs';
|
|
29
29
|
import { RELAY_EVENT_DEFAULT_TYPES } from '../../shared/relay-events.mjs';
|
|
30
|
+
import { checkRequestedProviderModel, isAutoModelId, providerModelLabel, unknownProviderModelText } from './provider-model-selection.mjs';
|
|
31
|
+
import { cleanTaskOutput, parseRunnerSummary, stallMinutes, stripSgr } from '../../shared/task-output-text.mjs';
|
|
32
|
+
import { TASK_OUTPUT_NONE_MESSAGES } from '../public/app/task-console-render.mjs';
|
|
30
33
|
import {
|
|
31
34
|
RELAY_EVENT_REMOTE_PULL_PATH,
|
|
32
35
|
RELAY_EVENT_REMOTE_SUBSCRIBE_PATH,
|
|
@@ -129,6 +132,8 @@ const FAILURE_TEXT_PATTERNS = Object.freeze([
|
|
|
129
132
|
/^relay timeout after \d+ attempts/i,
|
|
130
133
|
]);
|
|
131
134
|
const CLAUDE_LONG_CONTEXT_SUFFIX = /\[1m\]$/i;
|
|
135
|
+
// How many of a provider's model ids an unknown-model refusal carries.
|
|
136
|
+
const UNKNOWN_MODEL_LIST_MAX = 50;
|
|
132
137
|
// POST /api/conversation/bootstrap refuses an effort its model does not take
|
|
133
138
|
// with this code (400); POST /api/message sends the same 400 without a code,
|
|
134
139
|
// but with the list below.
|
|
@@ -268,6 +273,45 @@ function matchOfferedModel(candidate, offered = []) {
|
|
|
268
273
|
return offered.find((model) => model.toLowerCase().replace(CLAUDE_LONG_CONTEXT_SUFFIX, '') === wantedBase) || '';
|
|
269
274
|
}
|
|
270
275
|
|
|
276
|
+
/** The models /api/models lists for a provider label ("openai-byok"). */
|
|
277
|
+
function catalogModelsLabelled(catalog, label) {
|
|
278
|
+
const providersByModel = isPlainObject(catalog?.models?.providersByModel) ? catalog.models.providersByModel : {};
|
|
279
|
+
return Object.entries(providersByModel)
|
|
280
|
+
.filter(([, providers]) => Array.isArray(providers) && providers.some((entry) => toText(entry).toLowerCase() === label))
|
|
281
|
+
.map(([model]) => model);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Copilot's catalogue: every model of /api/models no other provider claims alone. */
|
|
285
|
+
function catalogGithubModels(catalog) {
|
|
286
|
+
const models = catalog?.models || {};
|
|
287
|
+
const providersByModel = isPlainObject(models.providersByModel) ? models.providersByModel : {};
|
|
288
|
+
const catalogModels = Array.isArray(models.models) ? models.models.map(toText).filter(Boolean) : [];
|
|
289
|
+
return catalogModels.filter((model) => {
|
|
290
|
+
const providers = catalogProvidersFor(providersByModel, model);
|
|
291
|
+
return providers.length === 0 || providers.includes(CATALOG_PROVIDER_LABELS.github);
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The models a provider's New Chat list offers on the target, which its
|
|
297
|
+
* bootstrap and message routes accept, or null when the target has no list
|
|
298
|
+
* for it yet (discovery has not run or failed, the route did not answer):
|
|
299
|
+
* then a requested model cannot be checked here.
|
|
300
|
+
*/
|
|
301
|
+
function offeredModelList(catalog, provider) {
|
|
302
|
+
if (provider === 'github') {
|
|
303
|
+
const listed = catalogGithubModels(catalog);
|
|
304
|
+
return listed.length ? listed : null;
|
|
305
|
+
}
|
|
306
|
+
if (provider === 'openai') {
|
|
307
|
+
const listed = catalogModelsLabelled(catalog, CATALOG_PROVIDER_LABELS.openai);
|
|
308
|
+
return listed.length ? listed : null;
|
|
309
|
+
}
|
|
310
|
+
const settings = catalog?.settings?.[provider];
|
|
311
|
+
const listed = Array.isArray(settings?.models) ? settings.models.map(toText).filter(Boolean) : [];
|
|
312
|
+
return listed.length ? listed : null;
|
|
313
|
+
}
|
|
314
|
+
|
|
271
315
|
/** A reasoning effort as the relays write it, or '' when the value is not one. */
|
|
272
316
|
function normalizeEffort(value) {
|
|
273
317
|
const effort = toText(value).toLowerCase();
|
|
@@ -1015,19 +1059,9 @@ export function createRemoteRelayDispatcher({
|
|
|
1015
1059
|
|
|
1016
1060
|
function listProviders(catalog) {
|
|
1017
1061
|
const models = catalog.models || {};
|
|
1018
|
-
const providersByModel = isPlainObject(models.providersByModel) ? models.providersByModel : {};
|
|
1019
|
-
const catalogModels = Array.isArray(models.models) ? models.models.map(toText).filter(Boolean) : [];
|
|
1020
|
-
const modelsLabelled = (label) => Object.entries(providersByModel)
|
|
1021
|
-
.filter(([, providers]) => Array.isArray(providers) && providers.some((entry) => toText(entry).toLowerCase() === label))
|
|
1022
|
-
.map(([model]) => model);
|
|
1023
|
-
|
|
1024
1062
|
return REMOTE_RELAY_PROVIDERS.map((provider) => {
|
|
1025
1063
|
if (provider === 'github') {
|
|
1026
|
-
|
|
1027
|
-
const githubModels = catalogModels.filter((model) => {
|
|
1028
|
-
const providers = catalogProvidersFor(providersByModel, model);
|
|
1029
|
-
return providers.length === 0 || providers.includes(CATALOG_PROVIDER_LABELS.github);
|
|
1030
|
-
});
|
|
1064
|
+
const githubModels = catalogGithubModels(catalog);
|
|
1031
1065
|
const defaultModel = toText(catalog.status.defaultModel) || toText(models.defaultModel) || toText(models.currentModel) || null;
|
|
1032
1066
|
return {
|
|
1033
1067
|
provider,
|
|
@@ -1059,7 +1093,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1059
1093
|
&& (provider === 'claude' || provider === 'grok' || settings.configured === true);
|
|
1060
1094
|
const defaultModel = toText(settings.model) || null;
|
|
1061
1095
|
const listed = provider === 'openai'
|
|
1062
|
-
?
|
|
1096
|
+
? catalogModelsLabelled(catalog, CATALOG_PROVIDER_LABELS.openai)
|
|
1063
1097
|
: (Array.isArray(settings.models) ? settings.models : []);
|
|
1064
1098
|
return {
|
|
1065
1099
|
provider,
|
|
@@ -1281,7 +1315,40 @@ export function createRemoteRelayDispatcher({
|
|
|
1281
1315
|
* `lastActivityAt` of a session, from the relay's `sessionState` /
|
|
1282
1316
|
* `sessionTasks`. A relay that predates them gives none.
|
|
1283
1317
|
*/
|
|
1284
|
-
|
|
1318
|
+
/**
|
|
1319
|
+
* The console facts read_session adds to a task (`row` is the conversation
|
|
1320
|
+
* payload): `taskId` for read_task_output, `output` whether there is any to
|
|
1321
|
+
* read, and `health` (minutes since the last output, the quiet threshold,
|
|
1322
|
+
* test-run counts) for a task with an output file.
|
|
1323
|
+
*/
|
|
1324
|
+
function taskConsoleFields(row, id) {
|
|
1325
|
+
const taskId = toText(id);
|
|
1326
|
+
if (!taskId || !isPlainObject(row)) return {};
|
|
1327
|
+
const live = (Array.isArray(row.backgroundTasks) ? row.backgroundTasks : []).find((task) => toText(task?.taskId) === taskId);
|
|
1328
|
+
const ended = live ? null : (Array.isArray(row.endedBackgroundTasks) ? row.endedBackgroundTasks : []).find((task) => toText(task?.taskId) === taskId);
|
|
1329
|
+
const task = live || ended;
|
|
1330
|
+
if (!task) return {};
|
|
1331
|
+
const kind = toText(task.outputKind) || (toText(task.outputPath) ? 'file' : 'none');
|
|
1332
|
+
const health = isPlainObject(task.health) ? task.health : null;
|
|
1333
|
+
const quietAfter = Number(row.taskStallMinutes) || null;
|
|
1334
|
+
const minutes = live && health ? stallMinutes(health.lastOutputAt, now()) : null;
|
|
1335
|
+
// The command line comes scrubbed: the relay cleans it when the worker posts it.
|
|
1336
|
+
const command = toText(task.command);
|
|
1337
|
+
return {
|
|
1338
|
+
taskId,
|
|
1339
|
+
output: kind !== 'none',
|
|
1340
|
+
...(command ? { command: clipLine(command, 500) } : {}),
|
|
1341
|
+
...(health ? {
|
|
1342
|
+
health: {
|
|
1343
|
+
...(minutes !== null ? { minutesSinceOutput: minutes } : {}),
|
|
1344
|
+
...(quietAfter && minutes !== null ? { quiet: minutes >= quietAfter, quietAfterMinutes: quietAfter } : {}),
|
|
1345
|
+
...(isPlainObject(health.counts) ? { counts: { runner: toText(health.runner) || null, ...health.counts } } : {}),
|
|
1346
|
+
},
|
|
1347
|
+
} : {}),
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function sessionActivityFields(row, { console: withConsole = false } = {}) {
|
|
1285
1352
|
const state = toText(row?.sessionState);
|
|
1286
1353
|
if (!SESSION_STATES.has(state)) return {};
|
|
1287
1354
|
const tasks = (Array.isArray(row?.sessionTasks) ? row.sessionTasks : [])
|
|
@@ -1298,6 +1365,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1298
1365
|
...(toText(task.summary) && toText(task.status) !== 'running' ? { summary: clipLine(task.summary, 200) } : {}),
|
|
1299
1366
|
...(task.endedAt ? { endedAt: task.endedAt } : {}),
|
|
1300
1367
|
...(toText(task.id) ? { id: toText(task.id) } : {}),
|
|
1368
|
+
...(withConsole ? taskConsoleFields(row, task.id) : {}),
|
|
1301
1369
|
}));
|
|
1302
1370
|
return {
|
|
1303
1371
|
state,
|
|
@@ -1487,7 +1555,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1487
1555
|
cwd: toText(conversation?.currentWorkspaceRootPath) || null,
|
|
1488
1556
|
archived: conversation?.archived === true,
|
|
1489
1557
|
active: conversation?.activeTurn === true || isBusyState(conversation?.sessionState),
|
|
1490
|
-
...sessionActivityFields(conversation),
|
|
1558
|
+
...sessionActivityFields(conversation, { console: true }),
|
|
1491
1559
|
...(via ? { via } : {}),
|
|
1492
1560
|
...(group ? { group } : {}),
|
|
1493
1561
|
openUrl: remoteConversationUrl(ctx.relay.url, conversationId) || null,
|
|
@@ -1500,6 +1568,101 @@ export function createRemoteRelayDispatcher({
|
|
|
1500
1568
|
};
|
|
1501
1569
|
}
|
|
1502
1570
|
|
|
1571
|
+
/**
|
|
1572
|
+
* read_task_output: the end of a background shell or Monitor task's
|
|
1573
|
+
* output (GET …/background-task/:taskId/output on the target relay, which
|
|
1574
|
+
* scrubs it), as plain lines without colours, at most `lines` lines and
|
|
1575
|
+
* 32 KB; minutes since its last output; test-run counts when it is one.
|
|
1576
|
+
*/
|
|
1577
|
+
async function readTaskOutput(ctx, args) {
|
|
1578
|
+
let answer;
|
|
1579
|
+
try {
|
|
1580
|
+
answer = await call(ctx, 'GET', sessionPath(args.session, `/background-task/${encodeURIComponent(args.task_id)}/output`), {
|
|
1581
|
+
query: { lines: args.lines },
|
|
1582
|
+
});
|
|
1583
|
+
} catch (error) {
|
|
1584
|
+
const status = Number(error?.status) || null;
|
|
1585
|
+
if (isRemoteRelayError(error) && (status === 404 || toText(error.code) === REMOTE_RELAY_ERROR_CODES.notFound)) {
|
|
1586
|
+
const body = isPlainObject(error.remoteBody) ? error.remoteBody : {};
|
|
1587
|
+
if (toText(body.code) === 'task-not-found') {
|
|
1588
|
+
throw new DispatchFailure(404, REMOTE_RELAY_ERROR_CODES.notFound,
|
|
1589
|
+
`Task ${args.task_id} is not running in session ${args.session} on relay "${ctx.relay.name}" and did not end there within the last hour; read_session lists the session's tasks.`);
|
|
1590
|
+
}
|
|
1591
|
+
if (!/conversation not found/i.test(toText(body.error))) {
|
|
1592
|
+
throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unsupported,
|
|
1593
|
+
`Relay "${ctx.relay.name}" cannot show background task output (it runs an older OAR version).`);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
throw error;
|
|
1597
|
+
}
|
|
1598
|
+
const model = Array.isArray(answer?.lines) ? answer.lines : [];
|
|
1599
|
+
const task = isPlainObject(answer?.task) ? answer.task : {};
|
|
1600
|
+
const ended = answer?.ended === true;
|
|
1601
|
+
// Plain text, a repeat folded as ×N; cleaned again, whatever the other
|
|
1602
|
+
// side did.
|
|
1603
|
+
const rendered = model.map((line) => {
|
|
1604
|
+
const text = stripSgr(cleanTaskOutput(String(line?.text ?? '')));
|
|
1605
|
+
const count = Number(line?.count) > 1 ? Number(line.count) : 1;
|
|
1606
|
+
return count > 1 ? `${text} [×${count}]` : text;
|
|
1607
|
+
});
|
|
1608
|
+
let tail = rendered.slice(-args.lines);
|
|
1609
|
+
let cut = tail.length < rendered.length;
|
|
1610
|
+
let bytes = 0;
|
|
1611
|
+
let keepFrom = tail.length;
|
|
1612
|
+
for (let index = tail.length - 1; index >= 0; index -= 1) {
|
|
1613
|
+
bytes += Buffer.byteLength(tail[index], 'utf8') + 1;
|
|
1614
|
+
if (bytes > REMOTE_RELAY_LIMITS.taskOutputBytesMax) break;
|
|
1615
|
+
keepFrom = index;
|
|
1616
|
+
}
|
|
1617
|
+
if (keepFrom > 0) {
|
|
1618
|
+
tail = tail.slice(keepFrom);
|
|
1619
|
+
cut = true;
|
|
1620
|
+
}
|
|
1621
|
+
const health = isPlainObject(answer?.health) ? answer.health : null;
|
|
1622
|
+
const lastOutputAt = Number(health?.lastOutputAt) || Number(answer?.mtime) || null;
|
|
1623
|
+
const minutes = !ended && lastOutputAt ? stallMinutes(lastOutputAt, now()) : null;
|
|
1624
|
+
const quietAfter = Number(answer?.taskStallMinutes) || null;
|
|
1625
|
+
let counts = null;
|
|
1626
|
+
if (isPlainObject(health?.counts)) {
|
|
1627
|
+
counts = { runner: toText(health.runner) || null, ...health.counts, final: health.final === true };
|
|
1628
|
+
} else {
|
|
1629
|
+
const parsed = parseRunnerSummary(model);
|
|
1630
|
+
if (parsed) counts = { runner: parsed.runner, passed: parsed.passed, failed: parsed.failed, skipped: parsed.skipped, final: parsed.final };
|
|
1631
|
+
}
|
|
1632
|
+
const kind = toText(answer?.kind) || 'file';
|
|
1633
|
+
let note = null;
|
|
1634
|
+
if (kind === 'none') note = toText(answer?.message) || TASK_OUTPUT_NONE_MESSAGES.provider;
|
|
1635
|
+
else if (answer?.pending) note = 'The session\'s worker was asked for the output; call again in a few seconds.';
|
|
1636
|
+
else if (answer?.unavailable) note = 'The output file is not there any more (the provider cleaned it up).';
|
|
1637
|
+
else if (kind === 'snapshot') note = 'Only the end of this task\'s output is available (its provider keeps no file).';
|
|
1638
|
+
const status = ended ? (toText(answer?.status) || 'unknown') : 'running';
|
|
1639
|
+
const exitCode = Number.isInteger(answer?.exitCode) ? answer.exitCode : null;
|
|
1640
|
+
const parts = [`${tail.length} line${tail.length === 1 ? '' : 's'}`, ended ? `ended (${status}${exitCode !== null ? `, exit ${exitCode}` : ''})` : 'running'];
|
|
1641
|
+
if (minutes !== null) parts.push(`last output ${minutes} min ago`);
|
|
1642
|
+
if (counts) parts.push(`${counts.passed} passed, ${counts.failed} failed`);
|
|
1643
|
+
return {
|
|
1644
|
+
summary: `read_task_output → ${ctx.relay.name} session ${shortId(args.session)} task ${shortId(args.task_id)}: ${parts.join(', ')}`,
|
|
1645
|
+
task: {
|
|
1646
|
+
id: args.task_id,
|
|
1647
|
+
description: toText(task.description),
|
|
1648
|
+
type: toText(task.taskType) || null,
|
|
1649
|
+
startedAt: task.startedAt || null,
|
|
1650
|
+
status,
|
|
1651
|
+
exitCode,
|
|
1652
|
+
...(task.endedAt ? { endedAt: task.endedAt } : {}),
|
|
1653
|
+
},
|
|
1654
|
+
stall: minutes === null ? null : {
|
|
1655
|
+
minutes,
|
|
1656
|
+
since: new Date(lastOutputAt).toISOString(),
|
|
1657
|
+
...(quietAfter ? { quiet: minutes >= quietAfter, quietAfterMinutes: quietAfter } : {}),
|
|
1658
|
+
},
|
|
1659
|
+
counts,
|
|
1660
|
+
tail,
|
|
1661
|
+
truncated: cut || answer?.hasMoreBefore === true,
|
|
1662
|
+
...(note ? { note } : {}),
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1503
1666
|
function outcomeSummary(action, ctx, outcome) {
|
|
1504
1667
|
const questions = Array.isArray(outcome.pendingQuestions) ? outcome.pendingQuestions.length : 0;
|
|
1505
1668
|
return `${action} → ${ctx.relay.name} session ${shortId(outcome.session)}: ${outcome.status}`
|
|
@@ -1658,16 +1821,24 @@ export function createRemoteRelayDispatcher({
|
|
|
1658
1821
|
}
|
|
1659
1822
|
const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
|
|
1660
1823
|
const provider = toText(runtime.providerType).toLowerCase() || 'github';
|
|
1824
|
+
// Checked before any approval card (runRelayAction) when the session
|
|
1825
|
+
// could be read there; here otherwise.
|
|
1826
|
+
const settled = args.model
|
|
1827
|
+
? (ctx.settledModel || await settleSendModel(ctx, conversation, args.model))
|
|
1828
|
+
: null;
|
|
1829
|
+
const requestedModel = settled?.model || '';
|
|
1830
|
+
const modelNote = settled?.note || null;
|
|
1661
1831
|
// Without a model the remote falls back to its catalogue's current one and
|
|
1662
1832
|
// silently switches the session; keep the model the session already uses.
|
|
1663
1833
|
// Grok pins its model at bootstrap and refuses any other, so it gets none.
|
|
1664
|
-
const model =
|
|
1834
|
+
const model = requestedModel
|
|
1665
1835
|
|| (provider === 'grok' ? '' : (toText(conversation?.preferredModel) || toText(runtime.providerModel) || toText(runtime.model)));
|
|
1666
1836
|
const mode = args.mode || ctx.caller.mode || toText(conversation?.preferredRelayMode) || '';
|
|
1667
1837
|
// The remote runs a prompt that names no effort with its provider's
|
|
1668
1838
|
// default, not with the one the session is set to: pass that one on, so
|
|
1669
1839
|
// the session keeps its own. This session's effort is never mirrored here.
|
|
1670
|
-
|
|
1840
|
+
// A Copilot model named with its effort ("gpt-5.4-high") brings that effort.
|
|
1841
|
+
const requested = normalizeEffort(args.effort) || normalizeEffort(settled?.effort);
|
|
1671
1842
|
const own = requested ? '' : normalizeEffort(conversation?.preferredReasoningEffort);
|
|
1672
1843
|
const origin = await buildOrigin(ctx);
|
|
1673
1844
|
const { sent, choice } = await postPromptWithEffort(ctx, { session, text: args.text, origin, model, mode }, {
|
|
@@ -1675,11 +1846,91 @@ export function createRemoteRelayDispatcher({
|
|
|
1675
1846
|
source: requested ? EFFORT_SOURCES.requested : (own ? EFFORT_SOURCES.remote : ''),
|
|
1676
1847
|
note: null,
|
|
1677
1848
|
});
|
|
1678
|
-
return deliverAndWait('send', ctx, {
|
|
1849
|
+
return deliverAndWait('send', ctx, {
|
|
1850
|
+
session,
|
|
1851
|
+
sent,
|
|
1852
|
+
waitSeconds: args.wait_seconds,
|
|
1853
|
+
extra: { ...(modelNote ? { modelNote } : {}), ...effortResult(choice) },
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
/**
|
|
1858
|
+
* A model the agent named, checked against the list New Chat offers for
|
|
1859
|
+
* `provider` on the target (offeredModelList). Throws a DispatchFailure
|
|
1860
|
+
* (unknownModel) for one it does not offer, before anything is created or
|
|
1861
|
+
* sent. Returns `{ model, note }`: the id to use (the provider's casing, a
|
|
1862
|
+
* Claude alias resolved) and what the agent should know about it, the
|
|
1863
|
+
* model left unchecked for want of a list included.
|
|
1864
|
+
*/
|
|
1865
|
+
function settleRequestedModel(ctx, catalog, provider, requested, { creating, extraModels = [] }) {
|
|
1866
|
+
const entry = catalog ? listProviders(catalog).find((candidate) => candidate.provider === provider) : null;
|
|
1867
|
+
const offered = catalog ? offeredModelList(catalog, provider) : null;
|
|
1868
|
+
const check = checkRequestedProviderModel({
|
|
1869
|
+
requestedModel: requested,
|
|
1870
|
+
provider,
|
|
1871
|
+
// What the message route accepts besides (send): the provider's whole
|
|
1872
|
+
// discovered list and the session's own model. Never a list by itself.
|
|
1873
|
+
models: offered ? [...offered, ...extraModels.map(toText).filter(Boolean)] : null,
|
|
1874
|
+
defaultModel: entry?.defaultModel || '',
|
|
1875
|
+
effortsFor: (base) => catalogEffortsFor(catalog?.models, provider, base),
|
|
1876
|
+
});
|
|
1877
|
+
const where = `on relay "${ctx.relay.name}"`;
|
|
1878
|
+
const label = providerModelLabel(provider);
|
|
1879
|
+
if (check.status === 'unknown') {
|
|
1880
|
+
throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unknownModel,
|
|
1881
|
+
`${unknownProviderModelText({ model: requested, provider, suggestions: check.suggestions, models: check.models, where })} ${creating ? 'No session was created.' : 'Nothing was sent.'}`,
|
|
1882
|
+
{
|
|
1883
|
+
provider,
|
|
1884
|
+
model: requested,
|
|
1885
|
+
suggestions: check.suggestions,
|
|
1886
|
+
models: check.models.slice(0, UNKNOWN_MODEL_LIST_MAX),
|
|
1887
|
+
note: 'Pass one of these ids (relay_info lists them per provider), or leave model out.',
|
|
1888
|
+
});
|
|
1889
|
+
}
|
|
1890
|
+
if (check.status === 'unchecked') {
|
|
1891
|
+
return {
|
|
1892
|
+
model: requested,
|
|
1893
|
+
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'} with it as asked.`,
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
const tierNote = check.tierDropped ? ` ${label} ${where} lists no "[1m]" id for it, so it runs with the standard context.` : '';
|
|
1897
|
+
if (check.status === 'alias') {
|
|
1898
|
+
return { model: check.model, note: `'${requested}' was taken as ${check.model}, the newest ${label} model of that name ${where}.${tierNote}` };
|
|
1899
|
+
}
|
|
1900
|
+
// A Copilot model with its effort: the bootstrap and the message route
|
|
1901
|
+
// know the listed id, so the effort goes on apart.
|
|
1902
|
+
if (check.base) return { model: check.base, effort: check.effort, note: `'${requested}' was taken as ${check.base} with effort ${check.effort}.` };
|
|
1903
|
+
if (check.tierDropped) return { model: check.model, note: `'${requested}' was taken as ${check.model}.${tierNote}` };
|
|
1904
|
+
// The same id in the provider's casing needs no word; another one does.
|
|
1905
|
+
if (check.model.toLowerCase() !== requested.toLowerCase()) {
|
|
1906
|
+
return { model: check.model, note: `'${requested}' is listed as ${check.model} ${where}; that is the model used.` };
|
|
1907
|
+
}
|
|
1908
|
+
return { model: check.model, note: null };
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
/** settleRequestedModel for a send to `conversation` (its detail payload); an unread catalogue leaves it unchecked. */
|
|
1912
|
+
async function settleSendModel(ctx, conversation, requested) {
|
|
1913
|
+
const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
|
|
1914
|
+
const provider = toText(runtime.providerType).toLowerCase() || 'github';
|
|
1915
|
+
let catalog = null;
|
|
1916
|
+
try {
|
|
1917
|
+
catalog = await loadCatalog(ctx);
|
|
1918
|
+
} catch {
|
|
1919
|
+
// No catalogue: the model goes unchecked, and the note says so.
|
|
1920
|
+
}
|
|
1921
|
+
const settings = catalog?.settings?.[provider];
|
|
1922
|
+
return settleRequestedModel(ctx, catalog, provider, requested, {
|
|
1923
|
+
creating: false,
|
|
1924
|
+
extraModels: [
|
|
1925
|
+
...(Array.isArray(settings?.availableModels) ? settings.availableModels : []),
|
|
1926
|
+
runtime.providerModel,
|
|
1927
|
+
runtime.model,
|
|
1928
|
+
],
|
|
1929
|
+
});
|
|
1679
1930
|
}
|
|
1680
1931
|
|
|
1681
1932
|
async function createSession(ctx, args) {
|
|
1682
|
-
const catalog = await loadCatalog(ctx);
|
|
1933
|
+
const catalog = ctx.catalog || await loadCatalog(ctx);
|
|
1683
1934
|
const providers = buildProviders(catalog);
|
|
1684
1935
|
const configured = providers.filter((entry) => entry.configured).map((entry) => entry.provider);
|
|
1685
1936
|
const provider = createProvider(ctx.caller, args);
|
|
@@ -1694,6 +1945,16 @@ export function createRemoteRelayDispatcher({
|
|
|
1694
1945
|
let model = args.model || '';
|
|
1695
1946
|
let modelSource = model ? 'requested' : '';
|
|
1696
1947
|
let note = null;
|
|
1948
|
+
let variantEffort = '';
|
|
1949
|
+
if (model) {
|
|
1950
|
+
// Refused here when the provider does not offer it: a session created
|
|
1951
|
+
// with an id its provider never runs stays empty, and every send to it
|
|
1952
|
+
// fails.
|
|
1953
|
+
const settled = settleRequestedModel(ctx, catalog, provider, model, { creating: true });
|
|
1954
|
+
model = settled.model;
|
|
1955
|
+
note = settled.note;
|
|
1956
|
+
variantEffort = normalizeEffort(settled.effort);
|
|
1957
|
+
}
|
|
1697
1958
|
// A cloud session without a requested model gets the Claude Cloud tab's
|
|
1698
1959
|
// default, as New Chat does: the bootstrap picks it, nothing is mirrored.
|
|
1699
1960
|
if (!model && cloud) {
|
|
@@ -1715,7 +1976,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1715
1976
|
|
|
1716
1977
|
// The effort mirrors the caller like the model does: this session's when
|
|
1717
1978
|
// the remote lists it for the model (or lists nothing), else the remote's.
|
|
1718
|
-
let choice = { effort: normalizeEffort(args.effort), source: EFFORT_SOURCES.requested, note: null };
|
|
1979
|
+
let choice = { effort: normalizeEffort(args.effort) || variantEffort, source: EFFORT_SOURCES.requested, note: null };
|
|
1719
1980
|
if (!choice.effort && cloud) {
|
|
1720
1981
|
// A cloud session takes no effort: none is mirrored, none is reported.
|
|
1721
1982
|
choice = { effort: '', source: '', note: null };
|
|
@@ -1771,6 +2032,11 @@ export function createRemoteRelayDispatcher({
|
|
|
1771
2032
|
`Relay "${ctx.relay.name}" did not return the new session's id.`);
|
|
1772
2033
|
}
|
|
1773
2034
|
const boundModel = toText(bootstrap?.selectedModel) || model;
|
|
2035
|
+
// A relay that swapped the asked model for another (one that predates
|
|
2036
|
+
// these checks does for Copilot) is named, never passed over in silence.
|
|
2037
|
+
if (modelSource === 'requested' && model && !isAutoModelId(model) && boundModel.toLowerCase() !== model.toLowerCase()) {
|
|
2038
|
+
note = [note, `Relay "${ctx.relay.name}" started the session on ${boundModel}, not on ${model} as asked.`].filter(Boolean).join(' ');
|
|
2039
|
+
}
|
|
1774
2040
|
let sent;
|
|
1775
2041
|
try {
|
|
1776
2042
|
const posted = await postPromptWithEffort(ctx, { session, text: args.text, origin, model: boundModel, mode }, choice);
|
|
@@ -2272,6 +2538,7 @@ export function createRemoteRelayDispatcher({
|
|
|
2272
2538
|
usage: usageAction,
|
|
2273
2539
|
list_sessions: listSessions,
|
|
2274
2540
|
read_session: readSession,
|
|
2541
|
+
read_task_output: readTaskOutput,
|
|
2275
2542
|
wait: waitAction,
|
|
2276
2543
|
send,
|
|
2277
2544
|
create_session: createSession,
|
|
@@ -2551,10 +2818,39 @@ export function createRemoteRelayDispatcher({
|
|
|
2551
2818
|
{ relay: relay.name, retryAfterSeconds }));
|
|
2552
2819
|
}
|
|
2553
2820
|
|
|
2821
|
+
// The target's catalogue, loaded before anyone is asked for approval when
|
|
2822
|
+
// the agent names a model: a card for a model that does not exist there
|
|
2823
|
+
// would be a question about nothing. createSession reuses it.
|
|
2824
|
+
let catalog = null;
|
|
2825
|
+
let settledModel = null;
|
|
2554
2826
|
if (action === 'create_session') {
|
|
2555
2827
|
const problem = createSessionProblem(caller, args);
|
|
2556
2828
|
if (problem) return finish(failure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, problem, { relay: relay.name }));
|
|
2557
2829
|
}
|
|
2830
|
+
if ((action === 'create_session' || action === 'send') && args.model) {
|
|
2831
|
+
const early = { relay, caller, hops, conversationId, signal };
|
|
2832
|
+
try {
|
|
2833
|
+
if (action === 'create_session') {
|
|
2834
|
+
catalog = await loadCatalog(early);
|
|
2835
|
+
const provider = createProvider(caller, args);
|
|
2836
|
+
// A provider not set up there answers as such (createSession), not
|
|
2837
|
+
// with a model it has no list for.
|
|
2838
|
+
if (listProviders(catalog).find((entry) => entry.provider === provider)?.configured) {
|
|
2839
|
+
settleRequestedModel(early, catalog, provider, args.model, { creating: true });
|
|
2840
|
+
}
|
|
2841
|
+
} else {
|
|
2842
|
+
const conversation = await call(early, 'GET', sessionPath(args.session), { query: { limit: 1 } });
|
|
2843
|
+
settledModel = await settleSendModel(early, conversation, args.model);
|
|
2844
|
+
}
|
|
2845
|
+
} catch (error) {
|
|
2846
|
+
if (error instanceof DispatchFailure) {
|
|
2847
|
+
return finish(failure(error.status, error.code, error.message, { relay: relay.name, ...error.extra }));
|
|
2848
|
+
}
|
|
2849
|
+
// The target did not answer: the call itself reports that.
|
|
2850
|
+
catalog = null;
|
|
2851
|
+
settledModel = null;
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2558
2854
|
|
|
2559
2855
|
const localCreate = local && action === 'create_session';
|
|
2560
2856
|
let holdsLocalCreate = false;
|
|
@@ -2628,7 +2924,7 @@ export function createRemoteRelayDispatcher({
|
|
|
2628
2924
|
}
|
|
2629
2925
|
}
|
|
2630
2926
|
|
|
2631
|
-
const ctx = { relay, caller, hops, conversationId, signal, onPromptQueued: releaseLocalCreate };
|
|
2927
|
+
const ctx = { relay, caller, hops, conversationId, signal, onPromptQueued: releaseLocalCreate, ...(catalog ? { catalog } : {}), ...(settledModel ? { settledModel } : {}) };
|
|
2632
2928
|
const body = await ACTIONS[action](ctx, args);
|
|
2633
2929
|
if (prompting) agentPrompts(conversationId).count += 1;
|
|
2634
2930
|
const { summary, ...rest } = body;
|