negotium 0.19.2 → 0.20.0
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/dist/agent-helpers.js +1146 -212
- package/dist/agent-helpers.js.map +27 -19
- package/dist/background-bash.js.map +1 -1
- package/dist/browser-runtime.js.map +1 -1
- package/dist/{chunk-kkftbxh2.js → chunk-7he9nzfa.js} +1 -1
- package/dist/{chunk-55dd0xw7.js → chunk-ad6xqvqy.js} +2 -2
- package/dist/{chunk-55dd0xw7.js.map → chunk-ad6xqvqy.js.map} +2 -2
- package/dist/{chunk-exqz8bez.js → chunk-hh3f0yr7.js} +3 -3
- package/dist/{chunk-exqz8bez.js.map → chunk-hh3f0yr7.js.map} +2 -2
- package/dist/{chunk-aqqmzmft.js → chunk-nm59q3em.js} +1 -1
- package/dist/{chunk-cv7vdsrn.js → chunk-w3c6fc0p.js} +286 -9
- package/dist/{chunk-cv7vdsrn.js.map → chunk-w3c6fc0p.js.map} +10 -7
- package/dist/hosted-agent.js +290 -12
- package/dist/hosted-agent.js.map +10 -7
- package/dist/main.js +4599 -2724
- package/dist/main.js.map +49 -38
- package/dist/mcp-factories.js +1166 -162
- package/dist/mcp-factories.js.map +27 -19
- package/dist/media.js.map +1 -1
- package/dist/prompts.js.map +1 -1
- package/dist/query-runtime.js.map +3 -3
- package/dist/registry.js +3 -3
- package/dist/rollout.js +2 -2
- package/dist/runtime/src/agents/explicit-agent-switch.ts +101 -0
- package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +126 -43
- package/dist/runtime/src/agents/public-helpers.ts +5 -0
- package/dist/runtime/src/agents/self-config-core.ts +28 -58
- package/dist/runtime/src/application/submit-runtime-gateway-turn.ts +34 -8
- package/dist/runtime/src/index.ts +75 -0
- package/dist/runtime/src/mcp/peer-bridge.ts +6 -1
- package/dist/runtime/src/mcp/runtime-spec.ts +145 -10
- package/dist/runtime/src/mcp/session-comm/actor-policy.ts +87 -0
- package/dist/runtime/src/mcp/session-comm/context.ts +42 -1
- package/dist/runtime/src/mcp/session-comm/default-host.ts +178 -2
- package/dist/runtime/src/mcp/session-comm/hub-remote-session.ts +553 -0
- package/dist/runtime/src/mcp/session-comm/limits.ts +8 -0
- package/dist/runtime/src/mcp/session-comm/peer-forward.ts +78 -3
- package/dist/runtime/src/mcp/session-comm/server.ts +167 -4
- package/dist/runtime/src/mcp/session-comm/topic-catalog.ts +7 -0
- package/dist/runtime/src/mcp/session-comm/topics.ts +32 -10
- package/dist/runtime/src/mcp-runtime-host.ts +2 -1
- package/dist/runtime/src/node-host.ts +26 -0
- package/dist/runtime/src/platform/mcp-config.ts +56 -2
- package/dist/runtime/src/query/active-rooms.ts +6 -0
- package/dist/runtime/src/runtime/actor-topic-reach.ts +85 -0
- package/dist/runtime/src/runtime/actor-topic-scope.ts +175 -0
- package/dist/runtime/src/runtime/ask-callbacks.ts +73 -20
- package/dist/runtime/src/runtime/gateway-capabilities.ts +22 -0
- package/dist/runtime/src/runtime/remote-session-grant.ts +221 -0
- package/dist/runtime/src/runtime/remote-session-inbox.ts +476 -0
- package/dist/runtime/src/runtime/remote-session-reply-outbox.ts +156 -0
- package/dist/runtime/src/runtime/turn-runner.ts +157 -33
- package/dist/runtime/src/runtime-gateway.ts +20 -0
- package/dist/runtime/src/storage/api-topics.ts +22 -3
- package/dist/runtime/src/storage/remote-session.ts +526 -0
- package/dist/runtime/src/storage/runtime-turn-requests.ts +75 -2
- package/dist/runtime/src/topics/derive.ts +3 -0
- package/dist/runtime/src/types/api.ts +6 -0
- package/dist/runtime/src/types.ts +42 -0
- package/dist/runtime/src/version.ts +1 -1
- package/dist/runtime-gateway.js.map +2 -2
- package/dist/runtime-helpers.js.map +1 -1
- package/dist/storage.js +16 -4
- package/dist/storage.js.map +4 -4
- package/dist/types/packages/core/src/agents/explicit-agent-switch.d.ts +26 -0
- package/dist/types/packages/core/src/agents/mcp-tools/spawn-subagent.d.ts +12 -2
- package/dist/types/packages/core/src/agents/public-helpers.d.ts +1 -0
- package/dist/types/packages/core/src/agents/self-config-core.d.ts +15 -1
- package/dist/types/packages/core/src/mcp/peer-bridge.d.ts +6 -1
- package/dist/types/packages/core/src/mcp/runtime-spec.d.ts +69 -2
- package/dist/types/packages/core/src/mcp/session-comm/context.d.ts +17 -1
- package/dist/types/packages/core/src/mcp/session-comm/hub-remote-session.d.ts +149 -0
- package/dist/types/packages/core/src/mcp/session-comm/limits.d.ts +8 -0
- package/dist/types/packages/core/src/mcp/session-comm/peer-forward.d.ts +23 -2
- package/dist/types/packages/core/src/mcp/session-comm/topic-catalog.d.ts +6 -0
- package/dist/types/packages/core/src/platform/mcp-config.d.ts +10 -1
- package/dist/types/packages/core/src/query/active-rooms.d.ts +6 -0
- package/dist/types/packages/core/src/runtime/actor-topic-reach.d.ts +49 -0
- package/dist/types/packages/core/src/runtime/actor-topic-scope.d.ts +91 -0
- package/dist/types/packages/core/src/runtime/ask-callbacks.d.ts +8 -2
- package/dist/types/packages/core/src/runtime/remote-session-grant.d.ts +91 -0
- package/dist/types/packages/core/src/runtime/remote-session-inbox.d.ts +83 -0
- package/dist/types/packages/core/src/runtime/remote-session-reply-outbox.d.ts +37 -0
- package/dist/types/packages/core/src/runtime/turn-runner.d.ts +22 -3
- package/dist/types/packages/core/src/runtime-gateway.d.ts +20 -0
- package/dist/types/packages/core/src/storage/api-topics.d.ts +3 -0
- package/dist/types/packages/core/src/storage/remote-session.d.ts +135 -0
- package/dist/types/packages/core/src/storage/runtime-turn-requests.d.ts +12 -1
- package/dist/types/packages/core/src/topics/derive.d.ts +2 -0
- package/dist/types/packages/core/src/types/api.d.ts +6 -0
- package/dist/types/packages/core/src/types.d.ts +40 -0
- package/dist/types/packages/core/src/version.d.ts +1 -1
- package/dist/vault.js.map +1 -1
- package/package.json +1 -1
- /package/dist/{chunk-kkftbxh2.js.map → chunk-7he9nzfa.js.map} +0 -0
- /package/dist/{chunk-aqqmzmft.js.map → chunk-nm59q3em.js.map} +0 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable state for hub-routed remote session-comm (the `node/topic` branch
|
|
3
|
+
* of `tell_session` / `ask_session` / `abort_session` on the `otium` surface).
|
|
4
|
+
*
|
|
5
|
+
* Three small tables, all keyed by the hub's per-call `requestId`:
|
|
6
|
+
*
|
|
7
|
+
* - `remote_session_inbox_claims` — idempotency for what the hub delivers into
|
|
8
|
+
* this node (`POST /topics/:id/session-comm/inbox`). A claim is a small state
|
|
9
|
+
* machine: `processing` (held under a lease while the delivery is being
|
|
10
|
+
* handed off) → `completed`. Only a `completed` claim answers a retried
|
|
11
|
+
* delivery with a replay; a duplicate that meets a `processing` claim is told
|
|
12
|
+
* to come back (`in_progress`), and a `processing` claim whose lease expired
|
|
13
|
+
* (the process died mid-delivery) is taken over and re-run. The same id with
|
|
14
|
+
* a different payload is a conflict.
|
|
15
|
+
* - `remote_session_asks` — the caller side of a remote `ask_session`: which
|
|
16
|
+
* room asked, so the hub's `ask-reply` delivery can be routed back into it
|
|
17
|
+
* after this process restarted.
|
|
18
|
+
* - `remote_session_reply_outbox` — the target side of a remote ask: the
|
|
19
|
+
* answer waiting to be posted to the hub with its one-shot reply token,
|
|
20
|
+
* retried until the hub acknowledges it or the pending-ask TTL passes.
|
|
21
|
+
*/
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import { db } from "#storage/forum-db";
|
|
24
|
+
import { PENDING_ASK_TTL_MS } from "#storage/session-asks";
|
|
25
|
+
import { registerStorageSchemaInitializer } from "#storage/storage-host";
|
|
26
|
+
|
|
27
|
+
registerStorageSchemaInitializer((database) => {
|
|
28
|
+
database.exec(`
|
|
29
|
+
CREATE TABLE IF NOT EXISTS remote_session_inbox_claims (
|
|
30
|
+
request_id TEXT PRIMARY KEY,
|
|
31
|
+
kind TEXT NOT NULL,
|
|
32
|
+
topic_id TEXT NOT NULL,
|
|
33
|
+
payload_hash TEXT NOT NULL,
|
|
34
|
+
created_at INTEGER NOT NULL
|
|
35
|
+
);
|
|
36
|
+
CREATE INDEX IF NOT EXISTS idx_remote_session_inbox_claims_created
|
|
37
|
+
ON remote_session_inbox_claims(created_at);
|
|
38
|
+
CREATE TABLE IF NOT EXISTS remote_session_asks (
|
|
39
|
+
request_id TEXT PRIMARY KEY,
|
|
40
|
+
caller_topic_id TEXT NOT NULL,
|
|
41
|
+
user_id TEXT NOT NULL,
|
|
42
|
+
from_key TEXT NOT NULL,
|
|
43
|
+
to_key TEXT NOT NULL,
|
|
44
|
+
caller_thread_root_id TEXT,
|
|
45
|
+
created_at INTEGER NOT NULL
|
|
46
|
+
);
|
|
47
|
+
CREATE INDEX IF NOT EXISTS idx_remote_session_asks_caller
|
|
48
|
+
ON remote_session_asks(caller_topic_id);
|
|
49
|
+
CREATE TABLE IF NOT EXISTS remote_session_reply_outbox (
|
|
50
|
+
request_id TEXT PRIMARY KEY,
|
|
51
|
+
hub_url TEXT NOT NULL,
|
|
52
|
+
token TEXT NOT NULL,
|
|
53
|
+
kind TEXT NOT NULL CHECK (kind IN ('reply', 'error')),
|
|
54
|
+
reply_text TEXT NOT NULL,
|
|
55
|
+
from_label TEXT NOT NULL,
|
|
56
|
+
created_at INTEGER NOT NULL,
|
|
57
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
58
|
+
next_attempt_at INTEGER NOT NULL DEFAULT 0,
|
|
59
|
+
last_error TEXT
|
|
60
|
+
);
|
|
61
|
+
`);
|
|
62
|
+
// Claim state machine (added after the table existed, so guarded): a claim
|
|
63
|
+
// is `processing` under a lease until its delivery is durably recorded, then
|
|
64
|
+
// `completed`. `payload_json` keeps what a `processing` ask-reply must
|
|
65
|
+
// deliver so an expired lease can be re-run after a restart.
|
|
66
|
+
for (const ddl of [
|
|
67
|
+
"ALTER TABLE remote_session_inbox_claims ADD COLUMN state TEXT NOT NULL DEFAULT 'completed'",
|
|
68
|
+
"ALTER TABLE remote_session_inbox_claims ADD COLUMN lease_until INTEGER NOT NULL DEFAULT 0",
|
|
69
|
+
"ALTER TABLE remote_session_inbox_claims ADD COLUMN payload_json TEXT",
|
|
70
|
+
]) {
|
|
71
|
+
try {
|
|
72
|
+
database.exec(ddl);
|
|
73
|
+
} catch {
|
|
74
|
+
// Column already present.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}, 36);
|
|
78
|
+
|
|
79
|
+
/** Claims older than this are forgotten; the hub never retries a call this late. */
|
|
80
|
+
export const REMOTE_SESSION_INBOX_CLAIM_TTL_MS = 24 * 60 * 60 * 1000;
|
|
81
|
+
/** A reply the hub has not acknowledged by then is dropped: the caller's pending ask has timed out. */
|
|
82
|
+
export const REMOTE_SESSION_REPLY_OUTBOX_TTL_MS = PENDING_ASK_TTL_MS;
|
|
83
|
+
/** Poll cadence of the reply outbox worker; individual rows back off (see {@link remoteSessionRetryDelayMs}). */
|
|
84
|
+
export const REMOTE_SESSION_REPLY_RETRY_MS = 5_000;
|
|
85
|
+
/** Backoff ladder for a reply the hub did not accept: 5, 10, 20, 30 s (cap), with jitter. */
|
|
86
|
+
export const REMOTE_SESSION_REPLY_BACKOFF_BASE_MS = 5_000;
|
|
87
|
+
export const REMOTE_SESSION_REPLY_BACKOFF_MAX_MS = 30_000;
|
|
88
|
+
/**
|
|
89
|
+
* How long a `processing` claim is held before another delivery (a hub retry
|
|
90
|
+
* or the maintenance pass) may take it over. Longer than any single delivery
|
|
91
|
+
* attempt, so a live process is never raced by its own retry.
|
|
92
|
+
*/
|
|
93
|
+
export const REMOTE_SESSION_INBOX_CLAIM_LEASE_MS = 60_000;
|
|
94
|
+
|
|
95
|
+
export type RemoteSessionInboxKind = "tell" | "ask" | "abort" | "ask-reply";
|
|
96
|
+
export type RemoteSessionInboxClaimState = "processing" | "completed";
|
|
97
|
+
/**
|
|
98
|
+
* `claimed`: this caller now holds the `processing` lease (fresh, or taken
|
|
99
|
+
* over from an expired one). `replay`: a `completed` claim with the same
|
|
100
|
+
* payload. `in_progress`: another delivery holds a live lease. `conflict`:
|
|
101
|
+
* the id is bound to a different payload/kind/room.
|
|
102
|
+
*/
|
|
103
|
+
export type RemoteSessionInboxClaimOutcome = "claimed" | "replay" | "in_progress" | "conflict";
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Delay before retry number `attempts` (1-based: the first retry after the
|
|
107
|
+
* immediate attempt is `attempts = 1`): 5, 10, 20, 30 s and 30 s thereafter,
|
|
108
|
+
* each with ±20 % jitter from `random` (injectable for deterministic tests).
|
|
109
|
+
*/
|
|
110
|
+
export function remoteSessionRetryDelayMs(
|
|
111
|
+
attempts: number,
|
|
112
|
+
random: () => number = Math.random,
|
|
113
|
+
): number {
|
|
114
|
+
const step = Math.max(0, Math.floor(attempts) - 1);
|
|
115
|
+
const base = Math.min(
|
|
116
|
+
REMOTE_SESSION_REPLY_BACKOFF_BASE_MS * 2 ** step,
|
|
117
|
+
REMOTE_SESSION_REPLY_BACKOFF_MAX_MS,
|
|
118
|
+
);
|
|
119
|
+
const jitter = 0.8 + 0.4 * Math.min(1, Math.max(0, random()));
|
|
120
|
+
return Math.round(base * jitter);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Stable digest of a delivery body, so a retried delivery is recognised as one. */
|
|
124
|
+
export function remoteSessionPayloadHash(payload: unknown): string {
|
|
125
|
+
return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface InboxClaimRow {
|
|
129
|
+
kind: string;
|
|
130
|
+
topic_id: string;
|
|
131
|
+
payload_hash: string;
|
|
132
|
+
state: RemoteSessionInboxClaimState;
|
|
133
|
+
lease_until: number | bigint;
|
|
134
|
+
payload_json: string | null;
|
|
135
|
+
created_at: number | bigint;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface RemoteSessionInboxClaimRecord {
|
|
139
|
+
requestId: string;
|
|
140
|
+
kind: RemoteSessionInboxKind;
|
|
141
|
+
topicId: string;
|
|
142
|
+
payloadHash: string;
|
|
143
|
+
state: RemoteSessionInboxClaimState;
|
|
144
|
+
leaseUntil: number;
|
|
145
|
+
payload: unknown;
|
|
146
|
+
createdAt: number;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Claim one hub delivery as `processing` under a lease. For tell/ask/abort
|
|
151
|
+
* the caller completes it in the same transaction as the enqueue; for an
|
|
152
|
+
* ask-reply it stays `processing` — with `payload` persisted — until the
|
|
153
|
+
* answer is durably in the caller's room, or is released so the hub's retry
|
|
154
|
+
* runs the delivery again.
|
|
155
|
+
*/
|
|
156
|
+
export function claimRemoteSessionInbox(args: {
|
|
157
|
+
requestId: string;
|
|
158
|
+
kind: RemoteSessionInboxKind;
|
|
159
|
+
topicId: string;
|
|
160
|
+
payloadHash: string;
|
|
161
|
+
/** Persisted while `processing` so an interrupted delivery can be re-run. */
|
|
162
|
+
payload?: unknown;
|
|
163
|
+
now?: number;
|
|
164
|
+
leaseMs?: number;
|
|
165
|
+
/** Take over even a live lease (a fresh process knows nobody holds one). */
|
|
166
|
+
force?: boolean;
|
|
167
|
+
}): RemoteSessionInboxClaimOutcome {
|
|
168
|
+
const now = args.now ?? Date.now();
|
|
169
|
+
const leaseUntil = now + (args.leaseMs ?? REMOTE_SESSION_INBOX_CLAIM_LEASE_MS);
|
|
170
|
+
const payloadJson = args.payload === undefined ? null : JSON.stringify(args.payload);
|
|
171
|
+
return db.transaction((): RemoteSessionInboxClaimOutcome => {
|
|
172
|
+
const inserted = db
|
|
173
|
+
.query(
|
|
174
|
+
`INSERT OR IGNORE INTO remote_session_inbox_claims
|
|
175
|
+
(request_id, kind, topic_id, payload_hash, created_at, state, lease_until, payload_json)
|
|
176
|
+
VALUES (?, ?, ?, ?, ?, 'processing', ?, ?)`,
|
|
177
|
+
)
|
|
178
|
+
.run(args.requestId, args.kind, args.topicId, args.payloadHash, now, leaseUntil, payloadJson);
|
|
179
|
+
if (Number(inserted.changes ?? 0) === 1) return "claimed";
|
|
180
|
+
const existing = db
|
|
181
|
+
.query<InboxClaimRow, [string]>(
|
|
182
|
+
"SELECT * FROM remote_session_inbox_claims WHERE request_id = ?",
|
|
183
|
+
)
|
|
184
|
+
.get(args.requestId);
|
|
185
|
+
if (
|
|
186
|
+
!existing ||
|
|
187
|
+
existing.kind !== args.kind ||
|
|
188
|
+
existing.topic_id !== args.topicId ||
|
|
189
|
+
existing.payload_hash !== args.payloadHash
|
|
190
|
+
) {
|
|
191
|
+
return "conflict";
|
|
192
|
+
}
|
|
193
|
+
if (existing.state === "completed") return "replay";
|
|
194
|
+
if (!args.force && Number(existing.lease_until) > now) return "in_progress";
|
|
195
|
+
// The previous holder died (or hung past its lease): take the claim over.
|
|
196
|
+
db.query(
|
|
197
|
+
`UPDATE remote_session_inbox_claims
|
|
198
|
+
SET lease_until = ?, payload_json = COALESCE(?, payload_json)
|
|
199
|
+
WHERE request_id = ?`,
|
|
200
|
+
).run(leaseUntil, payloadJson, args.requestId);
|
|
201
|
+
return "claimed";
|
|
202
|
+
})();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The delivery is durably recorded: replays may now be acknowledged. */
|
|
206
|
+
export function completeRemoteSessionInboxClaim(
|
|
207
|
+
requestId: string,
|
|
208
|
+
now: number = Date.now(),
|
|
209
|
+
): boolean {
|
|
210
|
+
const result = db
|
|
211
|
+
.query(
|
|
212
|
+
`UPDATE remote_session_inbox_claims
|
|
213
|
+
SET state = 'completed', lease_until = ?, payload_json = NULL
|
|
214
|
+
WHERE request_id = ? AND state = 'processing'`,
|
|
215
|
+
)
|
|
216
|
+
.run(now, requestId);
|
|
217
|
+
return Number(result.changes ?? 0) === 1;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function getRemoteSessionInboxClaim(
|
|
221
|
+
requestId: string,
|
|
222
|
+
): RemoteSessionInboxClaimRecord | null {
|
|
223
|
+
const row = db
|
|
224
|
+
.query<InboxClaimRow & { request_id: string }, [string]>(
|
|
225
|
+
"SELECT * FROM remote_session_inbox_claims WHERE request_id = ?",
|
|
226
|
+
)
|
|
227
|
+
.get(requestId);
|
|
228
|
+
return row ? toClaimRecord(row) : null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function toClaimRecord(row: InboxClaimRow & { request_id: string }): RemoteSessionInboxClaimRecord {
|
|
232
|
+
let payload: unknown = null;
|
|
233
|
+
if (row.payload_json) {
|
|
234
|
+
try {
|
|
235
|
+
payload = JSON.parse(row.payload_json);
|
|
236
|
+
} catch {
|
|
237
|
+
payload = null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
requestId: row.request_id,
|
|
242
|
+
kind: row.kind as RemoteSessionInboxKind,
|
|
243
|
+
topicId: row.topic_id,
|
|
244
|
+
payloadHash: row.payload_hash,
|
|
245
|
+
state: row.state,
|
|
246
|
+
leaseUntil: Number(row.lease_until),
|
|
247
|
+
payload,
|
|
248
|
+
createdAt: Number(row.created_at),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** `processing` claims whose lease ran out: deliveries a dead process left behind. */
|
|
253
|
+
export function listExpiredRemoteSessionInboxClaims(
|
|
254
|
+
now: number = Date.now(),
|
|
255
|
+
options: { includeLive?: boolean } = {},
|
|
256
|
+
): RemoteSessionInboxClaimRecord[] {
|
|
257
|
+
return db
|
|
258
|
+
.query<InboxClaimRow & { request_id: string }, [number]>(
|
|
259
|
+
`SELECT * FROM remote_session_inbox_claims
|
|
260
|
+
WHERE state = 'processing' AND lease_until <= ?
|
|
261
|
+
ORDER BY created_at ASC`,
|
|
262
|
+
)
|
|
263
|
+
.all(options.includeLive ? Number.MAX_SAFE_INTEGER : now)
|
|
264
|
+
.map(toClaimRecord);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function releaseRemoteSessionInboxClaim(requestId: string): boolean {
|
|
268
|
+
const result = db
|
|
269
|
+
.query("DELETE FROM remote_session_inbox_claims WHERE request_id = ?")
|
|
270
|
+
.run(requestId);
|
|
271
|
+
return Number(result.changes ?? 0) === 1;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function purgeRemoteSessionInboxClaims(now: number = Date.now()): number {
|
|
275
|
+
const result = db
|
|
276
|
+
.query("DELETE FROM remote_session_inbox_claims WHERE created_at < ?")
|
|
277
|
+
.run(now - REMOTE_SESSION_INBOX_CLAIM_TTL_MS);
|
|
278
|
+
return Number(result.changes ?? 0);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface RemoteSessionAskRecord {
|
|
282
|
+
requestId: string;
|
|
283
|
+
callerTopicId: string;
|
|
284
|
+
userId: string;
|
|
285
|
+
fromKey: string;
|
|
286
|
+
toKey: string;
|
|
287
|
+
callerThreadRootId?: string;
|
|
288
|
+
createdAt: number;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
interface RemoteSessionAskRow {
|
|
292
|
+
request_id: string;
|
|
293
|
+
caller_topic_id: string;
|
|
294
|
+
user_id: string;
|
|
295
|
+
from_key: string;
|
|
296
|
+
to_key: string;
|
|
297
|
+
caller_thread_root_id: string | null;
|
|
298
|
+
created_at: number | bigint;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function toAskRecord(row: RemoteSessionAskRow): RemoteSessionAskRecord {
|
|
302
|
+
return {
|
|
303
|
+
requestId: row.request_id,
|
|
304
|
+
callerTopicId: row.caller_topic_id,
|
|
305
|
+
userId: row.user_id,
|
|
306
|
+
fromKey: row.from_key,
|
|
307
|
+
toKey: row.to_key,
|
|
308
|
+
...(row.caller_thread_root_id ? { callerThreadRootId: row.caller_thread_root_id } : {}),
|
|
309
|
+
createdAt: Number(row.created_at),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Remember an outbound remote ask before the hub is asked to forward it. */
|
|
314
|
+
export function recordRemoteSessionAsk(
|
|
315
|
+
args: Omit<RemoteSessionAskRecord, "createdAt"> & { createdAt?: number },
|
|
316
|
+
): void {
|
|
317
|
+
db.query(
|
|
318
|
+
`INSERT INTO remote_session_asks
|
|
319
|
+
(request_id, caller_topic_id, user_id, from_key, to_key, caller_thread_root_id, created_at)
|
|
320
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
321
|
+
ON CONFLICT(request_id) DO UPDATE SET
|
|
322
|
+
caller_topic_id = excluded.caller_topic_id,
|
|
323
|
+
user_id = excluded.user_id,
|
|
324
|
+
from_key = excluded.from_key,
|
|
325
|
+
to_key = excluded.to_key,
|
|
326
|
+
caller_thread_root_id = excluded.caller_thread_root_id,
|
|
327
|
+
created_at = excluded.created_at`,
|
|
328
|
+
).run(
|
|
329
|
+
args.requestId,
|
|
330
|
+
args.callerTopicId,
|
|
331
|
+
args.userId,
|
|
332
|
+
args.fromKey,
|
|
333
|
+
args.toKey,
|
|
334
|
+
args.callerThreadRootId ?? null,
|
|
335
|
+
args.createdAt ?? Date.now(),
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function getRemoteSessionAsk(requestId: string): RemoteSessionAskRecord | null {
|
|
340
|
+
const row = db
|
|
341
|
+
.query<RemoteSessionAskRow, [string]>("SELECT * FROM remote_session_asks WHERE request_id = ?")
|
|
342
|
+
.get(requestId);
|
|
343
|
+
return row ? toAskRecord(row) : null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function deleteRemoteSessionAsk(requestId: string): boolean {
|
|
347
|
+
const result = db.query("DELETE FROM remote_session_asks WHERE request_id = ?").run(requestId);
|
|
348
|
+
return Number(result.changes ?? 0) === 1;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Consume the caller record exactly once: the row is gone after this returns it. */
|
|
352
|
+
export function takeRemoteSessionAsk(requestId: string): RemoteSessionAskRecord | null {
|
|
353
|
+
return db.transaction(() => {
|
|
354
|
+
const record = getRemoteSessionAsk(requestId);
|
|
355
|
+
if (record) deleteRemoteSessionAsk(requestId);
|
|
356
|
+
return record;
|
|
357
|
+
})();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function deleteRemoteSessionAsksForTopic(callerTopicId: string): number {
|
|
361
|
+
const result = db
|
|
362
|
+
.query("DELETE FROM remote_session_asks WHERE caller_topic_id = ?")
|
|
363
|
+
.run(callerTopicId);
|
|
364
|
+
return Number(result.changes ?? 0);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function purgeStaleRemoteSessionAsks(now: number = Date.now()): number {
|
|
368
|
+
const result = db
|
|
369
|
+
.query("DELETE FROM remote_session_asks WHERE created_at < ?")
|
|
370
|
+
.run(now - PENDING_ASK_TTL_MS);
|
|
371
|
+
return Number(result.changes ?? 0);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export interface RemoteSessionReplyOutboxEntry {
|
|
375
|
+
requestId: string;
|
|
376
|
+
hubUrl: string;
|
|
377
|
+
token: string;
|
|
378
|
+
kind: "reply" | "error";
|
|
379
|
+
replyText: string;
|
|
380
|
+
fromLabel: string;
|
|
381
|
+
createdAt: number;
|
|
382
|
+
attempts: number;
|
|
383
|
+
nextAttemptAt: number;
|
|
384
|
+
lastError?: string;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
interface RemoteSessionReplyOutboxRow {
|
|
388
|
+
request_id: string;
|
|
389
|
+
hub_url: string;
|
|
390
|
+
token: string;
|
|
391
|
+
kind: "reply" | "error";
|
|
392
|
+
reply_text: string;
|
|
393
|
+
from_label: string;
|
|
394
|
+
created_at: number | bigint;
|
|
395
|
+
attempts: number | bigint;
|
|
396
|
+
next_attempt_at: number | bigint;
|
|
397
|
+
last_error: string | null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function toOutboxEntry(row: RemoteSessionReplyOutboxRow): RemoteSessionReplyOutboxEntry {
|
|
401
|
+
return {
|
|
402
|
+
requestId: row.request_id,
|
|
403
|
+
hubUrl: row.hub_url,
|
|
404
|
+
token: row.token,
|
|
405
|
+
kind: row.kind,
|
|
406
|
+
replyText: row.reply_text,
|
|
407
|
+
fromLabel: row.from_label,
|
|
408
|
+
createdAt: Number(row.created_at),
|
|
409
|
+
attempts: Number(row.attempts),
|
|
410
|
+
nextAttemptAt: Number(row.next_attempt_at),
|
|
411
|
+
...(row.last_error ? { lastError: row.last_error } : {}),
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Queue an answer for the hub. One row per ask: a second answer for the same
|
|
417
|
+
* request (a retry after a partial failure) replaces the first, which is what
|
|
418
|
+
* the hub's one-shot reply token would enforce anyway.
|
|
419
|
+
*/
|
|
420
|
+
export function upsertRemoteSessionReplyOutbox(args: {
|
|
421
|
+
requestId: string;
|
|
422
|
+
hubUrl: string;
|
|
423
|
+
token: string;
|
|
424
|
+
kind: "reply" | "error";
|
|
425
|
+
replyText: string;
|
|
426
|
+
fromLabel: string;
|
|
427
|
+
createdAt?: number;
|
|
428
|
+
}): void {
|
|
429
|
+
db.query(
|
|
430
|
+
`INSERT INTO remote_session_reply_outbox
|
|
431
|
+
(request_id, hub_url, token, kind, reply_text, from_label, created_at, attempts, next_attempt_at, last_error)
|
|
432
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, NULL)
|
|
433
|
+
ON CONFLICT(request_id) DO UPDATE SET
|
|
434
|
+
hub_url = excluded.hub_url,
|
|
435
|
+
token = excluded.token,
|
|
436
|
+
kind = excluded.kind,
|
|
437
|
+
reply_text = excluded.reply_text,
|
|
438
|
+
from_label = excluded.from_label,
|
|
439
|
+
attempts = 0,
|
|
440
|
+
next_attempt_at = 0,
|
|
441
|
+
last_error = NULL`,
|
|
442
|
+
).run(
|
|
443
|
+
args.requestId,
|
|
444
|
+
args.hubUrl,
|
|
445
|
+
args.token,
|
|
446
|
+
args.kind,
|
|
447
|
+
args.replyText,
|
|
448
|
+
args.fromLabel,
|
|
449
|
+
args.createdAt ?? Date.now(),
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export function listRemoteSessionReplyOutbox(
|
|
454
|
+
args: { now?: number; limit?: number; due?: boolean } = {},
|
|
455
|
+
): RemoteSessionReplyOutboxEntry[] {
|
|
456
|
+
const now = args.now ?? Date.now();
|
|
457
|
+
const rows =
|
|
458
|
+
args.due === false
|
|
459
|
+
? db
|
|
460
|
+
.query<RemoteSessionReplyOutboxRow, [number]>(
|
|
461
|
+
"SELECT * FROM remote_session_reply_outbox ORDER BY created_at LIMIT ?",
|
|
462
|
+
)
|
|
463
|
+
.all(args.limit ?? 100)
|
|
464
|
+
: db
|
|
465
|
+
.query<RemoteSessionReplyOutboxRow, [number, number]>(
|
|
466
|
+
`SELECT * FROM remote_session_reply_outbox
|
|
467
|
+
WHERE next_attempt_at <= ?
|
|
468
|
+
ORDER BY created_at LIMIT ?`,
|
|
469
|
+
)
|
|
470
|
+
.all(now, args.limit ?? 100);
|
|
471
|
+
return rows.map(toOutboxEntry);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function deleteRemoteSessionReplyOutbox(requestId: string): boolean {
|
|
475
|
+
const result = db
|
|
476
|
+
.query("DELETE FROM remote_session_reply_outbox WHERE request_id = ?")
|
|
477
|
+
.run(requestId);
|
|
478
|
+
return Number(result.changes ?? 0) === 1;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Record a failed attempt and schedule the next one on the backoff ladder
|
|
483
|
+
* (5, 10, 20, 30 s cap, jittered) — the immediate first attempt is
|
|
484
|
+
* `attempts = 0`, so the first deferral waits ≈5 s. `retryMs` pins the delay.
|
|
485
|
+
*/
|
|
486
|
+
export function deferRemoteSessionReplyOutbox(args: {
|
|
487
|
+
requestId: string;
|
|
488
|
+
error: string;
|
|
489
|
+
now?: number;
|
|
490
|
+
retryMs?: number;
|
|
491
|
+
random?: () => number;
|
|
492
|
+
}): void {
|
|
493
|
+
const now = args.now ?? Date.now();
|
|
494
|
+
db.transaction(() => {
|
|
495
|
+
const row = db
|
|
496
|
+
.query<{ attempts: number | bigint }, [string]>(
|
|
497
|
+
"SELECT attempts FROM remote_session_reply_outbox WHERE request_id = ?",
|
|
498
|
+
)
|
|
499
|
+
.get(args.requestId);
|
|
500
|
+
if (!row) return;
|
|
501
|
+
const attempts = Number(row.attempts) + 1;
|
|
502
|
+
const delay = args.retryMs ?? remoteSessionRetryDelayMs(attempts, args.random);
|
|
503
|
+
db.query(
|
|
504
|
+
`UPDATE remote_session_reply_outbox
|
|
505
|
+
SET attempts = ?, next_attempt_at = ?, last_error = ?
|
|
506
|
+
WHERE request_id = ?`,
|
|
507
|
+
).run(attempts, now + delay, args.error.slice(0, 500), args.requestId);
|
|
508
|
+
})();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Drop answers nobody is waiting for any more. Returns what was dropped, for the log. */
|
|
512
|
+
export function purgeExpiredRemoteSessionReplyOutbox(
|
|
513
|
+
now: number = Date.now(),
|
|
514
|
+
): RemoteSessionReplyOutboxEntry[] {
|
|
515
|
+
const cutoff = now - REMOTE_SESSION_REPLY_OUTBOX_TTL_MS;
|
|
516
|
+
const expired = db
|
|
517
|
+
.query<RemoteSessionReplyOutboxRow, [number]>(
|
|
518
|
+
"SELECT * FROM remote_session_reply_outbox WHERE created_at < ?",
|
|
519
|
+
)
|
|
520
|
+
.all(cutoff)
|
|
521
|
+
.map(toOutboxEntry);
|
|
522
|
+
if (expired.length) {
|
|
523
|
+
db.query("DELETE FROM remote_session_reply_outbox WHERE created_at < ?").run(cutoff);
|
|
524
|
+
}
|
|
525
|
+
return expired;
|
|
526
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { intersectActorTopicScopes } from "#runtime/actor-topic-scope";
|
|
3
|
+
import { mergeRemoteSessionGrants } from "#runtime/remote-session-grant";
|
|
2
4
|
import {
|
|
3
5
|
flattenUserTurnAttachments,
|
|
4
6
|
legacyUserTurnEnvelope,
|
|
@@ -10,7 +12,13 @@ import { TURN_LEASE_STALE_MS } from "#storage/runtime-leases";
|
|
|
10
12
|
import { getRuntimeTopicEpoch, TOPIC_MAINTENANCE_STALE_MS } from "#storage/runtime-topic-state";
|
|
11
13
|
import type { StorageDatabase } from "#storage/storage-contract";
|
|
12
14
|
import { registerStorageSchemaInitializer } from "#storage/storage-host";
|
|
13
|
-
import type {
|
|
15
|
+
import type {
|
|
16
|
+
ActorTopicScope,
|
|
17
|
+
AgentKind,
|
|
18
|
+
EffortLevel,
|
|
19
|
+
PeerRuntimeBridgeContext,
|
|
20
|
+
RemoteSessionGrant,
|
|
21
|
+
} from "#types";
|
|
14
22
|
|
|
15
23
|
const REQUEST_CLAIM_STALE_MS = TURN_LEASE_STALE_MS;
|
|
16
24
|
|
|
@@ -37,6 +45,10 @@ export interface RuntimeUserTurnExecution {
|
|
|
37
45
|
vaultUserId?: string;
|
|
38
46
|
/** Product actor identity, independent from the canonical execution principal. */
|
|
39
47
|
actorUserId?: string;
|
|
48
|
+
/** Host-asserted rooms the actor may reach; kept with the row so retries keep it. */
|
|
49
|
+
actorTopicScope?: ActorTopicScope;
|
|
50
|
+
/** Hub-issued per-turn remote session-comm grant; kept with the row like the assertion. */
|
|
51
|
+
remoteSession?: RemoteSessionGrant;
|
|
40
52
|
/** Newly accepted user texts not yet recorded in the unified conversation log. */
|
|
41
53
|
conversationPrompts?: string[];
|
|
42
54
|
/** Number of leading userMessages already present in the unified conversation log. */
|
|
@@ -306,6 +318,17 @@ export function enqueueRuntimeUserTurnRequest(input: {
|
|
|
306
318
|
return requestId;
|
|
307
319
|
}
|
|
308
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Who a request speaks for. `null` when no actor was recorded (a turn the node
|
|
323
|
+
* started for itself, or a row older than the field): two such requests are
|
|
324
|
+
* one conversation, but an unrecorded actor never matches a recorded one — the
|
|
325
|
+
* conservative reading, since the alternative is folding a stranger's message
|
|
326
|
+
* into a turn whose author is unknown.
|
|
327
|
+
*/
|
|
328
|
+
function requestActor(actorUserId: string | undefined): string | null {
|
|
329
|
+
return actorUserId ?? null;
|
|
330
|
+
}
|
|
331
|
+
|
|
309
332
|
function loggedMessageCount(request: RuntimeUserTurnRequest): number {
|
|
310
333
|
const explicit = request.execution?.loggedUserMessageCount;
|
|
311
334
|
if (typeof explicit === "number" && Number.isInteger(explicit)) {
|
|
@@ -350,9 +373,25 @@ export function mergeRuntimeUserTurnRequest(input: {
|
|
|
350
373
|
// and a pending request from another thread is left standing to run on
|
|
351
374
|
// its own (S-13).
|
|
352
375
|
const thread = input.execution.threadRootId;
|
|
353
|
-
const
|
|
376
|
+
const sameThread = rows
|
|
354
377
|
.map(rowToRequest)
|
|
355
378
|
.filter((request) => request.execution?.threadRootId === thread);
|
|
379
|
+
// Nor is a different person the same conversation for this purpose. A
|
|
380
|
+
// merged batch runs with one actor's identity and one room assertion,
|
|
381
|
+
// so folding B's follow-up onto A's pending question would run A's
|
|
382
|
+
// words under B's authority (or B's under A's). Only the most recent
|
|
383
|
+
// unbroken run of the *same* actor's requests is folded; a request from
|
|
384
|
+
// anyone else ends that run and keeps its own row, in arrival order, so
|
|
385
|
+
// each turn carries exactly the actor and assertion it was sent with.
|
|
386
|
+
const actor = requestActor(input.execution.actorUserId);
|
|
387
|
+
let firstMergeable = sameThread.length;
|
|
388
|
+
while (
|
|
389
|
+
firstMergeable > 0 &&
|
|
390
|
+
requestActor(sameThread[firstMergeable - 1]!.execution?.actorUserId) === actor
|
|
391
|
+
) {
|
|
392
|
+
firstMergeable -= 1;
|
|
393
|
+
}
|
|
394
|
+
const previous = sameThread.slice(firstMergeable);
|
|
356
395
|
const omittedRequestIds = new Set([
|
|
357
396
|
...(input.omitRequestIds ?? []),
|
|
358
397
|
...previous
|
|
@@ -392,8 +431,24 @@ export function mergeRuntimeUserTurnRequest(input: {
|
|
|
392
431
|
const loggedUserMessageCount =
|
|
393
432
|
carried.reduce((count, request) => count + loggedMessageCount(request), 0) +
|
|
394
433
|
Math.max(0, incomingLoggedCount - alreadyIncludedMessageCount);
|
|
434
|
+
// Same actor, possibly different assertions (the hub's roster moved
|
|
435
|
+
// between two messages): the merged turn gets the intersection, and no
|
|
436
|
+
// assertion at all if any folded request lacked one. Narrowing is the
|
|
437
|
+
// only safe direction for a batch that speaks with one voice.
|
|
438
|
+
const actorTopicScope = intersectActorTopicScopes([
|
|
439
|
+
...previous.map((request) => request.execution?.actorTopicScope),
|
|
440
|
+
input.execution.actorTopicScope,
|
|
441
|
+
]);
|
|
442
|
+
// Same rule for the remote-session grant: the batch keeps one only when
|
|
443
|
+
// every folded request carried one (for the same hub), the latest-expiring.
|
|
444
|
+
const remoteSession = mergeRemoteSessionGrants([
|
|
445
|
+
...previous.map((request) => request.execution?.remoteSession),
|
|
446
|
+
input.execution.remoteSession,
|
|
447
|
+
]);
|
|
395
448
|
const execution: RuntimeUserTurnExecution = {
|
|
396
449
|
...input.execution,
|
|
450
|
+
actorTopicScope,
|
|
451
|
+
remoteSession,
|
|
397
452
|
loggedUserMessageCount,
|
|
398
453
|
supersededRequestIds: [
|
|
399
454
|
...new Set(
|
|
@@ -681,6 +736,24 @@ export function cancelRuntimeUserTurnRequests(topicId: string): string[] {
|
|
|
681
736
|
return rows.map((row) => row.request_id);
|
|
682
737
|
}
|
|
683
738
|
|
|
739
|
+
/**
|
|
740
|
+
* The request a worker currently holds for `topicId` — claimed or already
|
|
741
|
+
* running — if any. This is the turn a new human message could steer; whether
|
|
742
|
+
* it may is decided by whoever folds the new message (see
|
|
743
|
+
* `submitRuntimeGatewayTurn`).
|
|
744
|
+
*/
|
|
745
|
+
export function getActiveRuntimeUserTurnRequest(topicId: string): RuntimeUserTurnRequest | null {
|
|
746
|
+
const row = db
|
|
747
|
+
.query<RuntimeUserTurnRequestRow, [string, number]>(
|
|
748
|
+
`SELECT * FROM runtime_user_turn_requests
|
|
749
|
+
WHERE topic_id = ?
|
|
750
|
+
AND (status = 'running' OR (claimed_by IS NOT NULL AND claimed_at >= ?))
|
|
751
|
+
ORDER BY created_at ASC, rowid ASC LIMIT 1`,
|
|
752
|
+
)
|
|
753
|
+
.get(topicId, Date.now() - REQUEST_CLAIM_STALE_MS);
|
|
754
|
+
return row ? rowToRequest(row) : null;
|
|
755
|
+
}
|
|
756
|
+
|
|
684
757
|
export function getRuntimeUserTurnRequest(topicId: string): RuntimeUserTurnRequest | null {
|
|
685
758
|
const row = db
|
|
686
759
|
.query<RuntimeUserTurnRequestRow, [string]>(
|
|
@@ -203,6 +203,8 @@ function captureForkSnapshot(
|
|
|
203
203
|
|
|
204
204
|
interface DerivedTopicOptions {
|
|
205
205
|
name?: string;
|
|
206
|
+
/** Product actor who asked for the derive, when `userId` is a shared principal. */
|
|
207
|
+
derivedByUserId?: string;
|
|
206
208
|
subagent?: { agent?: AgentKind; model?: string; memoryTopicId?: string };
|
|
207
209
|
summarizeFork?: (request: CompactSummaryRequest) => Promise<string>;
|
|
208
210
|
}
|
|
@@ -312,6 +314,7 @@ async function createDerivedTopicImpl(
|
|
|
312
314
|
parentTopicId: sourceTopicId,
|
|
313
315
|
...(subagent?.memoryTopicId ? { memoryTopicId: subagent.memoryTopicId } : {}),
|
|
314
316
|
isFork: copyHistory,
|
|
317
|
+
...(opts?.derivedByUserId ? { derivedByUserId: opts.derivedByUserId } : {}),
|
|
315
318
|
...(subagent ? { isSubagent: true } : {}),
|
|
316
319
|
visibility: topic.visibility,
|
|
317
320
|
// A derived room lives on the same surface as the room it came from.
|
|
@@ -120,6 +120,12 @@ export interface TopicDto {
|
|
|
120
120
|
memoryKey?: string;
|
|
121
121
|
/** True when the topic was created via fork (copies history). */
|
|
122
122
|
isFork?: boolean;
|
|
123
|
+
/**
|
|
124
|
+
* The person whose turn asked for this spawn/fork, when the node executed it
|
|
125
|
+
* under a shared principal. A host that mirrors the room seeds its owner from
|
|
126
|
+
* this; absent for rooms not derived by an agent tool.
|
|
127
|
+
*/
|
|
128
|
+
derivedByUserId?: string;
|
|
123
129
|
/** True when this topic was spawned by an agent as a subagent worker room. */
|
|
124
130
|
isSubagent?: boolean;
|
|
125
131
|
/** Extra tell_session targets granted to this subagent; direct parent is implicit. */
|