@oar-sh/oar 0.9.24 → 0.9.26
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/skills/reasoning-stream.mjs +3 -1
- package/CHANGELOG.md +284 -0
- package/README.md +12 -11
- package/package.json +5 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +15 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +8 -4
- package/server/claude-worker/claude-session-process.mjs +4 -0
- package/server/claude-worker/claude-turn-publisher.mjs +25 -11
- package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
- package/server/copilot-worker/copilot-prompt-context.mjs +2 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +11 -9
- package/server/cursor-worker/cursor-turn-runner.mjs +3 -1
- package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/grok-turn-runner.mjs +3 -1
- package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
- package/server/migrations/0039-media-index.mjs +167 -0
- package/server/migrations/0040-image-session-defaults.mjs +93 -0
- package/server/public/app/api-client.js +58 -0
- package/server/public/app/ask-user-view.js +2 -1
- package/server/public/app/attachments-view.js +66 -9
- package/server/public/app/bootstrap.js +85 -25
- package/server/public/app/chat-actions-menu-state.mjs +1 -0
- package/server/public/app/context-media-tab.js +143 -0
- package/server/public/app/context-usage-view.mjs +2 -2
- package/server/public/app/conversation-view.js +90 -5
- package/server/public/app/image-composer-ui.js +518 -0
- package/server/public/app/image-turn-placeholder.mjs +233 -0
- package/server/public/app/journal-view.js +12 -2
- package/server/public/app/live-activity-cap.mjs +3 -3
- package/server/public/app/markdown-math.mjs +471 -0
- package/server/public/app/media-gallery-model.mjs +202 -0
- package/server/public/app/media-gallery-ui.js +524 -0
- package/server/public/app/media-view.js +79 -0
- package/server/public/app/openai-image-composer.mjs +492 -0
- package/server/public/app/openai-image-cost-ui.js +426 -0
- package/server/public/app/openai-image-spend.mjs +443 -0
- package/server/public/app/plan-usage-view.mjs +38 -6
- package/server/public/app/pwa-install.js +25 -4
- package/server/public/app/relay-error-ctas.mjs +5 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +3 -0
- package/server/public/app/relay-restart-ui.js +2 -2
- package/server/public/app/remote-relays-settings.js +33 -6
- package/server/public/app/router.js +49 -237
- package/server/public/app/session-history-ui.js +2 -0
- package/server/public/app/settings-modal.js +4 -0
- package/server/public/app/settings-tabs.js +10 -1
- package/server/public/app/socket-handlers.js +26 -0
- package/server/public/app/store.js +2 -0
- package/server/public/app/stream-view-preference.mjs +1 -1
- package/server/public/app/tab-strip-fade.mjs +1 -1
- package/server/public/app/thoughts-preference.mjs +1 -1
- package/server/public/app/tmux-inspector-view.js +29 -16
- package/server/public/app/viewer-gallery.mjs +23 -1
- package/server/public/index.html +517 -123
- package/server/relay-tools.md +1 -0
- package/server/repositories/image-conversation-repository.mjs +69 -0
- package/server/repositories/media-repository.mjs +305 -0
- package/server/routes/media-routes.mjs +361 -0
- package/server/routes/messages-routes.mjs +1410 -360
- package/server/routes/relay-board-routes.mjs +46 -1
- package/server/routes/remote-relay-routes.mjs +1 -1
- package/server/routes/sessions-routes.mjs +361 -14
- package/server/server-runtime.mjs +21 -0
- package/server/services/database-file.mjs +6 -4
- package/server/services/generated-image-cleanup-service.mjs +2 -1
- package/server/services/generated-image-files.mjs +110 -0
- package/server/services/image-input-paths.mjs +389 -0
- package/server/services/image-operation-service.mjs +6 -2
- package/server/services/media-extract.mjs +653 -0
- package/server/services/media-index-service.mjs +221 -0
- package/server/services/openai-image-preview-turns.mjs +330 -0
- package/server/services/openai-image-spend-service.mjs +516 -0
- package/server/services/openai-image-stream.mjs +170 -0
- package/server/services/plan-usage-openai-image.mjs +148 -0
- package/server/services/relay-prompt-sanitizer.mjs +7 -0
- package/server/services/relay-restart-queue.mjs +1 -1
- package/server/services/remote-relay-caller-context.mjs +6 -0
- package/server/services/remote-relay-dispatcher.mjs +409 -23
- package/server/services/remote-relay-registry-service.mjs +10 -2
- package/server/services/session-worker-stop-service.mjs +50 -18
- package/server/services/token-usage-service.mjs +15 -0
- package/shared/image-header.mjs +90 -0
- package/shared/math-instructions.mjs +30 -0
- package/shared/openai-image-options.mjs +299 -0
- package/shared/openai-image-presets.mjs +175 -0
- package/shared/openai-image-previews.mjs +40 -0
- package/shared/openai-image-prices.mjs +291 -0
- package/shared/openai-image-spend.mjs +90 -0
- package/shared/remote-relay-contract.mjs +126 -4
- package/shared/worker-runtime/direct-openai-image.mjs +96 -50
- package/server/public/app/math-dollars.mjs +0 -89
|
@@ -30,6 +30,22 @@ import {
|
|
|
30
30
|
validateRemoteRelayToolInput,
|
|
31
31
|
withRemotePromptHeader,
|
|
32
32
|
} from '../../shared/remote-relay-contract.mjs';
|
|
33
|
+
import {
|
|
34
|
+
IMAGE_INPUT_REFUSED_CODE,
|
|
35
|
+
OPENAI_IMAGE_MODEL_NEEDS_OPENAI_CODE,
|
|
36
|
+
OPENAI_IMAGE_NO_MODEL_CODE,
|
|
37
|
+
formatOpenAIImageSummary,
|
|
38
|
+
isOpenAIImageModelId,
|
|
39
|
+
openAIImageModelNeedsOpenAIText,
|
|
40
|
+
openAIImageNoModelText,
|
|
41
|
+
pickOpenAIImageDefaultModel,
|
|
42
|
+
} from '../../shared/openai-image-options.mjs';
|
|
43
|
+
import {
|
|
44
|
+
OPENAI_IMAGE_DAILY_CAP_REACHED_CODE,
|
|
45
|
+
OPENAI_IMAGE_PRESETS_PATH,
|
|
46
|
+
OPENAI_IMAGE_USAGE_PATH,
|
|
47
|
+
formatOpenAIImageUsd,
|
|
48
|
+
} from '../../shared/openai-image-spend.mjs';
|
|
33
49
|
import { schemaFields } from '../../shared/question-schema.mjs';
|
|
34
50
|
import { PLAN_ONLY_ERROR_CODE, planOnlyRefusesMode } from '../../shared/plan-only.mjs';
|
|
35
51
|
import { matchQuestionChoice } from '../../shared/question-answer-rule.mjs';
|
|
@@ -110,6 +126,8 @@ const PROGRESS_ACTIVITY_COUNT = 5;
|
|
|
110
126
|
const ACTIVITY_LINE_MAX = 300;
|
|
111
127
|
const ACTIVITY_LINES_PER_MESSAGE = 20;
|
|
112
128
|
const REPLY_TEXT_MAX = REMOTE_RELAY_LIMITS.perMessageChars * 2;
|
|
129
|
+
// The images one message lists (an image turn makes at most four).
|
|
130
|
+
const REPLY_IMAGES_MAX = 8;
|
|
113
131
|
// A message that would only fit as a stub is dropped instead (read_session).
|
|
114
132
|
const MIN_TRIMMED_MESSAGE_CHARS = 200;
|
|
115
133
|
const TITLE_MAX = 80;
|
|
@@ -316,12 +334,17 @@ function catalogModelsLabelled(catalog, label) {
|
|
|
316
334
|
.map(([model]) => model);
|
|
317
335
|
}
|
|
318
336
|
|
|
319
|
-
/**
|
|
337
|
+
/**
|
|
338
|
+
* Copilot's catalogue: every model of /api/models no other provider claims
|
|
339
|
+
* alone, without image ids (a relay that predates their filter lists them;
|
|
340
|
+
* the Copilot runtime cannot serve them).
|
|
341
|
+
*/
|
|
320
342
|
function catalogGithubModels(catalog) {
|
|
321
343
|
const models = catalog?.models || {};
|
|
322
344
|
const providersByModel = isPlainObject(models.providersByModel) ? models.providersByModel : {};
|
|
323
345
|
const catalogModels = Array.isArray(models.models) ? models.models.map(toText).filter(Boolean) : [];
|
|
324
346
|
return catalogModels.filter((model) => {
|
|
347
|
+
if (isOpenAIImageModelId(model)) return false;
|
|
325
348
|
const providers = catalogProvidersFor(providersByModel, model);
|
|
326
349
|
return providers.length === 0 || providers.includes(CATALOG_PROVIDER_LABELS.github);
|
|
327
350
|
});
|
|
@@ -427,6 +450,61 @@ function isTransientRemoteError(error) {
|
|
|
427
450
|
// POST /api/message answers a replayed messageId with this (409).
|
|
428
451
|
const DUPLICATE_MESSAGE_ID_CODE = 'DUPLICATE_MESSAGE_ID';
|
|
429
452
|
|
|
453
|
+
// The actions that carry images or image options to the target.
|
|
454
|
+
const IMAGE_INPUT_ACTIONS = new Set(['send', 'create_session', 'update_session']);
|
|
455
|
+
|
|
456
|
+
function imageInputRefusalText(error) {
|
|
457
|
+
const body = isPlainObject(error?.remoteBody) ? error.remoteBody : null;
|
|
458
|
+
if (toText(body?.code) !== IMAGE_INPUT_REFUSED_CODE) return null;
|
|
459
|
+
return toText(body.error).replace(/[.\s]+$/, '') || 'refused';
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** The target's words when its OpenAI image cap refused a send (409), or null for any other error. */
|
|
463
|
+
function imageCapRefusalText(error) {
|
|
464
|
+
const body = isPlainObject(error?.remoteBody) ? error.remoteBody : null;
|
|
465
|
+
if (toText(body?.code) !== OPENAI_IMAGE_DAILY_CAP_REACHED_CODE) return null;
|
|
466
|
+
return toText(body.error) || 'the daily cap of OpenAI image spend is reached';
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** A route the target does not have: an OAR from before it. */
|
|
470
|
+
function isMissingRemoteRoute(error) {
|
|
471
|
+
return Number(error?.status) === 404 || toText(error?.code) === REMOTE_RELAY_ERROR_CODES.notFound;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The spend state of a target's OpenAI images as the agent reads it (the
|
|
476
|
+
* `usage` action's entry), or null for a state that says nothing.
|
|
477
|
+
*/
|
|
478
|
+
function openAIImageSpendEntry(state) {
|
|
479
|
+
if (!isPlainObject(state)) return null;
|
|
480
|
+
const number = (value) => {
|
|
481
|
+
const parsed = Number(value);
|
|
482
|
+
return value !== null && value !== undefined && value !== '' && Number.isFinite(parsed) ? parsed : null;
|
|
483
|
+
};
|
|
484
|
+
const used = Math.round(((number(state.spentUsd) ?? 0) + (number(state.reservedUsd) ?? 0)) * 100) / 100;
|
|
485
|
+
const cap = number(state.capUsd);
|
|
486
|
+
const capUsd = cap !== null && cap > 0 ? cap : null;
|
|
487
|
+
const remaining = capUsd === null ? null : Math.max(0, Math.round((capUsd - used) * 100) / 100);
|
|
488
|
+
const allowedToday = state.allowedToday === true;
|
|
489
|
+
const reached = capUsd !== null && state.reached === true;
|
|
490
|
+
const text = capUsd === null
|
|
491
|
+
? `today ${formatOpenAIImageUsd(used)}, no daily cap`
|
|
492
|
+
: `today ${formatOpenAIImageUsd(used)} of the daily cap of ${formatOpenAIImageUsd(capUsd)} (${Math.round((used / capUsd) * 100)} %), ${formatOpenAIImageUsd(remaining)} left`
|
|
493
|
+
+ (reached ? (allowedToday ? '; past the cap, allowed for today by the owner' : '; new image turns are refused until tomorrow (relay time) or until the owner allows more for today') : '');
|
|
494
|
+
return {
|
|
495
|
+
label: 'OpenAI images',
|
|
496
|
+
day: toText(state.day) || null,
|
|
497
|
+
usedUsd: used,
|
|
498
|
+
capUsd,
|
|
499
|
+
remainingUsd: remaining,
|
|
500
|
+
...(number(state.monthUsd) !== null ? { monthUsd: number(state.monthUsd) } : {}),
|
|
501
|
+
warning: state.warning === true,
|
|
502
|
+
reached,
|
|
503
|
+
allowedToday,
|
|
504
|
+
text,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
430
508
|
function isOwnDuplicateMessage(error, messageId) {
|
|
431
509
|
const body = isPlainObject(error?.remoteBody) ? error.remoteBody : null;
|
|
432
510
|
if (Number(error?.status) !== 409 || toText(body?.code) !== DUPLICATE_MESSAGE_ID_CODE) return false;
|
|
@@ -586,13 +664,61 @@ export function remoteFollowUpState(rows, replyMessageId, page, { sawBackgroundW
|
|
|
586
664
|
};
|
|
587
665
|
}
|
|
588
666
|
|
|
667
|
+
function positiveIntOrNull(value) {
|
|
668
|
+
const number = Number(value);
|
|
669
|
+
return Number.isInteger(number) && number > 0 ? number : null;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function generatedImageAttachments(row) {
|
|
673
|
+
return (Array.isArray(row?.attachments) ? row.attachments : []).filter((entry) => isPlainObject(entry?.generatedImage));
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* The images an assistant message made, as the agent reads them: their files
|
|
678
|
+
* on the target relay (which a later send there can attach), size and type.
|
|
679
|
+
* A relay that does not give the path (an older one, a file gone) lists none.
|
|
680
|
+
*/
|
|
681
|
+
function replyImagesOf(row) {
|
|
682
|
+
const images = [];
|
|
683
|
+
for (const attachment of generatedImageAttachments(row)) {
|
|
684
|
+
const path = toText(attachment.generatedImage.path);
|
|
685
|
+
if (!path) continue;
|
|
686
|
+
images.push({
|
|
687
|
+
path,
|
|
688
|
+
width: positiveIntOrNull(attachment.width),
|
|
689
|
+
height: positiveIntOrNull(attachment.height),
|
|
690
|
+
mime: toText(attachment.type) || null,
|
|
691
|
+
});
|
|
692
|
+
if (images.length >= REPLY_IMAGES_MAX) break;
|
|
693
|
+
}
|
|
694
|
+
return images;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* An assistant message's text, or for an image turn whose model wrote none
|
|
699
|
+
* the line on what it made ("1 image · 1536x1024 · gpt-image-2"): newer
|
|
700
|
+
* relays store that line as the text, older messages have nothing.
|
|
701
|
+
*/
|
|
702
|
+
function assistantText(row, model = '') {
|
|
703
|
+
const text = String(row?.text ?? '');
|
|
704
|
+
if (text.trim()) return text;
|
|
705
|
+
const generated = generatedImageAttachments(row);
|
|
706
|
+
if (!generated.length) return text;
|
|
707
|
+
return formatOpenAIImageSummary({
|
|
708
|
+
images: generated.map((attachment) => ({ width: positiveIntOrNull(attachment.width), height: positiveIntOrNull(attachment.height) })),
|
|
709
|
+
model: toText(row?.model) || toText(model),
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
|
|
589
713
|
function replyOf(row, extra = {}) {
|
|
590
|
-
const clipped = clipMiddle(toText(row
|
|
714
|
+
const clipped = clipMiddle(toText(assistantText(row)), REPLY_TEXT_MAX);
|
|
715
|
+
const images = replyImagesOf(row);
|
|
591
716
|
return {
|
|
592
717
|
text: clipped.text,
|
|
593
718
|
model: toText(row?.model) || null,
|
|
594
719
|
messageId: toText(row?.id) || null,
|
|
595
720
|
...(clipped.truncated ? { truncated: true } : {}),
|
|
721
|
+
...(images.length ? { images } : {}),
|
|
596
722
|
...extra,
|
|
597
723
|
};
|
|
598
724
|
}
|
|
@@ -1453,24 +1579,43 @@ export function createRemoteRelayDispatcher({
|
|
|
1453
1579
|
throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unsupported,
|
|
1454
1580
|
`Relay "${ctx.relay.name}" reports only the Copilot quota in an older shape; it needs an update for plan usage per provider.`);
|
|
1455
1581
|
}
|
|
1456
|
-
const limit = await
|
|
1457
|
-
|
|
1582
|
+
const [limit, imageSpend] = await Promise.all([
|
|
1583
|
+
call(ctx, 'GET', '/api/usage-limit').catch(() => null),
|
|
1584
|
+
// OpenAI images against the relay's daily cap; an OAR from before the
|
|
1585
|
+
// cap has no route for it, and then no entry.
|
|
1586
|
+
call(ctx, 'GET', OPENAI_IMAGE_USAGE_PATH)
|
|
1587
|
+
.then((state) => ({ entry: openAIImageSpendEntry(state) }))
|
|
1588
|
+
.catch((error) => ({ entry: null, failed: !isMissingRemoteRoute(error) })),
|
|
1589
|
+
]);
|
|
1590
|
+
const openaiImages = imageSpend.entry;
|
|
1591
|
+
// The entry says it all: the report's own OpenAI images card would be a second copy.
|
|
1592
|
+
const providers = cards.slice(0, 12).map(usageCard).filter(Boolean)
|
|
1593
|
+
.filter((card) => !(openaiImages && card.provider === 'openai'));
|
|
1458
1594
|
const set = providers.filter((card) => !['not-configured', 'unavailable'].includes(card.status)).map((card) => card.provider);
|
|
1459
1595
|
const usageLimit = limit?.ok === true ? usageLimitState(limit) : null;
|
|
1596
|
+
const notes = [
|
|
1597
|
+
usageLimit ? '' : 'The usage-limit state was not available there.',
|
|
1598
|
+
imageSpend.failed ? 'The OpenAI image spend was not available there.' : '',
|
|
1599
|
+
].filter(Boolean);
|
|
1460
1600
|
return {
|
|
1461
|
-
summary: `usage → ${ctx.relay.name}: ${set.join(', ') || 'no provider'} reported${usageLimit?.pauses?.length ? `, ${usageLimit.pauses.length} paused at a usage limit` : ''}`,
|
|
1601
|
+
summary: `usage → ${ctx.relay.name}: ${set.join(', ') || 'no provider'} reported${usageLimit?.pauses?.length ? `, ${usageLimit.pauses.length} paused at a usage limit` : ''}${openaiImages ? `; OpenAI images ${openaiImages.text}` : ''}`,
|
|
1462
1602
|
providers,
|
|
1603
|
+
...(openaiImages ? { openaiImages } : {}),
|
|
1463
1604
|
usageLimit,
|
|
1464
|
-
...(
|
|
1605
|
+
...(notes.length ? { note: notes.join(' ') } : {}),
|
|
1465
1606
|
};
|
|
1466
1607
|
}
|
|
1467
1608
|
|
|
1468
1609
|
async function relayInfo(ctx) {
|
|
1469
|
-
const [identity, catalog, groups] = await Promise.all([
|
|
1610
|
+
const [identity, catalog, groups, presets] = await Promise.all([
|
|
1470
1611
|
call(ctx, 'GET', '/api/relay/identity').catch(() => null),
|
|
1471
1612
|
loadCatalog(ctx),
|
|
1472
1613
|
relayGroups(ctx),
|
|
1614
|
+
// The image presets a send or create_session may name; an OAR from
|
|
1615
|
+
// before them has no route, and then no list.
|
|
1616
|
+
call(ctx, 'GET', OPENAI_IMAGE_PRESETS_PATH).catch(() => null),
|
|
1473
1617
|
]);
|
|
1618
|
+
const imagePresets = Array.isArray(presets?.presets) ? relayImagePresets(presets.presets) : null;
|
|
1474
1619
|
const status = catalog.status;
|
|
1475
1620
|
const providers = buildProviders(catalog);
|
|
1476
1621
|
const remoteRelays = isPlainObject(identity?.remoteRelays) ? identity.remoteRelays : null;
|
|
@@ -1488,6 +1633,8 @@ export function createRemoteRelayDispatcher({
|
|
|
1488
1633
|
// The inbound switch is about other relays' agents; this relay's own are
|
|
1489
1634
|
// let in by the agent-sessions setting, which is on or this would not run.
|
|
1490
1635
|
acceptsAgentPrompts: ctx.relay.local ? true : (remoteRelays ? remoteRelays.inbound !== false : null),
|
|
1636
|
+
// Whether send and create_session may carry images and image there.
|
|
1637
|
+
imageInputs: ctx.relay.local ? true : remoteRelays?.imageInputs === true,
|
|
1491
1638
|
...(ctx.relay.local ? {
|
|
1492
1639
|
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).`,
|
|
1493
1640
|
} : {}),
|
|
@@ -1501,9 +1648,24 @@ export function createRemoteRelayDispatcher({
|
|
|
1501
1648
|
defaultRelayMode: toText(status.defaultRelayMode) || null,
|
|
1502
1649
|
// The session groups a create_session there may name (`group`).
|
|
1503
1650
|
...groups,
|
|
1651
|
+
...(imagePresets ? { imagePresets } : {}),
|
|
1504
1652
|
};
|
|
1505
1653
|
}
|
|
1506
1654
|
|
|
1655
|
+
/** A target's image presets as relay_info lists them: name, the values, builtIn. */
|
|
1656
|
+
function relayImagePresets(list) {
|
|
1657
|
+
return list.slice(0, 40).filter(isPlainObject).map((preset) => {
|
|
1658
|
+
const name = toText(preset.name).slice(0, 40);
|
|
1659
|
+
if (!name) return null;
|
|
1660
|
+
const values = {};
|
|
1661
|
+
for (const key of ['size', 'quality', 'count', 'output_format', 'background']) {
|
|
1662
|
+
const value = preset[key];
|
|
1663
|
+
if (key === 'count' ? Number.isInteger(value) : (typeof value === 'string' && value.trim())) values[key] = key === 'count' ? value : value.trim().slice(0, 40);
|
|
1664
|
+
}
|
|
1665
|
+
return { name, ...values, builtIn: preset.builtIn === true };
|
|
1666
|
+
}).filter(Boolean);
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1507
1669
|
/**
|
|
1508
1670
|
* The target relay's session workers, from its /api/status: `working`, the
|
|
1509
1671
|
* conversations whose worker is starting or running a turn, and
|
|
@@ -1724,7 +1886,8 @@ export function createRemoteRelayDispatcher({
|
|
|
1724
1886
|
};
|
|
1725
1887
|
}
|
|
1726
1888
|
|
|
1727
|
-
function messageText(row) {
|
|
1889
|
+
function messageText(row, sessionModel = '') {
|
|
1890
|
+
if (row?.role === 'assistant') return assistantText(row, sessionModel);
|
|
1728
1891
|
const text = String(row?.text ?? '');
|
|
1729
1892
|
// The header line is ours (or another relay's): the badge fields say the
|
|
1730
1893
|
// same, so the agent reads the prompt itself.
|
|
@@ -1737,7 +1900,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1737
1900
|
* returns a 9000-character reply whole, ten messages in the default 12000
|
|
1738
1901
|
* still get 4000 each. A trimmed message keeps its head and its tail.
|
|
1739
1902
|
*/
|
|
1740
|
-
function shapeMessages(rows, { maxChars, includeActivity }) {
|
|
1903
|
+
function shapeMessages(rows, { maxChars, includeActivity, sessionModel = '' }) {
|
|
1741
1904
|
const list = Array.isArray(rows) ? rows.filter(Boolean) : [];
|
|
1742
1905
|
const out = [];
|
|
1743
1906
|
const perMessageMax = Math.max(REMOTE_RELAY_LIMITS.perMessageChars, Math.floor(maxChars / Math.max(1, list.length)));
|
|
@@ -1745,7 +1908,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1745
1908
|
let omitted = 0;
|
|
1746
1909
|
for (let index = list.length - 1; index >= 0; index -= 1) {
|
|
1747
1910
|
const row = list[index];
|
|
1748
|
-
const perMessage = clipMiddle(messageText(row), perMessageMax);
|
|
1911
|
+
const perMessage = clipMiddle(messageText(row, sessionModel), perMessageMax);
|
|
1749
1912
|
let text = perMessage.text;
|
|
1750
1913
|
let truncated = perMessage.truncated;
|
|
1751
1914
|
if (text.length > budget) {
|
|
@@ -1762,6 +1925,8 @@ export function createRemoteRelayDispatcher({
|
|
|
1762
1925
|
// The newest lines, as the progress path reads them: the relay keeps the
|
|
1763
1926
|
// newest per thread now, so the first ones are not the start anyway.
|
|
1764
1927
|
const activities = includeActivity && role === 'assistant' ? activityTexts(row.activities, { fromEnd: true }) : [];
|
|
1928
|
+
// Like the tool lines, outside the text budget: a few short entries.
|
|
1929
|
+
const images = role === 'assistant' ? replyImagesOf(row) : [];
|
|
1765
1930
|
out.unshift({
|
|
1766
1931
|
id: toText(row.id),
|
|
1767
1932
|
role,
|
|
@@ -1772,6 +1937,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1772
1937
|
...(via ? { via } : {}),
|
|
1773
1938
|
text,
|
|
1774
1939
|
...(truncated ? { truncated: true } : {}),
|
|
1940
|
+
...(images.length ? { images } : {}),
|
|
1775
1941
|
...(activities.length ? { activities } : {}),
|
|
1776
1942
|
});
|
|
1777
1943
|
if (budget <= 0 && index > 0) {
|
|
@@ -1800,9 +1966,12 @@ export function createRemoteRelayDispatcher({
|
|
|
1800
1966
|
sessionWorkers(ctx),
|
|
1801
1967
|
]);
|
|
1802
1968
|
const worker = workers.workerFor(conversationId);
|
|
1969
|
+
const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
|
|
1970
|
+
const sessionModel = toText(runtime.providerModel) || toText(conversation?.preferredModel) || toText(runtime.model) || null;
|
|
1803
1971
|
const { messages, omitted } = shapeMessages(conversation?.messages, {
|
|
1804
1972
|
maxChars: args.max_chars,
|
|
1805
1973
|
includeActivity: args.include_activity,
|
|
1974
|
+
sessionModel,
|
|
1806
1975
|
});
|
|
1807
1976
|
let olderCursor = null;
|
|
1808
1977
|
if (omitted > 0 && messages.length) {
|
|
@@ -1813,7 +1982,6 @@ export function createRemoteRelayDispatcher({
|
|
|
1813
1982
|
beforeTimestamp: toText(conversation.pageInfo.olderCursor.beforeTimestamp),
|
|
1814
1983
|
});
|
|
1815
1984
|
}
|
|
1816
|
-
const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
|
|
1817
1985
|
const via = toText(conversation?.origin?.relayName);
|
|
1818
1986
|
const inFlight = progressSnapshot(conversation?.inFlight);
|
|
1819
1987
|
// Its session group, with the other members and what each is doing.
|
|
@@ -1839,7 +2007,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1839
2007
|
id: conversationId,
|
|
1840
2008
|
title: toText(conversation?.title),
|
|
1841
2009
|
provider: toText(runtime.providerType) || 'github',
|
|
1842
|
-
model:
|
|
2010
|
+
model: sessionModel,
|
|
1843
2011
|
mode: toText(conversation?.preferredRelayMode) || null,
|
|
1844
2012
|
cwd: toText(conversation?.currentWorkspaceRootPath) || null,
|
|
1845
2013
|
archived: conversation?.archived === true,
|
|
@@ -1983,7 +2151,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1983
2151
|
* When the replay fails too, nobody knows; the error carries the session
|
|
1984
2152
|
* and message id so the agent checks with wait instead of sending again.
|
|
1985
2153
|
*/
|
|
1986
|
-
async function postPrompt(ctx, { session, text, origin, model, mode, effort, billing = '' }) {
|
|
2154
|
+
async function postPrompt(ctx, { session, text, origin, model, mode, effort, billing = '', imagePaths = null, image = null }) {
|
|
1987
2155
|
const messageId = String(randomUUID());
|
|
1988
2156
|
const body = {
|
|
1989
2157
|
conversationId: session,
|
|
@@ -1993,6 +2161,9 @@ export function createRemoteRelayDispatcher({
|
|
|
1993
2161
|
...(model ? { model } : {}),
|
|
1994
2162
|
...(mode ? { relayMode: mode } : {}),
|
|
1995
2163
|
...(effort ? { reasoningEffort: effort } : {}),
|
|
2164
|
+
// Read on the target: the paths are its own files.
|
|
2165
|
+
...(Array.isArray(imagePaths) && imagePaths.length ? { imagePaths } : {}),
|
|
2166
|
+
...(isPlainObject(image) && Object.keys(image).length ? { image } : {}),
|
|
1996
2167
|
};
|
|
1997
2168
|
// A Claude session moved to another billing with this message: one call,
|
|
1998
2169
|
// which switches and enqueues together on the target, or does neither.
|
|
@@ -2003,12 +2174,12 @@ export function createRemoteRelayDispatcher({
|
|
|
2003
2174
|
try {
|
|
2004
2175
|
sent = await post();
|
|
2005
2176
|
} catch (error) {
|
|
2006
|
-
if (!isTransientRemoteError(error)) throw error;
|
|
2177
|
+
if (!isTransientRemoteError(error)) throw imageInputFailure(ctx, error, session) || imageCapFailure(ctx, error, session) || error;
|
|
2007
2178
|
try {
|
|
2008
2179
|
sent = await post();
|
|
2009
2180
|
} catch (retryError) {
|
|
2010
2181
|
if (isOwnDuplicateMessage(retryError, messageId)) return { duplicate: false, messageId };
|
|
2011
|
-
if (!isTransientRemoteError(retryError)) throw retryError;
|
|
2182
|
+
if (!isTransientRemoteError(retryError)) throw imageInputFailure(ctx, retryError, session) || imageCapFailure(ctx, retryError, session) || retryError;
|
|
2012
2183
|
throw new DispatchFailure(502, toText(retryError.code) || REMOTE_RELAY_ERROR_CODES.offline,
|
|
2013
2184
|
`${toText(retryError.message) || `Relay "${ctx.relay.name}" did not answer`}; the prompt may or may not have been queued.`,
|
|
2014
2185
|
{
|
|
@@ -2028,9 +2199,107 @@ export function createRemoteRelayDispatcher({
|
|
|
2028
2199
|
duplicate: false,
|
|
2029
2200
|
messageId: toText(sent?.messageId) || messageId,
|
|
2030
2201
|
effort: normalizeEffort(sent?.selectedReasoningEffort),
|
|
2202
|
+
// How many of the images the target attached; null when it does not say.
|
|
2203
|
+
imageInputs: Number.isInteger(sent?.imageInputs) && sent.imageInputs >= 0 ? sent.imageInputs : null,
|
|
2031
2204
|
};
|
|
2032
2205
|
}
|
|
2033
2206
|
|
|
2207
|
+
/** The target's refusal of the images or image options, as the agent reads it, or null for any other error. */
|
|
2208
|
+
function imageInputFailure(ctx, error, session) {
|
|
2209
|
+
const reason = imageInputRefusalText(error);
|
|
2210
|
+
if (reason === null) return null;
|
|
2211
|
+
const refusal = new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput,
|
|
2212
|
+
`Relay "${ctx.relay.name}" refused the images or image options: ${reason}. Nothing was sent.`,
|
|
2213
|
+
{ session, remoteCode: IMAGE_INPUT_REFUSED_CODE });
|
|
2214
|
+
refusal.imageInputReason = reason;
|
|
2215
|
+
return refusal;
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
/**
|
|
2219
|
+
* The target's OpenAI image cap refused the send (409): its words, which
|
|
2220
|
+
* name the spend and the cap, and that nothing was sent; or null for any
|
|
2221
|
+
* other error.
|
|
2222
|
+
*/
|
|
2223
|
+
function imageCapFailure(ctx, error, session) {
|
|
2224
|
+
const words = imageCapRefusalText(error);
|
|
2225
|
+
if (words === null) return null;
|
|
2226
|
+
const where = ctx.relay.local ? 'This relay' : `Relay "${ctx.relay.name}"`;
|
|
2227
|
+
return new DispatchFailure(409, REMOTE_RELAY_ERROR_CODES.providerUnavailable,
|
|
2228
|
+
`${where} refused the image turn: ${words.replace(/[.\s]+$/, '')}.${/nothing was sent/i.test(words) ? '' : ' Nothing was sent.'}`,
|
|
2229
|
+
{
|
|
2230
|
+
session,
|
|
2231
|
+
remoteCode: OPENAI_IMAGE_DAILY_CAP_REACHED_CODE,
|
|
2232
|
+
note: 'Ask the user to allow more for today (Settings → Providers → OpenAI on that relay) or wait until tomorrow (relay time); do not send it again before.',
|
|
2233
|
+
});
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
/**
|
|
2237
|
+
* Before create_session starts an OpenAI image session: refused when the
|
|
2238
|
+
* target's daily cap of image spend is reached and the owner did not allow
|
|
2239
|
+
* today, so no session is left behind. A target without the route (an OAR
|
|
2240
|
+
* from before the cap) has no cap; any other failure to read it is no
|
|
2241
|
+
* reason to stop, since the target checks the cap again at the send.
|
|
2242
|
+
*/
|
|
2243
|
+
async function refuseAtImageCap(ctx) {
|
|
2244
|
+
let state = null;
|
|
2245
|
+
try {
|
|
2246
|
+
// A target that hangs delays the session by 5 s at most; a timeout
|
|
2247
|
+
// carries on like any other error (the send-time check decides).
|
|
2248
|
+
state = await call(ctx, 'GET', OPENAI_IMAGE_USAGE_PATH, { timeoutMs: 5000 });
|
|
2249
|
+
} catch {
|
|
2250
|
+
return;
|
|
2251
|
+
}
|
|
2252
|
+
const entry = openAIImageSpendEntry(state);
|
|
2253
|
+
if (!entry?.reached || entry.allowedToday) return;
|
|
2254
|
+
const where = ctx.relay.local ? 'This relay' : `Relay "${ctx.relay.name}"`;
|
|
2255
|
+
throw new DispatchFailure(409, REMOTE_RELAY_ERROR_CODES.providerUnavailable,
|
|
2256
|
+
`${where} has reached its daily cap of OpenAI image spend: ${formatOpenAIImageUsd(entry.usedUsd)} today of ${formatOpenAIImageUsd(entry.capUsd)}. No session was created.`,
|
|
2257
|
+
{
|
|
2258
|
+
remoteCode: OPENAI_IMAGE_DAILY_CAP_REACHED_CODE,
|
|
2259
|
+
note: 'Ask the user to allow more for today (Settings → Providers → OpenAI on that relay) or wait until tomorrow (relay time).',
|
|
2260
|
+
});
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
/**
|
|
2264
|
+
* A target that takes the images answers how many it attached. An older one
|
|
2265
|
+
* that slipped in after the check (a downgrade) drops them silently and runs
|
|
2266
|
+
* the turn without: the prompt is queued by then, so the agent is told.
|
|
2267
|
+
*/
|
|
2268
|
+
function imageEchoNote(ctx, sent, imagePaths) {
|
|
2269
|
+
const wanted = Array.isArray(imagePaths) ? imagePaths.length : 0;
|
|
2270
|
+
if (!wanted || sent?.duplicate) return null;
|
|
2271
|
+
const where = ctx.relay.local ? 'This relay' : `Relay "${ctx.relay.name}"`;
|
|
2272
|
+
if (sent.imageInputs === null || sent.imageInputs === undefined) {
|
|
2273
|
+
return `${where} did not confirm that it attached the ${wanted === 1 ? 'image' : `${wanted} images`}; the turn may run without them.`;
|
|
2274
|
+
}
|
|
2275
|
+
if (sent.imageInputs < wanted) {
|
|
2276
|
+
return `${where} attached ${sent.imageInputs} of ${wanted} images; the turn may run without them.`;
|
|
2277
|
+
}
|
|
2278
|
+
return null;
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
/**
|
|
2282
|
+
* images and image need a target that takes them: an older OAR passes the
|
|
2283
|
+
* body fields over and runs (and bills) the turn without them. Its identity
|
|
2284
|
+
* is read fresh: the stored copy keeps only the fields this relay knew of
|
|
2285
|
+
* when it last probed. A relay without the identity route is older still.
|
|
2286
|
+
*/
|
|
2287
|
+
async function refuseWithoutImageInputs(ctx, action) {
|
|
2288
|
+
let identity = null;
|
|
2289
|
+
try {
|
|
2290
|
+
identity = await call(ctx, 'GET', '/api/relay/identity');
|
|
2291
|
+
} catch (error) {
|
|
2292
|
+
if (!(Number(error?.status) === 404 || toText(error?.code) === REMOTE_RELAY_ERROR_CODES.notFound)) throw error;
|
|
2293
|
+
}
|
|
2294
|
+
if (isPlainObject(identity?.remoteRelays) && identity.remoteRelays.imageInputs === true) return;
|
|
2295
|
+
const nothing = action === 'update_session'
|
|
2296
|
+
? 'nothing was changed'
|
|
2297
|
+
: (action === 'create_session' ? 'nothing was created or sent' : 'nothing was sent');
|
|
2298
|
+
throw new DispatchFailure(409, REMOTE_RELAY_ERROR_CODES.unsupported,
|
|
2299
|
+
`Relay "${ctx.relay.name}" runs an older OAR that cannot take images or image options: ${nothing}.`,
|
|
2300
|
+
{ note: 'Leave images and image out to work there without them, or ask the user to update that relay.' });
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2034
2303
|
/**
|
|
2035
2304
|
* POST /api/conversation/:id/billing with a message. A relay older than the
|
|
2036
2305
|
* switch answers 404 without a code: nothing was switched or sent. Its
|
|
@@ -2212,7 +2481,16 @@ export function createRemoteRelayDispatcher({
|
|
|
2212
2481
|
const requested = normalizeEffort(args.effort) || normalizeEffort(settled?.effort);
|
|
2213
2482
|
const own = requested ? '' : normalizeEffort(conversation?.preferredReasoningEffort);
|
|
2214
2483
|
const origin = await buildOrigin(ctx);
|
|
2215
|
-
const { sent, choice } = await postPromptWithEffort(ctx, {
|
|
2484
|
+
const { sent, choice } = await postPromptWithEffort(ctx, {
|
|
2485
|
+
session,
|
|
2486
|
+
text: args.text,
|
|
2487
|
+
origin,
|
|
2488
|
+
model,
|
|
2489
|
+
mode,
|
|
2490
|
+
billing: switchTo,
|
|
2491
|
+
imagePaths: args.images,
|
|
2492
|
+
image: args.image,
|
|
2493
|
+
}, {
|
|
2216
2494
|
effort: requested || own,
|
|
2217
2495
|
source: requested ? EFFORT_SOURCES.requested : (own ? EFFORT_SOURCES.remote : ''),
|
|
2218
2496
|
note: null,
|
|
@@ -2220,6 +2498,8 @@ export function createRemoteRelayDispatcher({
|
|
|
2220
2498
|
const billingNote = switchTo
|
|
2221
2499
|
? `Session ${session} now runs on ${switchTo === 'claude-api' ? 'the API key' : 'the subscription'}, from this message on (a running turn finishes on the old billing).`
|
|
2222
2500
|
: '';
|
|
2501
|
+
const imageNote = imageEchoNote(ctx, sent, args.images);
|
|
2502
|
+
const note = [effortResult(choice).note, billingNote, imageNote].filter(Boolean).join(' ');
|
|
2223
2503
|
return deliverAndWait('send', ctx, {
|
|
2224
2504
|
session,
|
|
2225
2505
|
sent,
|
|
@@ -2229,7 +2509,7 @@ export function createRemoteRelayDispatcher({
|
|
|
2229
2509
|
...(billing && REMOTE_RELAY_SEND_PROVIDERS.includes(provider) ? { provider: switchTo || provider } : {}),
|
|
2230
2510
|
...(switchTo ? { billingSwitched: true } : {}),
|
|
2231
2511
|
...effortResult(choice),
|
|
2232
|
-
...(
|
|
2512
|
+
...(note ? { note } : {}),
|
|
2233
2513
|
},
|
|
2234
2514
|
});
|
|
2235
2515
|
}
|
|
@@ -2243,6 +2523,13 @@ export function createRemoteRelayDispatcher({
|
|
|
2243
2523
|
* model left unchecked for want of a list included.
|
|
2244
2524
|
*/
|
|
2245
2525
|
function settleRequestedModel(ctx, catalog, provider, requested, { creating, extraModels = [], updating = false }) {
|
|
2526
|
+
// Refused here before the provider's list is looked at, which lacks the
|
|
2527
|
+
// id and would answer "unknown model" without the way out.
|
|
2528
|
+
if (provider === 'github' && isOpenAIImageModelId(requested)) {
|
|
2529
|
+
throw new DispatchFailure(400, OPENAI_IMAGE_MODEL_NEEDS_OPENAI_CODE,
|
|
2530
|
+
`${openAIImageModelNeedsOpenAIText(requested)} ${creating ? 'No session was created.' : (updating ? 'Nothing was changed.' : 'Nothing was sent.')}`,
|
|
2531
|
+
{ provider, model: requested, note: 'Pass provider "openai" with this model (relay_info lists the openai models), or a Copilot model that writes text.' });
|
|
2532
|
+
}
|
|
2246
2533
|
const entry = catalog ? listProviders(catalog).find((candidate) => candidate.provider === provider) : null;
|
|
2247
2534
|
const offered = catalog ? offeredModelList(catalog, provider) : null;
|
|
2248
2535
|
const check = checkRequestedProviderModel({
|
|
@@ -2337,13 +2624,27 @@ export function createRemoteRelayDispatcher({
|
|
|
2337
2624
|
note = settled.note;
|
|
2338
2625
|
variantEffort = normalizeEffort(settled.effort);
|
|
2339
2626
|
}
|
|
2627
|
+
// An OpenAI session started with images or image options is an image
|
|
2628
|
+
// session: it mirrors this session's model only when that makes images,
|
|
2629
|
+
// else it takes the image default of the target's OpenAI list (the pick
|
|
2630
|
+
// its New Chat makes). A list without an image model is refused below.
|
|
2631
|
+
const wantsImages = (Array.isArray(args.images) && args.images.length > 0) || isPlainObject(args.image);
|
|
2632
|
+
const imageSession = provider === 'openai' && wantsImages;
|
|
2340
2633
|
// A cloud session without a requested model gets the Claude Cloud tab's
|
|
2341
2634
|
// default, as New Chat does: the bootstrap picks it, nothing is mirrored.
|
|
2342
2635
|
if (!model && cloud) {
|
|
2343
2636
|
modelSource = 'remote default';
|
|
2344
2637
|
} else if (!model) {
|
|
2345
|
-
|
|
2346
|
-
|
|
2638
|
+
const mirrored = matchOfferedModel(ctx.caller.model, entry.models);
|
|
2639
|
+
const imageDefault = imageSession && !isOpenAIImageModelId(mirrored) ? pickOpenAIImageDefaultModel(entry.models) : '';
|
|
2640
|
+
// Never a chat model for an image session: it would make no images.
|
|
2641
|
+
if (imageSession && !imageDefault && !isOpenAIImageModelId(mirrored)) {
|
|
2642
|
+
throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unknownModel,
|
|
2643
|
+
`${openAIImageNoModelText(`relay "${ctx.relay.name}"`)} No session was created.`,
|
|
2644
|
+
{ remoteCode: OPENAI_IMAGE_NO_MODEL_CODE });
|
|
2645
|
+
}
|
|
2646
|
+
model = imageDefault || mirrored;
|
|
2647
|
+
modelSource = imageDefault ? 'remote default' : (model ? 'same as this session' : '');
|
|
2347
2648
|
}
|
|
2348
2649
|
if (!model && !cloud) {
|
|
2349
2650
|
model = entry.defaultModel || entry.models[0] || '';
|
|
@@ -2352,6 +2653,16 @@ export function createRemoteRelayDispatcher({
|
|
|
2352
2653
|
note = `${provider} on relay "${ctx.relay.name}" does not offer ${ctx.caller.model}; the session uses its default model ${model || '(none)'}.`;
|
|
2353
2654
|
}
|
|
2354
2655
|
}
|
|
2656
|
+
// Images and image options go to an image session only. Settled here, the
|
|
2657
|
+
// provider (this session's when none is named) and the model are known:
|
|
2658
|
+
// refused before the bootstrap, so no session is left behind. Only what
|
|
2659
|
+
// needs the new session's folder (the paths) is the target's to refuse.
|
|
2660
|
+
if (wantsImages && (provider !== 'openai' || !isOpenAIImageModelId(model))) {
|
|
2661
|
+
throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput,
|
|
2662
|
+
`images and image go to an OpenAI image session (provider "openai" with a gpt-image or dall-e model); this one would run ${provider}${model ? ` ${model}` : ''}. `
|
|
2663
|
+
+ 'Pass provider "openai" and leave model out (the relay\'s image default) or name an image model relay_info lists. No session was created.',
|
|
2664
|
+
{ remoteCode: IMAGE_INPUT_REFUSED_CODE });
|
|
2665
|
+
}
|
|
2355
2666
|
// planOnly starts the session locked in plan mode: with no mode named it
|
|
2356
2667
|
// starts in plan; another mode named is refused before anything is made.
|
|
2357
2668
|
const planOnly = args.planOnly === true || toText(args.planOnly).toLowerCase() === 'true';
|
|
@@ -2361,6 +2672,9 @@ export function createRemoteRelayDispatcher({
|
|
|
2361
2672
|
}
|
|
2362
2673
|
const mode = args.mode || (planOnly ? 'plan' : ctx.caller.mode) || toText(catalog.status.defaultRelayMode) || '';
|
|
2363
2674
|
const title = args.title || firstLine(args.text, TITLE_MAX) || 'Remote session';
|
|
2675
|
+
// An image session would make its first images at once: a target whose
|
|
2676
|
+
// daily cap of image spend is reached refuses before it is created.
|
|
2677
|
+
if (provider === 'openai' && isOpenAIImageModelId(model)) await refuseAtImageCap(ctx);
|
|
2364
2678
|
const origin = await buildOrigin(ctx);
|
|
2365
2679
|
|
|
2366
2680
|
// The effort mirrors the caller like the model does: this session's when
|
|
@@ -2449,7 +2763,15 @@ export function createRemoteRelayDispatcher({
|
|
|
2449
2763
|
}
|
|
2450
2764
|
let sent;
|
|
2451
2765
|
try {
|
|
2452
|
-
const posted = await postPromptWithEffort(ctx, {
|
|
2766
|
+
const posted = await postPromptWithEffort(ctx, {
|
|
2767
|
+
session,
|
|
2768
|
+
text: args.text,
|
|
2769
|
+
origin,
|
|
2770
|
+
model: boundModel,
|
|
2771
|
+
mode,
|
|
2772
|
+
imagePaths: args.images,
|
|
2773
|
+
image: args.image,
|
|
2774
|
+
}, choice);
|
|
2453
2775
|
sent = posted.sent;
|
|
2454
2776
|
if (!cloud || choice.effort) choice = posted.choice;
|
|
2455
2777
|
// From here the new session's queued turn counts toward the cap itself.
|
|
@@ -2457,6 +2779,16 @@ export function createRemoteRelayDispatcher({
|
|
|
2457
2779
|
} catch (error) {
|
|
2458
2780
|
// The session exists by now: say so, or the agent starts a second one.
|
|
2459
2781
|
const remoteCode = toText(error?.remoteBody?.code);
|
|
2782
|
+
if (error instanceof DispatchFailure && error.imageInputReason) {
|
|
2783
|
+
throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput,
|
|
2784
|
+
`Relay "${ctx.relay.name}" refused the images or image options of the first prompt: ${error.imageInputReason}. The session ${session} was created, but the prompt was not queued.`,
|
|
2785
|
+
{
|
|
2786
|
+
session,
|
|
2787
|
+
remoteCode: IMAGE_INPUT_REFUSED_CODE,
|
|
2788
|
+
openUrl: remoteConversationUrl(ctx.relay.url, session) || null,
|
|
2789
|
+
note: 'Use send with this session and other images or image options (or none); do not create another session.',
|
|
2790
|
+
});
|
|
2791
|
+
}
|
|
2460
2792
|
if (!choice.effort || !isEffortRejection(error)) {
|
|
2461
2793
|
// The remote's own 4xx is passed on, anything else is a 502, as
|
|
2462
2794
|
// mapRemoteError answers them.
|
|
@@ -2488,6 +2820,8 @@ export function createRemoteRelayDispatcher({
|
|
|
2488
2820
|
note: 'Use send with this session and an effort it takes (or none); do not create another session.',
|
|
2489
2821
|
});
|
|
2490
2822
|
}
|
|
2823
|
+
const imageNote = imageEchoNote(ctx, sent, args.images);
|
|
2824
|
+
const effortFields = effortResult(choice);
|
|
2491
2825
|
const result = await deliverAndWait('create_session', ctx, {
|
|
2492
2826
|
session,
|
|
2493
2827
|
sent,
|
|
@@ -2501,7 +2835,8 @@ export function createRemoteRelayDispatcher({
|
|
|
2501
2835
|
...(cloud ? { repo: toText(bootstrap?.cloud?.repoUrl) || args.repo, branch: toText(bootstrap?.cloud?.branch) || args.branch || null } : {}),
|
|
2502
2836
|
...(toText(bootstrap?.workspaceRootWarning) ? { cwdWarning: toText(bootstrap.workspaceRootWarning) } : {}),
|
|
2503
2837
|
...(note ? { modelNote: note } : {}),
|
|
2504
|
-
...
|
|
2838
|
+
...effortFields,
|
|
2839
|
+
...(imageNote ? { note: [effortFields.note, imageNote].filter(Boolean).join(' ') } : {}),
|
|
2505
2840
|
// The session group it went into, as list_sessions shows it.
|
|
2506
2841
|
group,
|
|
2507
2842
|
...(groupWarning ? { groupWarning } : {}),
|
|
@@ -2824,6 +3159,21 @@ export function createRemoteRelayDispatcher({
|
|
|
2824
3159
|
};
|
|
2825
3160
|
}
|
|
2826
3161
|
|
|
3162
|
+
/**
|
|
3163
|
+
* The changed settings for the summary line. Names only, but the image
|
|
3164
|
+
* defaults (an object) with what they were set to:
|
|
3165
|
+
* `image {size 1536x1024, quality low}`.
|
|
3166
|
+
*/
|
|
3167
|
+
function changedText(changed, settings) {
|
|
3168
|
+
return changed.map((key) => {
|
|
3169
|
+
if (key !== 'image' || !isPlainObject(settings?.image)) return key;
|
|
3170
|
+
const parts = Object.entries(settings.image)
|
|
3171
|
+
.filter(([, value]) => toText(value))
|
|
3172
|
+
.map(([name, value]) => `${name} ${clipLine(value, 40)}`);
|
|
3173
|
+
return parts.length ? `image {${parts.join(', ')}}` : key;
|
|
3174
|
+
}).join(', ');
|
|
3175
|
+
}
|
|
3176
|
+
|
|
2827
3177
|
/** The `settings` of an answer as the agent reads it: the target's, the group in list_sessions' shape. */
|
|
2828
3178
|
function settingsResult(settings) {
|
|
2829
3179
|
if (!isPlainObject(settings)) return null;
|
|
@@ -2851,7 +3201,7 @@ export function createRemoteRelayDispatcher({
|
|
|
2851
3201
|
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.');
|
|
2852
3202
|
}
|
|
2853
3203
|
return {
|
|
2854
|
-
summary: `update_session → ${where}: ${changed
|
|
3204
|
+
summary: `update_session → ${where}: ${changedText(changed, plan.settings) || 'nothing changed'}`,
|
|
2855
3205
|
session: plan.session,
|
|
2856
3206
|
settings: settingsResult(answer?.settings),
|
|
2857
3207
|
changed,
|
|
@@ -3777,6 +4127,24 @@ export function createRemoteRelayDispatcher({
|
|
|
3777
4127
|
return null;
|
|
3778
4128
|
}
|
|
3779
4129
|
|
|
4130
|
+
/**
|
|
4131
|
+
* Is `session` on `relay` the session whose agent created this conversation
|
|
4132
|
+
* (its origin, which an agent of this relay never sets for a paired one)?
|
|
4133
|
+
*/
|
|
4134
|
+
function sendsToCreator(conversationId, relay, session) {
|
|
4135
|
+
if (relay.local === true || !toText(relay.relayId) || !toText(session)) return false;
|
|
4136
|
+
let origin = null;
|
|
4137
|
+
try {
|
|
4138
|
+
origin = repository.getConversationOrigin?.(conversationId) || null;
|
|
4139
|
+
} catch {
|
|
4140
|
+
return false;
|
|
4141
|
+
}
|
|
4142
|
+
return !!origin && origin.local !== true
|
|
4143
|
+
&& toText(origin.relayId) === toText(relay.relayId)
|
|
4144
|
+
&& !!toText(origin.conversationId)
|
|
4145
|
+
&& toText(origin.conversationId) === toText(session);
|
|
4146
|
+
}
|
|
4147
|
+
|
|
3780
4148
|
async function runRelayAction({ conversationId, action, args, req, signal, waitNote }) {
|
|
3781
4149
|
const target = await resolveTarget(args.relay);
|
|
3782
4150
|
if (!target.relay) return target.failure;
|
|
@@ -3839,7 +4207,11 @@ export function createRemoteRelayDispatcher({
|
|
|
3839
4207
|
const origins = Array.isArray(caller.originRelayIds) ? caller.originRelayIds.map(toText) : [];
|
|
3840
4208
|
const answering = !!toText(relay.relayId) && origins.length > 0
|
|
3841
4209
|
&& origins.every((id) => id === toText(relay.relayId));
|
|
3842
|
-
|
|
4210
|
+
// A session another relay's agent created may always send to the session
|
|
4211
|
+
// that created it, whatever else its turn acts on (a prompt of a session
|
|
4212
|
+
// on this relay, say): its reports go back the way its work came.
|
|
4213
|
+
if (write && access !== 'user' && !answering && hops > REMOTE_RELAY_LIMITS.hopLimit
|
|
4214
|
+
&& !(action === 'send' && sendsToCreator(conversationId, relay, args.session))) {
|
|
3843
4215
|
return finish(failure(403, REMOTE_RELAY_ERROR_CODES.hopLimit,
|
|
3844
4216
|
`This turn acts on a prompt from another relay's agent (hop ${hops - 1}); passing it on to relay "${relay.name}" would pass the limit of ${REMOTE_RELAY_LIMITS.hopLimit} hops. The user can allow it by mentioning @${relay.name} in this conversation.`,
|
|
3845
4217
|
{ relay: relay.name }));
|
|
@@ -3861,6 +4233,20 @@ export function createRemoteRelayDispatcher({
|
|
|
3861
4233
|
const problem = createSessionProblem(caller, args);
|
|
3862
4234
|
if (problem) return finish(failure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, problem, { relay: relay.name }));
|
|
3863
4235
|
}
|
|
4236
|
+
// Images toward a paired relay that cannot take them: refused before
|
|
4237
|
+
// anyone is asked and before anything is created or sent. This relay
|
|
4238
|
+
// itself always takes them.
|
|
4239
|
+
if (!local && IMAGE_INPUT_ACTIONS.has(action) && (args.images || args.image)) {
|
|
4240
|
+
try {
|
|
4241
|
+
await refuseWithoutImageInputs({ relay, caller, hops, conversationId, signal }, action);
|
|
4242
|
+
} catch (error) {
|
|
4243
|
+
if (error instanceof DispatchFailure) {
|
|
4244
|
+
return finish(failure(error.status, error.code, error.message, { relay: relay.name, ...error.extra }));
|
|
4245
|
+
}
|
|
4246
|
+
if (isRemoteRelayError(error)) return finish(mapRemoteError(error, relay, { action, args }));
|
|
4247
|
+
throw error;
|
|
4248
|
+
}
|
|
4249
|
+
}
|
|
3864
4250
|
// The group a create_session names, looked up on the target before anyone
|
|
3865
4251
|
// is asked, like the model: the card names it, and an unknown group (or a
|
|
3866
4252
|
// target whose groups cannot be read) refuses the call, nothing created.
|