@oar-sh/oar 0.9.5 → 0.9.6
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 +12 -32
- package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
- package/CHANGELOG.md +168 -0
- package/README.md +20 -4
- package/package.json +2 -1
- package/server/claude-worker/claude-session-process.mjs +118 -1
- package/server/claude-worker/claude-turn-failure.mjs +92 -0
- package/server/claude-worker/claude-turn-publisher.mjs +52 -3
- package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
- package/server/copilot-worker/copilot-model-switch.mjs +29 -3
- package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
- package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
- package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
- package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
- package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
- package/server/db-schema.mjs +7 -0
- package/server/grok-worker/acp-client.mjs +68 -12
- package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
- package/server/grok-worker/grok-turn-runner.mjs +6 -2
- package/server/public/app/api-client.js +17 -2
- package/server/public/app/bootstrap.js +3 -0
- package/server/public/app/composer-control-state.mjs +4 -3
- package/server/public/app/conversation-view.js +95 -4
- package/server/public/app/journal-view.js +6 -0
- package/server/public/app/markdown-strikethrough.mjs +31 -0
- package/server/public/app/relay-board-inline.mjs +70 -0
- package/server/public/app/relay-board-view.js +157 -61
- package/server/public/app/relay-error-ctas.mjs +12 -3
- package/server/public/app/socket-handlers.js +9 -0
- package/server/public/app/store.js +4 -1
- package/server/public/app/subagent-fold.mjs +61 -0
- package/server/public/app/usage-limit-ui.js +225 -0
- package/server/public/index.html +90 -1
- package/server/repositories/message-repository.mjs +10 -1
- package/server/repositories/question-repository.mjs +1 -0
- package/server/repositories/remote-relay-repository.mjs +30 -0
- package/server/routes/messages-routes.mjs +85 -14
- package/server/routes/relay-board-routes.mjs +56 -2
- package/server/routes/sessions-routes.mjs +36 -0
- package/server/server-runtime.mjs +33 -2
- package/server/services/preview-proxy-server.mjs +64 -36
- package/server/services/process-list-service.mjs +122 -0
- package/server/services/relay-console-log.mjs +199 -0
- package/server/services/remote-relay-caller-context.mjs +37 -17
- package/server/services/remote-relay-dispatcher.mjs +72 -8
- package/server/services/remote-relay-inbound.mjs +35 -0
- package/server/services/session-worker-process-service.mjs +85 -32
- package/server/services/session-worker-registry-service.mjs +1 -1
- package/server/services/session-worker-supervisor-service.mjs +33 -1
- package/server/services/usage-limit-pause-service.mjs +441 -0
- package/shared/ask-user-bridge.mjs +14 -39
- package/shared/claude-usage-limit.mjs +257 -0
- package/shared/control-poller.mjs +69 -20
- package/shared/failure-note-text.mjs +38 -0
- package/shared/question-wait.mjs +108 -0
- package/shared/remote-relay-contract.mjs +16 -2
- package/shared/steer-settle-failure.mjs +16 -0
- package/shared/steer-settle-markers.mjs +8 -0
- package/shared/worker-runtime/process-tree.mjs +483 -0
- package/shared/worker-runtime/relay-errors.mjs +31 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
- package/shared/worker-runtime/turn-liveness.mjs +249 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The process table, read the way each platform allows, for the tree watch in
|
|
4
|
+
// shared/worker-runtime/process-tree.mjs. Every entry carries the time the
|
|
5
|
+
// process was started: that is what tells it from a later process that was
|
|
6
|
+
// given its pid.
|
|
7
|
+
//
|
|
8
|
+
// - Linux reads `/proc/<pid>/stat`, which needs no process and takes about
|
|
9
|
+
// 3 ms for 160 processes (measured 2026-09-29). The start time is field 22,
|
|
10
|
+
// in clock ticks since boot.
|
|
11
|
+
// - Windows asks PowerShell, through the list the relay's process inspector
|
|
12
|
+
// already reads. It takes about a second, so it is never polled.
|
|
13
|
+
// - Every other platform asks `ps`, whose start time has a resolution of one
|
|
14
|
+
// second.
|
|
15
|
+
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import { execFile } from 'child_process';
|
|
18
|
+
|
|
19
|
+
import { createSessionWorkerProcessInspector } from './session-worker-process-service.mjs';
|
|
20
|
+
|
|
21
|
+
const PS_TIMEOUT_MS = 10_000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One `/proc/<pid>/stat`. The name (field 2) is in parentheses and may hold
|
|
25
|
+
* spaces and parentheses of its own, so the fields after it are counted from
|
|
26
|
+
* the LAST closing one. A process that has ended and only waits to be
|
|
27
|
+
* collected (`Z`, `X`) is not running any more.
|
|
28
|
+
*/
|
|
29
|
+
export function parseProcStat(text) {
|
|
30
|
+
const raw = String(text || '');
|
|
31
|
+
const open = raw.indexOf('(');
|
|
32
|
+
const close = raw.lastIndexOf(')');
|
|
33
|
+
if (open < 1 || close < open) return null;
|
|
34
|
+
const processId = Number.parseInt(raw.slice(0, open), 10);
|
|
35
|
+
const fields = raw.slice(close + 2).trim().split(/\s+/);
|
|
36
|
+
const state = fields[0] || '';
|
|
37
|
+
const createdAt = Number(fields[19]);
|
|
38
|
+
if (!(processId > 0) || state === 'Z' || state === 'X' || !(createdAt > 0)) return null;
|
|
39
|
+
return {
|
|
40
|
+
processId,
|
|
41
|
+
parentProcessId: Number.parseInt(fields[1], 10) || 0,
|
|
42
|
+
name: raw.slice(open + 1, close),
|
|
43
|
+
createdAt,
|
|
44
|
+
// The runtime starts a command in a session of its own.
|
|
45
|
+
sessionLeader: Number.parseInt(fields[3], 10) === processId,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One line of `ps -o pid=,ppid=,stat=,lstart=,args=`. */
|
|
50
|
+
export function parsePsLine(line) {
|
|
51
|
+
const match = String(line || '').match(
|
|
52
|
+
/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\w{3}\s+\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s*(.*)$/,
|
|
53
|
+
);
|
|
54
|
+
if (!match) return null;
|
|
55
|
+
const createdAt = Date.parse(match[4]);
|
|
56
|
+
if (/^[ZX]/.test(match[3]) || !(createdAt > 0)) return null;
|
|
57
|
+
return {
|
|
58
|
+
processId: Number.parseInt(match[1], 10),
|
|
59
|
+
parentProcessId: Number.parseInt(match[2], 10) || 0,
|
|
60
|
+
createdAt,
|
|
61
|
+
commandLine: match[5],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createProcessLister({
|
|
66
|
+
platform = process.platform,
|
|
67
|
+
readdirSyncImpl = fs.readdirSync,
|
|
68
|
+
readFileSyncImpl = fs.readFileSync,
|
|
69
|
+
execFileImpl = execFile,
|
|
70
|
+
inspector = null,
|
|
71
|
+
} = {}) {
|
|
72
|
+
if (platform === 'win32') {
|
|
73
|
+
const windows = inspector || createSessionWorkerProcessInspector({ platform, execFileImpl });
|
|
74
|
+
return {
|
|
75
|
+
// A child keeps the pid of its parent after the parent has ended.
|
|
76
|
+
orphansKeepParent: true,
|
|
77
|
+
list: () => windows.readWindowsProcessSnapshot(),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (platform === 'linux') {
|
|
82
|
+
const read = (pid) => {
|
|
83
|
+
try {
|
|
84
|
+
return parseProcStat(readFileSyncImpl(`/proc/${pid}/stat`, 'utf8'));
|
|
85
|
+
} catch {
|
|
86
|
+
// It ended between the directory listing and the read.
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
orphansKeepParent: false,
|
|
92
|
+
list: () => readdirSyncImpl('/proc')
|
|
93
|
+
.filter((name) => /^\d+$/.test(String(name)))
|
|
94
|
+
.map((name) => read(name))
|
|
95
|
+
.filter(Boolean),
|
|
96
|
+
read,
|
|
97
|
+
describe(pid) {
|
|
98
|
+
try {
|
|
99
|
+
return String(readFileSyncImpl(`/proc/${pid}/cmdline`, 'utf8')).replace(/\0/g, ' ').trim();
|
|
100
|
+
} catch {
|
|
101
|
+
return '';
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
orphansKeepParent: false,
|
|
109
|
+
list: () => new Promise((resolve, reject) => {
|
|
110
|
+
execFileImpl(
|
|
111
|
+
'ps',
|
|
112
|
+
['-axww', '-o', 'pid=,ppid=,stat=,lstart=,args='],
|
|
113
|
+
// `lstart` is parsed as a date: keep it in the one language that is.
|
|
114
|
+
{ timeout: PS_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, LC_ALL: 'C' } },
|
|
115
|
+
(error, stdout) => {
|
|
116
|
+
if (error) reject(error);
|
|
117
|
+
else resolve(String(stdout || '').split(/\r?\n/).map(parsePsLine).filter(Boolean));
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The relay's console, kept in a file.
|
|
4
|
+
//
|
|
5
|
+
// The relay writes what it does to its console: a terminal, a tmux pane, a
|
|
6
|
+
// service manager's journal or nothing at all, depending on how it was
|
|
7
|
+
// started. After an incident or a restart that output is what one wants to
|
|
8
|
+
// read, so the runtime copies it into `<log dir>/relay-console.log`, with a
|
|
9
|
+
// time stamp per line and a size limit.
|
|
10
|
+
//
|
|
11
|
+
// The copy is taken from the console's methods rather than from stdout: the
|
|
12
|
+
// terminal console (tty-console) redraws its prompt on stdout, and none of
|
|
13
|
+
// that belongs in a log.
|
|
14
|
+
|
|
15
|
+
import fs from 'fs';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import { format } from 'util';
|
|
18
|
+
|
|
19
|
+
export const RELAY_CONSOLE_LOG_FILE = 'relay-console.log';
|
|
20
|
+
export const RELAY_CONSOLE_LOG_DISABLE_ENV = 'OAR_NO_CONSOLE_LOG';
|
|
21
|
+
|
|
22
|
+
const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
|
|
23
|
+
const DEFAULT_KEEP = 3;
|
|
24
|
+
const LEVELS = Object.freeze(['log', 'info', 'warn', 'error', 'debug']);
|
|
25
|
+
const ANSI_STYLE_PATTERN = /\u001b\[[0-9;]*m/g;
|
|
26
|
+
// A token in a URL, whatever its value: the page is opened with `?token=`.
|
|
27
|
+
const TOKEN_PARAM_PATTERN = /([?&]token=)[^&\s"'<>]+/gi;
|
|
28
|
+
const SECRET_MIN_LENGTH = 6;
|
|
29
|
+
|
|
30
|
+
function escapeRegExp(value) {
|
|
31
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Where the console log goes: the log directory the environment names, else
|
|
36
|
+
* `logs` under a data directory the environment names (a relay with its own
|
|
37
|
+
* data keeps its own log), else `logs` next to the server.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveRelayConsoleLogPath({ env = process.env, serverDir, pathImpl = path } = {}) {
|
|
40
|
+
const logDir = String(env?.COPILOT_WEB_RELAY_LOG_DIR || '').trim();
|
|
41
|
+
const dataDir = String(env?.COPILOT_WEB_RELAY_DATA_DIR || '').trim();
|
|
42
|
+
const dir = logDir
|
|
43
|
+
|| (dataDir ? pathImpl.join(dataDir, 'logs') : pathImpl.join(String(serverDir || process.cwd()), 'logs'));
|
|
44
|
+
return pathImpl.join(pathImpl.resolve(dir), RELAY_CONSOLE_LOG_FILE);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Copies what the relay writes to its console into `logPath`. Returns null
|
|
49
|
+
* when it is switched off or the file cannot be opened: a log problem must
|
|
50
|
+
* never stop the relay.
|
|
51
|
+
*
|
|
52
|
+
* `{ logPath, setSecrets(values), rewrap(), write(level, text), uninstall() }`:
|
|
53
|
+
* `setSecrets` names values that are never written (the auth token, once the
|
|
54
|
+
* config is read), or a function that returns them, asked at every write so
|
|
55
|
+
* a token that changes is covered; `rewrap` puts the copy in front again
|
|
56
|
+
* after something else replaced the console's methods.
|
|
57
|
+
*/
|
|
58
|
+
export function installRelayConsoleLog({
|
|
59
|
+
logPath,
|
|
60
|
+
env = process.env,
|
|
61
|
+
consoleImpl = console,
|
|
62
|
+
processImpl = process,
|
|
63
|
+
fsImpl = fs,
|
|
64
|
+
now = () => new Date(),
|
|
65
|
+
maxBytes = DEFAULT_MAX_BYTES,
|
|
66
|
+
keep = DEFAULT_KEEP,
|
|
67
|
+
} = {}) {
|
|
68
|
+
if (String(env?.[RELAY_CONSOLE_LOG_DISABLE_ENV] || '').trim() === '1') return null;
|
|
69
|
+
const target = String(logPath || '').trim();
|
|
70
|
+
if (!target) return null;
|
|
71
|
+
|
|
72
|
+
let fd = null;
|
|
73
|
+
let size = 0;
|
|
74
|
+
let secretSource = () => [];
|
|
75
|
+
let secretKey = '';
|
|
76
|
+
let secretPattern = null;
|
|
77
|
+
|
|
78
|
+
function open() {
|
|
79
|
+
fsImpl.mkdirSync(path.dirname(target), { recursive: true });
|
|
80
|
+
fd = fsImpl.openSync(target, 'a');
|
|
81
|
+
try {
|
|
82
|
+
size = Number(fsImpl.fstatSync(fd).size) || 0;
|
|
83
|
+
} catch {
|
|
84
|
+
size = 0;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function close() {
|
|
89
|
+
if (fd === null) return;
|
|
90
|
+
try { fsImpl.closeSync(fd); } catch {}
|
|
91
|
+
fd = null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// relay-console.log → .1 → .2 …; the oldest of `keep` is dropped.
|
|
95
|
+
function rotate() {
|
|
96
|
+
close();
|
|
97
|
+
try {
|
|
98
|
+
for (let index = keep; index >= 1; index -= 1) {
|
|
99
|
+
const from = index === 1 ? target : `${target}.${index - 1}`;
|
|
100
|
+
const to = `${target}.${index}`;
|
|
101
|
+
if (!fsImpl.existsSync(from)) continue;
|
|
102
|
+
try { fsImpl.rmSync(to, { force: true }); } catch {}
|
|
103
|
+
fsImpl.renameSync(from, to);
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// A file that cannot be moved (held open elsewhere) is written on.
|
|
107
|
+
}
|
|
108
|
+
open();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function currentSecretPattern() {
|
|
112
|
+
let values = [];
|
|
113
|
+
try {
|
|
114
|
+
values = secretSource() || [];
|
|
115
|
+
} catch {}
|
|
116
|
+
const secrets = (Array.isArray(values) ? values : [values])
|
|
117
|
+
.map((value) => String(value ?? '').trim())
|
|
118
|
+
.filter((value) => value.length >= SECRET_MIN_LENGTH);
|
|
119
|
+
const key = secrets.join('\n');
|
|
120
|
+
if (key !== secretKey) {
|
|
121
|
+
secretKey = key;
|
|
122
|
+
secretPattern = secrets.length ? new RegExp(secrets.map(escapeRegExp).join('|'), 'g') : null;
|
|
123
|
+
}
|
|
124
|
+
return secretPattern;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function scrub(text) {
|
|
128
|
+
const out = String(text).replace(ANSI_STYLE_PATTERN, '').replace(TOKEN_PARAM_PATTERN, '$1[token]');
|
|
129
|
+
const secrets = currentSecretPattern();
|
|
130
|
+
return secrets ? out.replace(secrets, '[token]') : out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Synchronous on purpose: the last lines before an exit are the ones an
|
|
134
|
+
// incident needs.
|
|
135
|
+
function write(level, text) {
|
|
136
|
+
if (fd === null) return;
|
|
137
|
+
try {
|
|
138
|
+
const stamp = now().toISOString();
|
|
139
|
+
const tag = level === 'log' ? '' : `${String(level).toUpperCase()} `;
|
|
140
|
+
const lines = scrub(text).split(/\r?\n/).map((line) => `${stamp} ${tag}${line}\n`).join('');
|
|
141
|
+
if (size > 0 && size + Buffer.byteLength(lines) > maxBytes) rotate();
|
|
142
|
+
if (fd === null) return;
|
|
143
|
+
size += fsImpl.writeSync(fd, lines);
|
|
144
|
+
} catch {
|
|
145
|
+
// A file that stopped taking writes (disk full, handle gone) is given up.
|
|
146
|
+
close();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
open();
|
|
152
|
+
if (size > maxBytes) rotate();
|
|
153
|
+
} catch {
|
|
154
|
+
close();
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const wrapped = new Map();
|
|
159
|
+
function wrap() {
|
|
160
|
+
for (const level of LEVELS) {
|
|
161
|
+
const current = consoleImpl[level];
|
|
162
|
+
if (typeof current !== 'function' || current.relayConsoleLog === true) continue;
|
|
163
|
+
const copy = function relayConsoleCopy(...args) {
|
|
164
|
+
try {
|
|
165
|
+
write(level, format(...args));
|
|
166
|
+
} catch {}
|
|
167
|
+
return current.apply(this, args);
|
|
168
|
+
};
|
|
169
|
+
copy.relayConsoleLog = true;
|
|
170
|
+
copy.inner = current;
|
|
171
|
+
consoleImpl[level] = copy;
|
|
172
|
+
wrapped.set(level, copy);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
wrap();
|
|
176
|
+
|
|
177
|
+
// Without a handler Node prints an uncaught error natively, past the console.
|
|
178
|
+
const onUncaught = (error, origin) => {
|
|
179
|
+
write('error', `${origin || 'uncaughtException'}: ${error?.stack || error?.message || error}`);
|
|
180
|
+
};
|
|
181
|
+
processImpl.on?.('uncaughtExceptionMonitor', onUncaught);
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
logPath: target,
|
|
185
|
+
write,
|
|
186
|
+
rewrap: wrap,
|
|
187
|
+
setSecrets(values = []) {
|
|
188
|
+
secretSource = typeof values === 'function' ? values : () => values;
|
|
189
|
+
},
|
|
190
|
+
uninstall() {
|
|
191
|
+
for (const [level, copy] of wrapped) {
|
|
192
|
+
if (consoleImpl[level] === copy) consoleImpl[level] = copy.inner;
|
|
193
|
+
}
|
|
194
|
+
wrapped.clear();
|
|
195
|
+
processImpl.off?.('uncaughtExceptionMonitor', onUncaught);
|
|
196
|
+
close();
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
@@ -120,11 +120,16 @@ function describeRemoteTurn(action, args, caller) {
|
|
|
120
120
|
* run, then the prompt (or the answer) it wants to send, so the user decides
|
|
121
121
|
* on the actual words. `caller` is the calling session's context.
|
|
122
122
|
*/
|
|
123
|
-
export function formatRemoteRelayApprovalPrompt({ relay, action, args = {}, caller = null } = {}) {
|
|
123
|
+
export function formatRemoteRelayApprovalPrompt({ relay, action, args = {}, caller = null, agentPrompts = 0 } = {}) {
|
|
124
124
|
const name = toText(relay?.name) || 'a remote relay';
|
|
125
125
|
const verb = APPROVAL_VERBS[action] || toText(action).replace(/_/g, ' ') || 'act';
|
|
126
126
|
const target = args.session ? ` (session ${shortId(args.session)})` : '';
|
|
127
|
-
const lines = [
|
|
127
|
+
const lines = [];
|
|
128
|
+
// The loop guard's card: agents that prompt each other without the user.
|
|
129
|
+
if (agentPrompts > 0) {
|
|
130
|
+
lines.push(`The agent has sent ${agentPrompts} prompts to agents on other relays since your last message. Let it go on?`);
|
|
131
|
+
}
|
|
132
|
+
lines.push(`Allow the agent to ${verb} on relay "${name}"${target}?`);
|
|
128
133
|
if (action === 'send' || action === 'create_session') {
|
|
129
134
|
const details = describeRemoteTurn(action, args, caller);
|
|
130
135
|
if (details) lines.push(details);
|
|
@@ -198,29 +203,34 @@ export function createRemoteRelayCallerContext({
|
|
|
198
203
|
return rows.find((row) => row.id === liveId) || rows[0];
|
|
199
204
|
}
|
|
200
205
|
|
|
201
|
-
function
|
|
202
|
-
|
|
206
|
+
function messageOrigin(messageId) {
|
|
207
|
+
return (messageId ? repository?.getMessageOrigin?.(messageId) : null) || null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function originHops(origin) {
|
|
203
211
|
const hops = Number(origin?.hops);
|
|
204
212
|
return Number.isInteger(hops) && hops >= 0 ? hops : 0;
|
|
205
213
|
}
|
|
206
214
|
|
|
207
215
|
/**
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
* turn whose background work it
|
|
216
|
+
* Where a turn's prompt came from (null for a human's): the origin of its
|
|
217
|
+
* own user message, or for a continuation row (no message of its own) that
|
|
218
|
+
* of the latest user message before it, the turn whose background work it
|
|
219
|
+
* continues.
|
|
211
220
|
*/
|
|
212
|
-
function
|
|
213
|
-
if (toText(row.kind).toLowerCase() !== 'continuation') return
|
|
221
|
+
function rowOrigin(conversationId, row) {
|
|
222
|
+
if (toText(row.kind).toLowerCase() !== 'continuation') return messageOrigin(row.id);
|
|
214
223
|
const at = toText(row.timestamp) || toText(row.processing_at);
|
|
215
224
|
const message = at
|
|
216
225
|
? getLatestUserMessageAtOrBefore.get(conversationId, at)
|
|
217
226
|
: getLatestUserMessage.get(conversationId);
|
|
218
|
-
return
|
|
227
|
+
return messageOrigin(message?.id);
|
|
219
228
|
}
|
|
220
229
|
|
|
221
230
|
/**
|
|
222
231
|
* `{ conversationId, title, provider, model, effort, mode, processingRowId,
|
|
223
|
-
* attemptId, userMessageId, hops }` for the conversation
|
|
232
|
+
* attemptId, userMessageId, hops, originRelayIds }` for the conversation
|
|
233
|
+
* making the call.
|
|
224
234
|
* `effort` is the reasoning effort of the live turn, else the one the
|
|
225
235
|
* conversation prefers ('' when neither is known).
|
|
226
236
|
* `hops` is how many relays the current turn's prompt already crossed: 0
|
|
@@ -229,6 +239,8 @@ export function createRemoteRelayCallerContext({
|
|
|
229
239
|
* conversation's processing rows wins (a background continuation counts as
|
|
230
240
|
* the turn it continues); without a turn (an off-turn call) the latest user
|
|
231
241
|
* message decides.
|
|
242
|
+
* `originRelayIds` are the relays (their instance ids) those prompts came
|
|
243
|
+
* from: empty when the agent acts on the user's words alone.
|
|
232
244
|
*/
|
|
233
245
|
function getCallerContext(conversationId) {
|
|
234
246
|
const id = toText(conversationId);
|
|
@@ -236,9 +248,11 @@ export function createRemoteRelayCallerContext({
|
|
|
236
248
|
const runtime = getRuntimeSession.get(id) || null;
|
|
237
249
|
const rows = listProcessingRows.all(id);
|
|
238
250
|
const live = liveTurnRow(id, rows);
|
|
239
|
-
const
|
|
240
|
-
?
|
|
241
|
-
:
|
|
251
|
+
const origins = (rows.length
|
|
252
|
+
? rows.map((row) => rowOrigin(id, row))
|
|
253
|
+
: [messageOrigin(getLatestUserMessage.get(id)?.id)]).filter(Boolean);
|
|
254
|
+
const hops = Math.max(0, ...origins.map(originHops));
|
|
255
|
+
const originRelayIds = [...new Set(origins.map((origin) => toText(origin.relayId)))];
|
|
242
256
|
return {
|
|
243
257
|
conversationId: id,
|
|
244
258
|
title: toText(conversation?.title),
|
|
@@ -250,6 +264,7 @@ export function createRemoteRelayCallerContext({
|
|
|
250
264
|
attemptId: toText(live?.attempt_id) || null,
|
|
251
265
|
userMessageId: live?.id || null,
|
|
252
266
|
hops,
|
|
267
|
+
originRelayIds,
|
|
253
268
|
};
|
|
254
269
|
}
|
|
255
270
|
|
|
@@ -270,8 +285,11 @@ export function createRemoteRelayCallerContext({
|
|
|
270
285
|
* denied, timed out, cancelled with the turn, or no turn to ask on (fails
|
|
271
286
|
* closed with REMOTE_RELAY_NO_ACTIVE_TURN). Waits as long as the question
|
|
272
287
|
* lives (8 h by default); `signal` withdraws the card early.
|
|
288
|
+
* `agentPrompts` (the loop guard) is how many prompts the agent has sent to
|
|
289
|
+
* other relays' agents since the user last wrote; the card then asks for
|
|
290
|
+
* that, in any relay mode.
|
|
273
291
|
*/
|
|
274
|
-
async function requestApproval({ conversationId, callerContext, relay, action, args = {}, signal } = {}) {
|
|
292
|
+
async function requestApproval({ conversationId, callerContext, relay, action, args = {}, signal, agentPrompts = 0 } = {}) {
|
|
275
293
|
const rowId = toText(callerContext?.processingRowId);
|
|
276
294
|
const row = rowId ? getQueueRow.get(rowId) : null;
|
|
277
295
|
if (!row || row.status !== 'processing') {
|
|
@@ -285,10 +303,12 @@ export function createRemoteRelayCallerContext({
|
|
|
285
303
|
const createdAt = now().toISOString();
|
|
286
304
|
const questionId = String(uuid());
|
|
287
305
|
const relayMode = normalizeMode(row.relay_mode);
|
|
288
|
-
const prompt = formatRemoteRelayApprovalPrompt({ relay, action, args, caller: callerContext });
|
|
306
|
+
const prompt = formatRemoteRelayApprovalPrompt({ relay, action, args, caller: callerContext, agentPrompts });
|
|
289
307
|
const context = sanitizeRelayQuestionContext({
|
|
290
308
|
source: REMOTE_RELAY_APPROVAL_SOURCE,
|
|
291
|
-
rationale:
|
|
309
|
+
rationale: agentPrompts > 0
|
|
310
|
+
? 'Agents on different relays that keep prompting each other need your go from time to time.'
|
|
311
|
+
: `Write actions on other relays need approval in ${relayMode} mode.`,
|
|
292
312
|
queueMessageId: row.id,
|
|
293
313
|
conversationId: toText(conversationId) || row.conversation_id,
|
|
294
314
|
relayMode,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { randomUUID as nodeRandomUUID } from 'crypto';
|
|
4
4
|
|
|
5
|
+
import { isFailureNoteText } from '../../shared/failure-note-text.mjs';
|
|
5
6
|
import {
|
|
6
7
|
REMOTE_RELAY_APPROVAL_MODES,
|
|
7
8
|
REMOTE_RELAY_EFFORT_PATTERN,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
REMOTE_RELAY_LIMITS,
|
|
10
11
|
REMOTE_RELAY_PROVIDERS,
|
|
11
12
|
isRemoteRelayApprovalQuestion,
|
|
13
|
+
isRemoteRelayPromptAction,
|
|
12
14
|
isRemoteRelayWriteAction,
|
|
13
15
|
normalizeRemoteRelayOrigin,
|
|
14
16
|
remoteConversationUrl,
|
|
@@ -90,7 +92,6 @@ const WORKING_WORKER_STATUSES = new Set(['starting', 'processing']);
|
|
|
90
92
|
// marker counts only there: a normal reply that quotes a relay error further
|
|
91
93
|
// down is a reply, not a failure.
|
|
92
94
|
const FAILURE_TEXT_PATTERNS = Object.freeze([
|
|
93
|
-
/^(?:(?!\n[ \t]*\r?\n)[\s\S]){0,600}?\berror code:\s*relay\.[a-z0-9-]+\.(?=\s|$)/i,
|
|
94
95
|
/^relay recovery limit reached after \d+ attempts/i,
|
|
95
96
|
/^relay timeout after \d+ attempts/i,
|
|
96
97
|
]);
|
|
@@ -184,7 +185,7 @@ function compactQuery(query = {}) {
|
|
|
184
185
|
|
|
185
186
|
function looksLikeFailureText(text) {
|
|
186
187
|
const value = toText(text);
|
|
187
|
-
return FAILURE_TEXT_PATTERNS.some((pattern) => pattern.test(value));
|
|
188
|
+
return isFailureNoteText(value) || FAILURE_TEXT_PATTERNS.some((pattern) => pattern.test(value));
|
|
188
189
|
}
|
|
189
190
|
|
|
190
191
|
function normalizeProvider(value) {
|
|
@@ -534,6 +535,10 @@ export function createRemoteRelayDispatcher({
|
|
|
534
535
|
const inflightByConversation = new Map();
|
|
535
536
|
const callTimesByConversation = new Map();
|
|
536
537
|
const writeTimesByConversation = new Map();
|
|
538
|
+
// The loop guard: prompts sent to other relays' agents per conversation,
|
|
539
|
+
// counted from the user's latest own message. Kept in memory: a restart
|
|
540
|
+
// ends a loop of agents by itself.
|
|
541
|
+
const agentPromptsByConversation = new Map();
|
|
537
542
|
|
|
538
543
|
// ─── Bookkeeping ───────────────────────────────────────────────────────────
|
|
539
544
|
|
|
@@ -556,6 +561,31 @@ export function createRemoteRelayDispatcher({
|
|
|
556
561
|
}
|
|
557
562
|
}
|
|
558
563
|
|
|
564
|
+
/**
|
|
565
|
+
* Why a relay is open to a conversation: 'user' (the user mentioned it),
|
|
566
|
+
* 'contact' (an agent on that relay wrote to the conversation, so it may be
|
|
567
|
+
* answered), or null (locked).
|
|
568
|
+
*/
|
|
569
|
+
function relayAccess(conversationId, relay) {
|
|
570
|
+
if (isUnlocked(conversationId, relay?.id)) return 'user';
|
|
571
|
+
try {
|
|
572
|
+
if (toText(relay?.relayId) && repository?.hasContactFrom?.(conversationId, relay.relayId)) return 'contact';
|
|
573
|
+
} catch {}
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function agentPrompts(conversationId) {
|
|
578
|
+
let since = null;
|
|
579
|
+
try {
|
|
580
|
+
since = repository?.latestHumanMessageId?.(conversationId) || null;
|
|
581
|
+
} catch {}
|
|
582
|
+
const kept = agentPromptsByConversation.get(conversationId);
|
|
583
|
+
if (kept && kept.since === since) return kept;
|
|
584
|
+
const fresh = { since, count: 0 };
|
|
585
|
+
agentPromptsByConversation.set(conversationId, fresh);
|
|
586
|
+
return fresh;
|
|
587
|
+
}
|
|
588
|
+
|
|
559
589
|
function adjustInflight(conversationId, delta) {
|
|
560
590
|
const next = inflight(conversationId) + delta;
|
|
561
591
|
if (next > 0) inflightByConversation.set(conversationId, next);
|
|
@@ -1594,7 +1624,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1594
1624
|
online: toText(relay.lastStatus).toLowerCase() === 'online',
|
|
1595
1625
|
version: relay.version || null,
|
|
1596
1626
|
permission: relay.permission,
|
|
1597
|
-
unlocked:
|
|
1627
|
+
unlocked: relayAccess(conversationId, relay) !== null,
|
|
1598
1628
|
}));
|
|
1599
1629
|
const unlocked = listed.filter((relay) => relay.unlocked).length;
|
|
1600
1630
|
return {
|
|
@@ -1604,7 +1634,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1604
1634
|
summary: `list_relays: ${listed.length} relay${listed.length === 1 ? '' : 's'}, ${unlocked} unlocked`,
|
|
1605
1635
|
self: { name: (await registry.selfName?.()) || null },
|
|
1606
1636
|
relays: listed,
|
|
1607
|
-
hint: '
|
|
1637
|
+
hint: 'The user unlocks a relay by mentioning @name in the chat; a relay whose agent wrote to this conversation is open as well.',
|
|
1608
1638
|
},
|
|
1609
1639
|
};
|
|
1610
1640
|
}
|
|
@@ -1635,7 +1665,8 @@ export function createRemoteRelayDispatcher({
|
|
|
1635
1665
|
return result;
|
|
1636
1666
|
};
|
|
1637
1667
|
|
|
1638
|
-
|
|
1668
|
+
const access = relayAccess(conversationId, relay);
|
|
1669
|
+
if (!access) {
|
|
1639
1670
|
return finish(failure(403, REMOTE_RELAY_ERROR_CODES.locked,
|
|
1640
1671
|
`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.`,
|
|
1641
1672
|
{ relay: relay.name }));
|
|
@@ -1649,9 +1680,16 @@ export function createRemoteRelayDispatcher({
|
|
|
1649
1680
|
const caller = { conversationId, ...((await getCallerContext(conversationId, req)) || {}) };
|
|
1650
1681
|
caller.mode = toText(caller.mode).toLowerCase();
|
|
1651
1682
|
const hops = nonNegativeInt(caller.hops) + 1;
|
|
1652
|
-
|
|
1683
|
+
// The hop limit stops a prompt from travelling on to a third relay. A
|
|
1684
|
+
// relay the user unlocked here stays in reach whatever prompts arrive,
|
|
1685
|
+
// the relay the prompts came from may always be answered, and reading
|
|
1686
|
+
// carries nothing on.
|
|
1687
|
+
const origins = Array.isArray(caller.originRelayIds) ? caller.originRelayIds.map(toText) : [];
|
|
1688
|
+
const answering = !!toText(relay.relayId) && origins.length > 0
|
|
1689
|
+
&& origins.every((id) => id === toText(relay.relayId));
|
|
1690
|
+
if (write && access !== 'user' && !answering && hops > REMOTE_RELAY_LIMITS.hopLimit) {
|
|
1653
1691
|
return finish(failure(403, REMOTE_RELAY_ERROR_CODES.hopLimit,
|
|
1654
|
-
`This turn
|
|
1692
|
+
`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.`,
|
|
1655
1693
|
{ relay: relay.name }));
|
|
1656
1694
|
}
|
|
1657
1695
|
|
|
@@ -1664,7 +1702,32 @@ export function createRemoteRelayDispatcher({
|
|
|
1664
1702
|
|
|
1665
1703
|
adjustInflight(conversationId, 1);
|
|
1666
1704
|
try {
|
|
1667
|
-
|
|
1705
|
+
const prompting = isRemoteRelayPromptAction(action);
|
|
1706
|
+
let asked = false;
|
|
1707
|
+
if (prompting && agentPrompts(conversationId).count >= REMOTE_RELAY_LIMITS.agentPromptsBeforeAsking) {
|
|
1708
|
+
// Agents that prompt each other can go on without the user for good:
|
|
1709
|
+
// from time to time the user is asked, in any relay mode.
|
|
1710
|
+
const decision = await requestApproval({
|
|
1711
|
+
conversationId,
|
|
1712
|
+
callerContext: caller,
|
|
1713
|
+
relay: relayTarget(relay),
|
|
1714
|
+
action,
|
|
1715
|
+
args,
|
|
1716
|
+
summary: summarizeRemoteRelayCall({ action, ...args, relay: relay.name }),
|
|
1717
|
+
signal,
|
|
1718
|
+
agentPrompts: agentPrompts(conversationId).count,
|
|
1719
|
+
});
|
|
1720
|
+
const approved = decision === true || decision?.approved === true;
|
|
1721
|
+
if (!approved) {
|
|
1722
|
+
const code = toText(decision?.code) || REMOTE_RELAY_ERROR_CODES.approvalDenied;
|
|
1723
|
+
const error = toText(decision?.error)
|
|
1724
|
+
|| `The agent has sent ${agentPrompts(conversationId).count} prompts to other relays' agents since the user last wrote, and the user did not allow more for now.`;
|
|
1725
|
+
return finish(failure(403, code, error, { relay: relay.name }));
|
|
1726
|
+
}
|
|
1727
|
+
agentPrompts(conversationId).count = 0;
|
|
1728
|
+
asked = true;
|
|
1729
|
+
}
|
|
1730
|
+
if (!asked && write && REMOTE_RELAY_APPROVAL_MODES.includes(caller.mode)) {
|
|
1668
1731
|
const decision = await requestApproval({
|
|
1669
1732
|
conversationId,
|
|
1670
1733
|
callerContext: caller,
|
|
@@ -1685,6 +1748,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1685
1748
|
|
|
1686
1749
|
const ctx = { relay, caller, hops, conversationId, signal };
|
|
1687
1750
|
const body = await ACTIONS[action](ctx, args);
|
|
1751
|
+
if (prompting) agentPrompts(conversationId).count += 1;
|
|
1688
1752
|
const { summary, ...rest } = body;
|
|
1689
1753
|
return finish({
|
|
1690
1754
|
status: 200,
|
|
@@ -161,6 +161,41 @@ export function formatRemoteRelayMentionHint(relays = []) {
|
|
|
161
161
|
return `<system_reminder>The user mentioned ${subject}. The ${REMOTE_RELAY_TOOL_NAME} tool can list, read, prompt and create sessions there.</system_reminder>`;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* The paired relay a remote prompt came from: `{ relayId, name }` (`relayId`
|
|
166
|
+
* is the local registry id), or null when the sender is not paired here or
|
|
167
|
+
* names no relay id.
|
|
168
|
+
*/
|
|
169
|
+
export function findOriginRemoteRelay(inbound, origin) {
|
|
170
|
+
const instance = String(origin?.relayId || '').trim();
|
|
171
|
+
if (!inbound || typeof inbound.listRelays !== 'function' || !instance) return null;
|
|
172
|
+
try {
|
|
173
|
+
const listed = inbound.listRelays();
|
|
174
|
+
const relay = (Array.isArray(listed) ? listed : [])
|
|
175
|
+
.find((entry) => String(entry?.relayId || '').trim() === instance);
|
|
176
|
+
return relay ? { relayId: String(relay.id || ''), name: String(relay.name || relay.id || '') } : null;
|
|
177
|
+
} catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The reminder the agent gets with a prompt another relay's agent sent: that
|
|
184
|
+
* relay is open to this conversation, and where the sender's session is. ''
|
|
185
|
+
* when the sender is not a paired relay. Like the mention hint, it rides in
|
|
186
|
+
* the queued prompt only.
|
|
187
|
+
*/
|
|
188
|
+
export function formatRemoteRelayOriginHint(relay, origin) {
|
|
189
|
+
const name = hintText(relay?.name);
|
|
190
|
+
if (!name) return '';
|
|
191
|
+
const session = hintText(origin?.conversationId, 80);
|
|
192
|
+
const where = session ? `relay "${name}", session "${session}"` : `relay "${name}"`;
|
|
193
|
+
return `<system_reminder>This prompt came from an agent on the paired OAR relay "${name}". `
|
|
194
|
+
+ 'Your reply to it is handed back to that agent by itself. '
|
|
195
|
+
+ `For more than the reply (reading its session, a question, a later report) the ${REMOTE_RELAY_TOOL_NAME} tool is open for that relay in this conversation: ${where}. `
|
|
196
|
+
+ 'A prompt you send there while its turn is still running is steered into that turn.</system_reminder>';
|
|
197
|
+
}
|
|
198
|
+
|
|
164
199
|
/** The queued prompt with the mention hint after the user's text. */
|
|
165
200
|
export function withRemoteRelayMentionHint(promptText, hint) {
|
|
166
201
|
const text = String(promptText ?? '');
|