@gnldev/chat-adapter 0.1.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/LICENSE +201 -0
- package/README.md +181 -0
- package/dist/approve.d.ts +32 -0
- package/dist/approve.js +46 -0
- package/dist/approve.js.map +1 -0
- package/dist/chat-route.d.ts +107 -0
- package/dist/chat-route.js +280 -0
- package/dist/chat-route.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/messages.d.ts +22 -0
- package/dist/messages.js +116 -0
- package/dist/messages.js.map +1 -0
- package/dist/sentinel-mask.d.ts +34 -0
- package/dist/sentinel-mask.js +39 -0
- package/dist/sentinel-mask.js.map +1 -0
- package/dist/ui-stream.d.ts +43 -0
- package/dist/ui-stream.js +85 -0
- package/dist/ui-stream.js.map +1 -0
- package/package.json +64 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { convertToModelMessages } from 'ai';
|
|
3
|
+
import { createGnl, RunThreadMismatchError, blockedErrorCode, callerConflictCode, upstreamFailure, sealRequestContext, resolveWorkIdentity } from '@gnldev/durable';
|
|
4
|
+
import { toUIMessageStreamResponse } from './ui-stream.js';
|
|
5
|
+
const DEFAULT_LOCK_TTL_MS = 300_000;
|
|
6
|
+
let anonCounter = 0;
|
|
7
|
+
/**
|
|
8
|
+
* The caller's own name for the work, reflected back in a refusal (§8, rules 1-3) — the same
|
|
9
|
+
* function @gnldev/server's route catch uses, rendered locally for the same reason the whole
|
|
10
|
+
* taxonomy is: the dependency direction is chat-adapter → durable, never chat-adapter → server.
|
|
11
|
+
*
|
|
12
|
+
* FROM THE REQUEST, never from storage: a swept run keeps only a hash of its workKey (§10.3), so
|
|
13
|
+
* reading the name back out would undo a deletion. The caller already knows what it sent.
|
|
14
|
+
*
|
|
15
|
+
* `detail` and not `error`: the sentence is the most casually logged field there is, and a workKey
|
|
16
|
+
* is a business name.
|
|
17
|
+
*/
|
|
18
|
+
function withWorkKey(detail, workKey) {
|
|
19
|
+
if (workKey === undefined)
|
|
20
|
+
return detail;
|
|
21
|
+
return detail && typeof detail === 'object' ? { ...detail, workKey } : { workKey };
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Typed error rendering — the SAME taxonomy as @gnldev/server's route catch (index.ts's
|
|
25
|
+
* threadMismatchResponse / blockedErrorResponse / upstreamErrorResponse), rendered locally because the
|
|
26
|
+
* dependency direction is chat-adapter → durable, never chat-adapter → server. Before this, the catch
|
|
27
|
+
* collapsed EVERYTHING to a flat 400 `{error}` — so a concurrent duplicate of the same runId
|
|
28
|
+
* (RunBusyError, "your run is already in flight — retry later and you'll get the replay") reached the
|
|
29
|
+
* client as "your request was malformed", which tells a retrying client to stop retrying at the exact
|
|
30
|
+
* moment retrying is the right move.
|
|
31
|
+
*/
|
|
32
|
+
function typedErrorResponse(c, e, workKey) {
|
|
33
|
+
if (e instanceof RunThreadMismatchError || e?.name === 'RunThreadMismatchError') {
|
|
34
|
+
const err = e;
|
|
35
|
+
// 409 without `resumable`: same runId + this thread never succeeds — see server's rationale.
|
|
36
|
+
return c.json({ error: err.message, code: 'run_thread_mismatch', detail: withWorkKey(err.detail, workKey) }, 409);
|
|
37
|
+
}
|
|
38
|
+
// FAZ-4 caller-conflict family — same 409-without-resumable posture as thread mismatch above.
|
|
39
|
+
// K9: the map is durable's single CALLER_CONFLICT_CODES export (thread mismatch is answered by the
|
|
40
|
+
// dedicated branch above; its entry here is harmless duplication by design).
|
|
41
|
+
const conflictCode = callerConflictCode(e);
|
|
42
|
+
if (conflictCode && conflictCode !== 'run_thread_mismatch') {
|
|
43
|
+
const err = e;
|
|
44
|
+
return c.json({ error: err.message, code: conflictCode, detail: withWorkKey(err.detail, workKey) }, 409);
|
|
45
|
+
}
|
|
46
|
+
const code = blockedErrorCode(e);
|
|
47
|
+
if (code) {
|
|
48
|
+
const err = e;
|
|
49
|
+
const body = { error: err?.message ?? String(e), code, detail: err?.detail };
|
|
50
|
+
if (code === 'retry_limit_exceeded')
|
|
51
|
+
return c.json(body, 422);
|
|
52
|
+
const res = c.json({ ...body, resumable: true }, 409);
|
|
53
|
+
// Run_busy: another worker holds this runId RIGHT NOW — a short client backoff then the same
|
|
54
|
+
// RunId lands on the journal replay. 5s is a hint, not a lease measurement (the route has no
|
|
55
|
+
// visibility into the holder's lock TTL).
|
|
56
|
+
if (code === 'run_busy')
|
|
57
|
+
res.headers.set('Retry-After', '5');
|
|
58
|
+
return res;
|
|
59
|
+
}
|
|
60
|
+
const up = upstreamFailure(e);
|
|
61
|
+
if (up) {
|
|
62
|
+
const err = e;
|
|
63
|
+
const body = {
|
|
64
|
+
error: err?.message ?? String(e),
|
|
65
|
+
code: up.code,
|
|
66
|
+
...(up.upstreamStatus !== undefined ? { upstreamStatus: up.upstreamStatus } : {}),
|
|
67
|
+
...(up.retryAfter !== undefined ? { retryAfter: up.retryAfter } : {}),
|
|
68
|
+
};
|
|
69
|
+
const res = c.json(body, up.status);
|
|
70
|
+
if (up.retryAfter !== undefined)
|
|
71
|
+
res.headers.set('Retry-After', String(up.retryAfter));
|
|
72
|
+
return res;
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Produces a single-endpoint Hono router from a createGnl config (or an already-built `gnl` instance)
|
|
78
|
+
* that a `useChat({ api: '.../agents/:name/chat' })` client can talk to:
|
|
79
|
+
* POST /agents/:name/chat { id?, messages: UIMessage[], runId?, threadId?, approvals? } → UI message stream
|
|
80
|
+
* Deliberately kept small — SAME posture as @gnldev/agui's `createAguiRoute`: NO auth/org/budget gates (if
|
|
81
|
+
* needed, wrap this route, or compose @gnldev/server's createRestApi's auth middleware around it — see README).
|
|
82
|
+
*
|
|
83
|
+
* IDENTITY precedence: `body.runId` > `opts.resolveRunId(...)` > the TURN'S NAME
|
|
84
|
+
* (`Idempotency-Key` header, else DERIVED `${body.id}:${lastMessage.id}`) > a generated id. The
|
|
85
|
+
* derivation is the load-bearing default: `body.id` is useChat's STABLE per-conversation id — using
|
|
86
|
+
* it ALONE would make every later turn replay turn 1 from the journal (withDurableModel replays
|
|
87
|
+
* `runId:model:0` and the model never runs again). Combining it with the LAST message's id (useChat
|
|
88
|
+
* stamps a fresh id per message) gives one exactly-once run PER TURN, and makes a network retry of
|
|
89
|
+
* the SAME turn land on the same run (deduped replay — free idempotency) while a NEW turn runs fresh.
|
|
90
|
+
* `threadId` defaults to `body.id` (the conversation), NOT the per-turn key — conversation memory
|
|
91
|
+
* must span turns.
|
|
92
|
+
*
|
|
93
|
+
* TWO REGIMES, decided by whether the route can name a SUBJECT (package #5, §7). With one, the
|
|
94
|
+
* turn's name is a `workKey` and the engine derives an opaque `run1_` id from it; without one it
|
|
95
|
+
* stays the raw id it has always been, because deriving needs an address (§6) and this route's
|
|
96
|
+
* quickstart names nobody. The retry contract is identical either way: the same message ids produce
|
|
97
|
+
* the same key, and the same key lands on the same run.
|
|
98
|
+
*
|
|
99
|
+
* SCOPE of that idempotency (FAZ-2): serial retries dedupe via journal replay, and CONCURRENT
|
|
100
|
+
* duplicates are now serialized by the default per-run lock (`CreateChatRouteOptions.lock`) — the
|
|
101
|
+
* loser gets the typed 409 `run_busy` + Retry-After instead of a second execution. The effective
|
|
102
|
+
* RunId is echoed on every response as `X-Gnl-Run-Id` and stamped onto interrupt chunks — a
|
|
103
|
+
* CORRELATION handle, not the retry key: to retry, send the same turn again. One exception, agui's
|
|
104
|
+
* too: a pre-run identity refusal (unknown agent, unaddressable scope) carries no header, because
|
|
105
|
+
* the header names a run and none exists yet on that path.
|
|
106
|
+
*
|
|
107
|
+
* Body `workKey` is deliberately NOT read here (the REST, agui and workflow doors all take it):
|
|
108
|
+
* this route speaks the useChat wire format, where the turn IS the work — the per-turn key above,
|
|
109
|
+
* or a gateway's `Idempotency-Key`, is what gets promoted. A field the UI library never sends would
|
|
110
|
+
* be dead surface with a live failure mode (a stale key silently pinning every turn to one run).
|
|
111
|
+
*/
|
|
112
|
+
export function createChatRoute(config, opts = {}) {
|
|
113
|
+
const gnl = 'gnl' in config ? config.gnl : createGnl(config);
|
|
114
|
+
// A route that cannot name anyone, in production, said nothing about it. Every run it starts is
|
|
115
|
+
// born ownerless — and an ownership gate with no owner to compare against passes (registry.ts's
|
|
116
|
+
// ownershipDenied takes the `!owner` branch), so the protection reads as present and is not.
|
|
117
|
+
// WARN, never throw: an existing deployment that has decided its own boundary lives in front of
|
|
118
|
+
// this route is not broken, and a framework that refuses to start over a posture question would be
|
|
119
|
+
// discovered at the worst possible moment. Once, at construction, addressed and with the fix in it.
|
|
120
|
+
if (process.env.NODE_ENV === 'production' && !opts.identity && !opts.resolveResourceId) {
|
|
121
|
+
console.warn('[gnl chat-route] no `identity` and no `resolveResourceId` in production — runs will be born ownerless; ' +
|
|
122
|
+
'ownership gates stay fail-open (a run with no owner is refused to nobody). Pass `identity: (req) => ({ resourceId })` ' +
|
|
123
|
+
'reading your session/JWT — never the request body.');
|
|
124
|
+
}
|
|
125
|
+
const app = new Hono();
|
|
126
|
+
app.post('/agents/:name/chat', async (c) => {
|
|
127
|
+
const name = c.req.param('name');
|
|
128
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
129
|
+
const lastMsg = body.messages?.[body.messages.length - 1];
|
|
130
|
+
// Bir kez çözülür: hem mühür hem resourceId aynı değeri kullansın. İsteğe bakan bir çözücüyü
|
|
131
|
+
// iki kez çağırmak, iki farklı cevap alma ihtimali demektir. `identity` de aynı sebeple tek çağrı:
|
|
132
|
+
// iki alanı birden besliyor, ikisi ayrı çağrıdan gelirse ayrı cevaplardan gelebilir.
|
|
133
|
+
// KİMLİK ARTIK ÖNDE ÇÖZÜLÜYOR: aşağıdaki anahtar kararı özneyi bilmeden verilemiyor.
|
|
134
|
+
const ident = await opts.identity?.(c.req.raw);
|
|
135
|
+
const subject = opts.resolveResourceId?.(c, body) ?? ident?.resourceId;
|
|
136
|
+
// WHICH ORGANIZATION. Only `identity` can answer it — there is no `resolveOrgId` hook and there
|
|
137
|
+
// will not be one; the hook that already resolves the subject from a verified session is the
|
|
138
|
+
// right place for the boundary that CONTAINS the subject. Never read from the body: an org is an
|
|
139
|
+
// isolation boundary, and a caller who picks their own has none (sealRequestContext strips the
|
|
140
|
+
// reserved key for exactly this reason).
|
|
141
|
+
const org = ident?.orgId;
|
|
142
|
+
// A RAW id, when the caller is holding one. `body.runId` and `resolveRunId` both name an ID (the
|
|
143
|
+
// second one says so in its name), so neither is promoted — a host that hands us an id has
|
|
144
|
+
// already decided the addressing.
|
|
145
|
+
const rawId = body.runId ?? opts.resolveRunId?.(c, body);
|
|
146
|
+
// THE TURN'S NAME. `${body.id}:${lastMessage.id}` is what this route has always derived, and the
|
|
147
|
+
// `Idempotency-Key` header is the same declaration arriving from a gateway instead. Deliberately
|
|
148
|
+
// AFTER the two raw resolvers (heyet kararı 1.5): the header is often stamped by a proxy, while a
|
|
149
|
+
// body/host decision is explicit, and header-first would let an intermediary redefine the turn.
|
|
150
|
+
const workName = c.req.header('Idempotency-Key') ?? (body.id && lastMsg?.id ? `${body.id}:${lastMsg.id}` : undefined);
|
|
151
|
+
let runId = rawId;
|
|
152
|
+
let workKey;
|
|
153
|
+
if (!runId && workName) {
|
|
154
|
+
// THE PROMOTION, and the concession that comes with it (package #5, §7).
|
|
155
|
+
//
|
|
156
|
+
// With a subject, the turn's name is a `workKey`: the engine derives `run1_<digest>` and the
|
|
157
|
+
// client-controlled string stops being a journal key prefix. That closes bug class 2 (§2) at
|
|
158
|
+
// its documented source — `conv` and `conv:msg1` were exactly this derivation's shape.
|
|
159
|
+
//
|
|
160
|
+
// WITHOUT a subject it stays what it has always been: a raw runId. Deriving a name into an id
|
|
161
|
+
// needs an ADDRESS (§6, fail-closed), and this route ships with no auth and a `useChat`
|
|
162
|
+
// quickstart that names nobody. Refusing those would replace a working first five minutes
|
|
163
|
+
// with a 400 — so the anonymous regime is preserved byte for byte, and the protection matrix's
|
|
164
|
+
// identity row is where a deployment reads which of the two it is in.
|
|
165
|
+
if (subject) {
|
|
166
|
+
try {
|
|
167
|
+
const identity = resolveWorkIdentity(`agent:${name}`, {
|
|
168
|
+
workKey: workName,
|
|
169
|
+
// The agent's own declaration, read off the registry rather than guessed: the route has
|
|
170
|
+
// no business deciding which address a name is unique within (see AgentConfig.workScope).
|
|
171
|
+
scopeKind: gnl.agent(name).workScope ?? 'resource',
|
|
172
|
+
resourceId: subject,
|
|
173
|
+
// THE ORG, and it is not optional decoration — @gnldev/server passes it on the REST
|
|
174
|
+
// route, and an `'org'` workScope with no org falls back to the deployment sentinel
|
|
175
|
+
// (§10.2). Without this line the same organization's same named work derives one id
|
|
176
|
+
// through REST and another through here: not an error, a DUPLICATE, decided by whichever
|
|
177
|
+
// surface the request came in on.
|
|
178
|
+
...(org ? { orgId: org } : {}),
|
|
179
|
+
anonymous: 'refuse',
|
|
180
|
+
surface: `POST /agents/${name}/chat`,
|
|
181
|
+
});
|
|
182
|
+
runId = identity.runId;
|
|
183
|
+
workKey = identity.work?.workKey;
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
// An unknown agent, or a scope that cannot be addressed. There is no run to correlate with,
|
|
187
|
+
// so no X-Gnl-Run-Id either — the header names a run, and there is none.
|
|
188
|
+
return c.json({ error: String(e?.message ?? e) }, 400);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
runId = workName;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (!runId) {
|
|
196
|
+
// Anon fallback: a fresh id per request = ZERO dedup — a network retry of this exact request
|
|
197
|
+
// Runs the turn again. Deliberately NOT content-hashed (two intentional identical requests must
|
|
198
|
+
// stay two runs); the fix is the contract, not magic: the response's X-Gnl-Run-Id header hands
|
|
199
|
+
// the client the key to retry with.
|
|
200
|
+
// Sayaç MODÜL düzeyinde: her replika kendi sıfırından sayar. Ortak journal üstünde iki
|
|
201
|
+
// süreç aynı milisaniyede `chat-<ms>-0` üretir ve İKİ FARKLI kullanıcının isteği tek koşuma
|
|
202
|
+
// düşer — dedup yokluğu değil, YANLIŞ dedup: ikinci istek birincinin adımlarını replay eder.
|
|
203
|
+
// Süreç-dışı entropi bunu kapatır. Doğru çözüm hâlâ istikrarlı bir runId GÖNDERMEK; aşağıdaki
|
|
204
|
+
// uyarı onu söylüyor, bu satır yalnız çarpışmayı engelliyor.
|
|
205
|
+
runId = `chat-${Date.now()}-${anonCounter++}-${crypto.randomUUID().slice(0, 8)}`;
|
|
206
|
+
console.warn(`[gnl chat-route] no runId derivable (body.runId / resolveRunId / body.id+message.id all absent) — generated '${runId}'. Retries of this request will NOT dedupe; send body.runId (echoed back as X-Gnl-Run-Id) so retries land on the same run.`);
|
|
207
|
+
}
|
|
208
|
+
// The conversation id (NOT the per-turn runId) anchors memory — see the runId note in the JSDoc.
|
|
209
|
+
// `identity` sits BELOW the dedicated resolver and ABOVE the body: it is server-derived, the body
|
|
210
|
+
// is not, so it must not be overridable by what the caller sent.
|
|
211
|
+
const threadId = opts.resolveThreadId?.(c, body) ?? ident?.threadId ?? body.threadId ?? body.id ?? runId;
|
|
212
|
+
// V1: `tools` is not passed to convertToModelMessages — a conversation whose CLIENT-side history
|
|
213
|
+
// still carries tool-invocation parts from a prior turn round-trips as best-effort (text/reasoning
|
|
214
|
+
// are unaffected). Fine for the common case (server-side history via toUIMessages + threadId memory
|
|
215
|
+
// is the durable source of truth); documented rather than silently assumed complete.
|
|
216
|
+
// AWAITED: `convertToModelMessages` is async in AI SDK 7 (it was synchronous in v5). Passing the
|
|
217
|
+
// un-awaited Promise straight through as `messages` sent a Promise into the run — the journal
|
|
218
|
+
// then tried to structuredClone it and every chat request failed with
|
|
219
|
+
// "#<Promise> could not be cloned", i.e. a flat 400 on the whole route.
|
|
220
|
+
// INSIDE the try below: conversion throws on CLIENT-controlled input (a malformed `messages`
|
|
221
|
+
// shape, an unsupported part type) — outside the try that surfaced as Hono's bare 500 with no
|
|
222
|
+
// typed body and NO X-Gnl-Run-Id, breaking the every-response header contract on exactly the
|
|
223
|
+
// malformed-request path. In the catch it falls through typedErrorResponse (no match) to the
|
|
224
|
+
// generic 400, which is what a malformed request is.
|
|
225
|
+
// FAZ-2 default lock: acquired by streamDurable BEFORE any setup work, released on stream
|
|
226
|
+
// finish/error — the loser throws RunBusyError synchronously into the catch below (409 run_busy).
|
|
227
|
+
const lock = opts.lock === false
|
|
228
|
+
? undefined
|
|
229
|
+
: { owner: `chat-${crypto.randomUUID()}`, ttlMs: opts.lock?.ttlMs ?? DEFAULT_LOCK_TTL_MS };
|
|
230
|
+
let result;
|
|
231
|
+
try {
|
|
232
|
+
const messages = await convertToModelMessages(body.messages ?? []);
|
|
233
|
+
result = await gnl.stream(name, {
|
|
234
|
+
// The NAME when the turn key was promoted, the raw id otherwise. The door re-resolves the
|
|
235
|
+
// same tuple and lands on the same id — passing the id instead would drop the declaration,
|
|
236
|
+
// and the declaration is what makes "which run was this turn?" answerable in Studio.
|
|
237
|
+
...(workKey !== undefined ? { workKey } : { runId }),
|
|
238
|
+
messages,
|
|
239
|
+
threadId,
|
|
240
|
+
approvals: body.approvals,
|
|
241
|
+
// HER ZAMAN mühürlü — kimlik bilinmese bile. Ayrılmış anahtarlar motorun "bunu sunucu
|
|
242
|
+
// doğruladı" kanalıdır; mühürsüz bir gövde o kanalın sahibi olur. Kimlik yoksa anahtarlar
|
|
243
|
+
// silinir (fail-closed), çözücü varsa sunucunun değeri yazılır.
|
|
244
|
+
// The org travels in the SEAL as well as into the derivation above — the two must not
|
|
245
|
+
// disagree. `sealRequestContext` writes the reserved `__gnl_orgId`/`org` keys from what the
|
|
246
|
+
// SERVER established, so an org-scoped run's record and its dynamic `system`/`tools` see the
|
|
247
|
+
// same organization the id was derived under, and a body that named its own is stripped.
|
|
248
|
+
context: sealRequestContext(body.context ?? {}, {
|
|
249
|
+
...(subject ? { resourceId: subject } : {}),
|
|
250
|
+
...(org ? { orgId: org } : {}),
|
|
251
|
+
}),
|
|
252
|
+
...(subject ? { resourceId: subject } : {}),
|
|
253
|
+
...(lock ? { lock } : {}),
|
|
254
|
+
// P0.2 thread the REQUEST's AbortSignal through to generation — a client
|
|
255
|
+
// disconnect (tab close, useChat's `stop()`, navigation away) stops token generation instead of
|
|
256
|
+
// silently billing to completion. This does NOT break resumable-SSE replay: an abort simply ends
|
|
257
|
+
// generation early, the journal keeps whatever prefix already completed, and a LATER call with the
|
|
258
|
+
// SAME runId resumes/replays exactly as before (see registry.ts's RunOptions.abortSignal note).
|
|
259
|
+
abortSignal: c.req.raw.signal,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
catch (e) {
|
|
263
|
+
const res = typedErrorResponse(c, e, workKey) ?? c.json({ error: String(e?.message ?? e) }, 400);
|
|
264
|
+
res.headers.set('X-Gnl-Run-Id', runId);
|
|
265
|
+
return res;
|
|
266
|
+
}
|
|
267
|
+
// Every response (success AND error) echoes the effective runId — the client-side retry key is a
|
|
268
|
+
// contract, not something the caller has to re-derive from useChat internals. The SAME runId is
|
|
269
|
+
// stamped onto `data-gnl-interrupt` chunks (FAZ-2) so an approval addresses THIS run.
|
|
270
|
+
const res = toUIMessageStreamResponse(result, { runId });
|
|
271
|
+
res.headers.set('X-Gnl-Run-Id', runId);
|
|
272
|
+
// FAZ-7: the engine's replay signal — 'replay' when this runId had frozen input before the call
|
|
273
|
+
// (a resume/retry landing on journal state), 'new' on a fresh run. An observability contract for
|
|
274
|
+
// reconciliation, NOT a byte-identity guarantee.
|
|
275
|
+
res.headers.set('X-Gnl-Idempotency-Status', result?.__gnlPriorRun ? 'replay' : 'new');
|
|
276
|
+
return res;
|
|
277
|
+
});
|
|
278
|
+
return app;
|
|
279
|
+
}
|
|
280
|
+
//# sourceMappingURL=chat-route.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chat-route.js","sourceRoot":"","sources":["../src/chat-route.ts"],"names":[],"mappings":"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","sourcesContent":["// CreateChatRoute: a single-endpoint Hono router speaking the Vercel AI SDK v5 `useChat` wire format —\n// the ai-sdk-package counterpart of @gnldev/agui's createAguiRoute (AG-UI/CopilotKit) and @gnldev/server's\n// PipeAgentStream (GNL's own SSE schema). Converts `UIMessage[]` -> `ModelMessage[]` via `ai`'s\n// `convertToModelMessages` (export verified against the installed ai@7 package — tsc compiles this\n// import against its .d.ts), streams the\n// agent via `gnl.stream`, and returns the SENTINEL-MASKED UI message stream response (ui-stream.ts) —\n// never the native, unmasked one.\nimport type { Context } from 'hono';\nimport { Hono } from 'hono';\nimport { convertToModelMessages } from 'ai';\nimport type { UIMessage } from 'ai';\nimport { createGnl, RunThreadMismatchError, blockedErrorCode, callerConflictCode, upstreamFailure, sealRequestContext, resolveWorkIdentity } from '@gnldev/durable';\nimport type { CreateGnlConfig, GnlIdentity } from '@gnldev/durable';\nimport { toUIMessageStreamResponse } from './ui-stream.js';\n\nexport interface CreateChatRouteOptions {\n /** Resolve the durable `runId` (exactly-once key for THIS request) from the request/body. */\n resolveRunId?: (c: Context, body: any) => string | undefined;\n /** Resolve the conversation `threadId` (memory continuity across requests) from the request/body. */\n resolveThreadId?: (c: Context, body: any) => string | undefined;\n /**\n * WHO this request acts for — the end user's `resourceId`, read from something the SERVER trusts\n * (a session cookie, a verified JWT, `principalOf(c.req.raw)?.id`) and NEVER from the body.\n *\n * WHY IT EXISTS. GNL has no end-user identity: an end user is a SUBJECT that a trusted application\n * names, not a principal GNL authenticates (`resolveResourceId` in @gnldev/server states this).\n * The engine treats the reserved context keys as \"the server established this\", and this route used\n * to forward `body.context` verbatim — so the reserved key arrived from whoever sent the request.\n * MEASURED against a running app: a plain POST carrying\n * `{\"context\":{\"__gnl_resourceId\":\"KURBAN-KULLANICI\"}}` produced a run owned by that name, and the\n * ownership stamp followed it. The seal that exists precisely to prevent this (registry.ts's\n * `sealRequestContext`, whose own comment names the attack) was never applied on this path.\n *\n * The route now ALWAYS seals. With no resolver the seal carries no identity, which STRIPS the\n * reserved keys: a forged subject cannot get through, and none is asserted either.\n *\n * HONEST BOUND — it decides what every ownership guarantee downstream is worth: this route ships\n * with NO auth of its own (see the createChatRoute JSDoc). A resolver reading an unauthenticated\n * request asserts a subject nobody verified. Put auth in front of this route, or the subject is\n * only as trustworthy as the caller.\n */\n resolveResourceId?: (c: Context, body: any) => string | undefined;\n /**\n * WHO and WHICH CONVERSATION, in one hook — the same signature @gnldev/agui's route takes, so the\n * function a host writes once works on both.\n *\n * It exists because the two hooks above are two hooks. A host wiring identity had to write\n * `resolveResourceId` AND `resolveThreadId`, and on the sibling adapter the second one had a\n * different shape and, for a while, no effect at all (see agui's route.ts note on the dead\n * `threadId` line). Answering \"who is this request for\" twice is how one of the answers ends up\n * missing.\n *\n * Takes the web `Request`, not the Hono `Context` — the precedent is @gnldev/server's\n * `OrgOptions.resolve`, and the reason is the same: a host binding this route from Express or\n * Fastify has a Request and no Context.\n *\n * PRECEDENCE: `resolveResourceId` / `resolveThreadId` still WIN, field by field. They are the\n * existing contract and a new convenience must not silently take a working deployment's answer\n * away. This fills whichever of the two the host did not supply.\n *\n * Called ONCE per request, for the reason already written below about `subject`: a resolver that\n * reads the request may answer differently the second time.\n *\n * HONEST BOUND — unchanged from `resolveResourceId`: this route ships with no auth of its own. A\n * resolver reading an unauthenticated request asserts a subject nobody verified.\n */\n identity?: GnlIdentity;\n /**\n * FAZ-2 — per-run concurrency lock, ON by default (`{ ttlMs: 300_000 }`). Two CONCURRENT requests\n * with the same runId (double-click, two tabs, a retry racing the original) used to BOTH execute;\n * Now the loser gets the typed `409 run_busy` (+ Retry-After) and the winner's journal replay\n * answers the retry. `lock: false` restores the old behavior. The streamed lock SELF-RENEWS on a\n * ttl/2 heartbeat (engine parity with run()), so ttlMs is the crash-takeover window — the generous\n * 5-minute default simply keeps takeover conservative.\n */\n lock?: { ttlMs?: number } | false;\n}\n\nconst DEFAULT_LOCK_TTL_MS = 300_000;\n\nlet anonCounter = 0;\n\n/**\n * The caller's own name for the work, reflected back in a refusal (§8, rules 1-3) — the same\n * function @gnldev/server's route catch uses, rendered locally for the same reason the whole\n * taxonomy is: the dependency direction is chat-adapter → durable, never chat-adapter → server.\n *\n * FROM THE REQUEST, never from storage: a swept run keeps only a hash of its workKey (§10.3), so\n * reading the name back out would undo a deletion. The caller already knows what it sent.\n *\n * `detail` and not `error`: the sentence is the most casually logged field there is, and a workKey\n * is a business name.\n */\nfunction withWorkKey(detail: unknown, workKey?: string): unknown {\n if (workKey === undefined) return detail;\n return detail && typeof detail === 'object' ? { ...(detail as Record<string, unknown>), workKey } : { workKey };\n}\n\n/**\n * Typed error rendering — the SAME taxonomy as @gnldev/server's route catch (index.ts's\n * threadMismatchResponse / blockedErrorResponse / upstreamErrorResponse), rendered locally because the\n * dependency direction is chat-adapter → durable, never chat-adapter → server. Before this, the catch\n * collapsed EVERYTHING to a flat 400 `{error}` — so a concurrent duplicate of the same runId\n * (RunBusyError, \"your run is already in flight — retry later and you'll get the replay\") reached the\n * client as \"your request was malformed\", which tells a retrying client to stop retrying at the exact\n * moment retrying is the right move.\n */\nfunction typedErrorResponse(c: Context, e: unknown, workKey?: string): Response | undefined {\n if (e instanceof RunThreadMismatchError || (e as { name?: string })?.name === 'RunThreadMismatchError') {\n const err = e as RunThreadMismatchError;\n // 409 without `resumable`: same runId + this thread never succeeds — see server's rationale.\n return c.json({ error: err.message, code: 'run_thread_mismatch', detail: withWorkKey(err.detail, workKey) }, 409);\n }\n // FAZ-4 caller-conflict family — same 409-without-resumable posture as thread mismatch above.\n // K9: the map is durable's single CALLER_CONFLICT_CODES export (thread mismatch is answered by the\n // dedicated branch above; its entry here is harmless duplication by design).\n const conflictCode = callerConflictCode(e);\n if (conflictCode && conflictCode !== 'run_thread_mismatch') {\n const err = e as { message?: string; detail?: unknown };\n return c.json({ error: err.message, code: conflictCode, detail: withWorkKey(err.detail, workKey) }, 409);\n }\n const code = blockedErrorCode(e);\n if (code) {\n const err = e as { message?: string; detail?: unknown } | null | undefined;\n const body = { error: err?.message ?? String(e), code, detail: err?.detail };\n if (code === 'retry_limit_exceeded') return c.json(body, 422);\n const res = c.json({ ...body, resumable: true }, 409);\n // Run_busy: another worker holds this runId RIGHT NOW — a short client backoff then the same\n // RunId lands on the journal replay. 5s is a hint, not a lease measurement (the route has no\n // visibility into the holder's lock TTL).\n if (code === 'run_busy') res.headers.set('Retry-After', '5');\n return res;\n }\n const up = upstreamFailure(e);\n if (up) {\n const err = e as { message?: string } | null | undefined;\n const body = {\n error: err?.message ?? String(e),\n code: up.code,\n ...(up.upstreamStatus !== undefined ? { upstreamStatus: up.upstreamStatus } : {}),\n ...(up.retryAfter !== undefined ? { retryAfter: up.retryAfter } : {}),\n };\n const res = c.json(body, up.status);\n if (up.retryAfter !== undefined) res.headers.set('Retry-After', String(up.retryAfter));\n return res;\n }\n return undefined;\n}\n\n/**\n * Produces a single-endpoint Hono router from a createGnl config (or an already-built `gnl` instance)\n * that a `useChat({ api: '.../agents/:name/chat' })` client can talk to:\n * POST /agents/:name/chat { id?, messages: UIMessage[], runId?, threadId?, approvals? } → UI message stream\n * Deliberately kept small — SAME posture as @gnldev/agui's `createAguiRoute`: NO auth/org/budget gates (if\n * needed, wrap this route, or compose @gnldev/server's createRestApi's auth middleware around it — see README).\n *\n * IDENTITY precedence: `body.runId` > `opts.resolveRunId(...)` > the TURN'S NAME\n * (`Idempotency-Key` header, else DERIVED `${body.id}:${lastMessage.id}`) > a generated id. The\n * derivation is the load-bearing default: `body.id` is useChat's STABLE per-conversation id — using\n * it ALONE would make every later turn replay turn 1 from the journal (withDurableModel replays\n * `runId:model:0` and the model never runs again). Combining it with the LAST message's id (useChat\n * stamps a fresh id per message) gives one exactly-once run PER TURN, and makes a network retry of\n * the SAME turn land on the same run (deduped replay — free idempotency) while a NEW turn runs fresh.\n * `threadId` defaults to `body.id` (the conversation), NOT the per-turn key — conversation memory\n * must span turns.\n *\n * TWO REGIMES, decided by whether the route can name a SUBJECT (package #5, §7). With one, the\n * turn's name is a `workKey` and the engine derives an opaque `run1_` id from it; without one it\n * stays the raw id it has always been, because deriving needs an address (§6) and this route's\n * quickstart names nobody. The retry contract is identical either way: the same message ids produce\n * the same key, and the same key lands on the same run.\n *\n * SCOPE of that idempotency (FAZ-2): serial retries dedupe via journal replay, and CONCURRENT\n * duplicates are now serialized by the default per-run lock (`CreateChatRouteOptions.lock`) — the\n * loser gets the typed 409 `run_busy` + Retry-After instead of a second execution. The effective\n * RunId is echoed on every response as `X-Gnl-Run-Id` and stamped onto interrupt chunks — a\n * CORRELATION handle, not the retry key: to retry, send the same turn again. One exception, agui's\n * too: a pre-run identity refusal (unknown agent, unaddressable scope) carries no header, because\n * the header names a run and none exists yet on that path.\n *\n * Body `workKey` is deliberately NOT read here (the REST, agui and workflow doors all take it):\n * this route speaks the useChat wire format, where the turn IS the work — the per-turn key above,\n * or a gateway's `Idempotency-Key`, is what gets promoted. A field the UI library never sends would\n * be dead surface with a live failure mode (a stale key silently pinning every turn to one run).\n */\nexport function createChatRoute(\n config: CreateGnlConfig | { gnl: ReturnType<typeof createGnl> },\n opts: CreateChatRouteOptions = {},\n): Hono {\n const gnl = 'gnl' in config ? config.gnl : createGnl(config);\n // A route that cannot name anyone, in production, said nothing about it. Every run it starts is\n // born ownerless — and an ownership gate with no owner to compare against passes (registry.ts's\n // ownershipDenied takes the `!owner` branch), so the protection reads as present and is not.\n // WARN, never throw: an existing deployment that has decided its own boundary lives in front of\n // this route is not broken, and a framework that refuses to start over a posture question would be\n // discovered at the worst possible moment. Once, at construction, addressed and with the fix in it.\n if (process.env.NODE_ENV === 'production' && !opts.identity && !opts.resolveResourceId) {\n console.warn(\n '[gnl chat-route] no `identity` and no `resolveResourceId` in production — runs will be born ownerless; ' +\n 'ownership gates stay fail-open (a run with no owner is refused to nobody). Pass `identity: (req) => ({ resourceId })` ' +\n 'reading your session/JWT — never the request body.',\n );\n }\n const app = new Hono();\n app.post('/agents/:name/chat', async (c) => {\n const name = c.req.param('name');\n const body = (await c.req.json().catch(() => ({}))) as {\n id?: string;\n messages?: UIMessage[];\n runId?: string;\n threadId?: string;\n approvals?: Record<string, boolean>;\n context?: Record<string, unknown>;\n };\n const lastMsg = body.messages?.[body.messages.length - 1];\n // Bir kez çözülür: hem mühür hem resourceId aynı değeri kullansın. İsteğe bakan bir çözücüyü\n // iki kez çağırmak, iki farklı cevap alma ihtimali demektir. `identity` de aynı sebeple tek çağrı:\n // iki alanı birden besliyor, ikisi ayrı çağrıdan gelirse ayrı cevaplardan gelebilir.\n // KİMLİK ARTIK ÖNDE ÇÖZÜLÜYOR: aşağıdaki anahtar kararı özneyi bilmeden verilemiyor.\n const ident = await opts.identity?.(c.req.raw);\n const subject = opts.resolveResourceId?.(c, body) ?? ident?.resourceId;\n // WHICH ORGANIZATION. Only `identity` can answer it — there is no `resolveOrgId` hook and there\n // will not be one; the hook that already resolves the subject from a verified session is the\n // right place for the boundary that CONTAINS the subject. Never read from the body: an org is an\n // isolation boundary, and a caller who picks their own has none (sealRequestContext strips the\n // reserved key for exactly this reason).\n const org = ident?.orgId;\n // A RAW id, when the caller is holding one. `body.runId` and `resolveRunId` both name an ID (the\n // second one says so in its name), so neither is promoted — a host that hands us an id has\n // already decided the addressing.\n const rawId = body.runId ?? opts.resolveRunId?.(c, body);\n // THE TURN'S NAME. `${body.id}:${lastMessage.id}` is what this route has always derived, and the\n // `Idempotency-Key` header is the same declaration arriving from a gateway instead. Deliberately\n // AFTER the two raw resolvers (heyet kararı 1.5): the header is often stamped by a proxy, while a\n // body/host decision is explicit, and header-first would let an intermediary redefine the turn.\n const workName = c.req.header('Idempotency-Key') ?? (body.id && lastMsg?.id ? `${body.id}:${lastMsg.id}` : undefined);\n let runId: string | undefined = rawId;\n let workKey: string | undefined;\n if (!runId && workName) {\n // THE PROMOTION, and the concession that comes with it (package #5, §7).\n //\n // With a subject, the turn's name is a `workKey`: the engine derives `run1_<digest>` and the\n // client-controlled string stops being a journal key prefix. That closes bug class 2 (§2) at\n // its documented source — `conv` and `conv:msg1` were exactly this derivation's shape.\n //\n // WITHOUT a subject it stays what it has always been: a raw runId. Deriving a name into an id\n // needs an ADDRESS (§6, fail-closed), and this route ships with no auth and a `useChat`\n // quickstart that names nobody. Refusing those would replace a working first five minutes\n // with a 400 — so the anonymous regime is preserved byte for byte, and the protection matrix's\n // identity row is where a deployment reads which of the two it is in.\n if (subject) {\n try {\n const identity = resolveWorkIdentity(`agent:${name}`, {\n workKey: workName,\n // The agent's own declaration, read off the registry rather than guessed: the route has\n // no business deciding which address a name is unique within (see AgentConfig.workScope).\n scopeKind: gnl.agent(name).workScope ?? 'resource',\n resourceId: subject,\n // THE ORG, and it is not optional decoration — @gnldev/server passes it on the REST\n // route, and an `'org'` workScope with no org falls back to the deployment sentinel\n // (§10.2). Without this line the same organization's same named work derives one id\n // through REST and another through here: not an error, a DUPLICATE, decided by whichever\n // surface the request came in on.\n ...(org ? { orgId: org } : {}),\n anonymous: 'refuse',\n surface: `POST /agents/${name}/chat`,\n });\n runId = identity.runId!;\n workKey = identity.work?.workKey;\n } catch (e: any) {\n // An unknown agent, or a scope that cannot be addressed. There is no run to correlate with,\n // so no X-Gnl-Run-Id either — the header names a run, and there is none.\n return c.json({ error: String(e?.message ?? e) }, 400);\n }\n } else {\n runId = workName;\n }\n }\n if (!runId) {\n // Anon fallback: a fresh id per request = ZERO dedup — a network retry of this exact request\n // Runs the turn again. Deliberately NOT content-hashed (two intentional identical requests must\n // stay two runs); the fix is the contract, not magic: the response's X-Gnl-Run-Id header hands\n // the client the key to retry with.\n // Sayaç MODÜL düzeyinde: her replika kendi sıfırından sayar. Ortak journal üstünde iki\n // süreç aynı milisaniyede `chat-<ms>-0` üretir ve İKİ FARKLI kullanıcının isteği tek koşuma\n // düşer — dedup yokluğu değil, YANLIŞ dedup: ikinci istek birincinin adımlarını replay eder.\n // Süreç-dışı entropi bunu kapatır. Doğru çözüm hâlâ istikrarlı bir runId GÖNDERMEK; aşağıdaki\n // uyarı onu söylüyor, bu satır yalnız çarpışmayı engelliyor.\n runId = `chat-${Date.now()}-${anonCounter++}-${crypto.randomUUID().slice(0, 8)}`;\n console.warn(\n `[gnl chat-route] no runId derivable (body.runId / resolveRunId / body.id+message.id all absent) — generated '${runId}'. Retries of this request will NOT dedupe; send body.runId (echoed back as X-Gnl-Run-Id) so retries land on the same run.`,\n );\n }\n // The conversation id (NOT the per-turn runId) anchors memory — see the runId note in the JSDoc.\n // `identity` sits BELOW the dedicated resolver and ABOVE the body: it is server-derived, the body\n // is not, so it must not be overridable by what the caller sent.\n const threadId = opts.resolveThreadId?.(c, body) ?? ident?.threadId ?? body.threadId ?? body.id ?? runId;\n // V1: `tools` is not passed to convertToModelMessages — a conversation whose CLIENT-side history\n // still carries tool-invocation parts from a prior turn round-trips as best-effort (text/reasoning\n // are unaffected). Fine for the common case (server-side history via toUIMessages + threadId memory\n // is the durable source of truth); documented rather than silently assumed complete.\n // AWAITED: `convertToModelMessages` is async in AI SDK 7 (it was synchronous in v5). Passing the\n // un-awaited Promise straight through as `messages` sent a Promise into the run — the journal\n // then tried to structuredClone it and every chat request failed with\n // \"#<Promise> could not be cloned\", i.e. a flat 400 on the whole route.\n // INSIDE the try below: conversion throws on CLIENT-controlled input (a malformed `messages`\n // shape, an unsupported part type) — outside the try that surfaced as Hono's bare 500 with no\n // typed body and NO X-Gnl-Run-Id, breaking the every-response header contract on exactly the\n // malformed-request path. In the catch it falls through typedErrorResponse (no match) to the\n // generic 400, which is what a malformed request is.\n // FAZ-2 default lock: acquired by streamDurable BEFORE any setup work, released on stream\n // finish/error — the loser throws RunBusyError synchronously into the catch below (409 run_busy).\n const lock =\n opts.lock === false\n ? undefined\n : { owner: `chat-${crypto.randomUUID()}`, ttlMs: opts.lock?.ttlMs ?? DEFAULT_LOCK_TTL_MS };\n let result: any;\n try {\n const messages = await convertToModelMessages(body.messages ?? []);\n result = await gnl.stream(name, {\n // The NAME when the turn key was promoted, the raw id otherwise. The door re-resolves the\n // same tuple and lands on the same id — passing the id instead would drop the declaration,\n // and the declaration is what makes \"which run was this turn?\" answerable in Studio.\n ...(workKey !== undefined ? { workKey } : { runId }),\n messages,\n threadId,\n approvals: body.approvals,\n // HER ZAMAN mühürlü — kimlik bilinmese bile. Ayrılmış anahtarlar motorun \"bunu sunucu\n // doğruladı\" kanalıdır; mühürsüz bir gövde o kanalın sahibi olur. Kimlik yoksa anahtarlar\n // silinir (fail-closed), çözücü varsa sunucunun değeri yazılır.\n // The org travels in the SEAL as well as into the derivation above — the two must not\n // disagree. `sealRequestContext` writes the reserved `__gnl_orgId`/`org` keys from what the\n // SERVER established, so an org-scoped run's record and its dynamic `system`/`tools` see the\n // same organization the id was derived under, and a body that named its own is stripped.\n context: sealRequestContext(body.context ?? {}, {\n ...(subject ? { resourceId: subject } : {}),\n ...(org ? { orgId: org } : {}),\n }),\n ...(subject ? { resourceId: subject } : {}),\n ...(lock ? { lock } : {}),\n // P0.2 thread the REQUEST's AbortSignal through to generation — a client\n // disconnect (tab close, useChat's `stop()`, navigation away) stops token generation instead of\n // silently billing to completion. This does NOT break resumable-SSE replay: an abort simply ends\n // generation early, the journal keeps whatever prefix already completed, and a LATER call with the\n // SAME runId resumes/replays exactly as before (see registry.ts's RunOptions.abortSignal note).\n abortSignal: c.req.raw.signal,\n });\n } catch (e: any) {\n const res = typedErrorResponse(c, e, workKey) ?? c.json({ error: String(e?.message ?? e) }, 400);\n res.headers.set('X-Gnl-Run-Id', runId);\n return res;\n }\n // Every response (success AND error) echoes the effective runId — the client-side retry key is a\n // contract, not something the caller has to re-derive from useChat internals. The SAME runId is\n // stamped onto `data-gnl-interrupt` chunks (FAZ-2) so an approval addresses THIS run.\n const res = toUIMessageStreamResponse(result, { runId });\n res.headers.set('X-Gnl-Run-Id', runId);\n // FAZ-7: the engine's replay signal — 'replay' when this runId had frozen input before the call\n // (a resume/retry landing on journal state), 'new' on a fresh run. An observability contract for\n // reconciliation, NOT a byte-identity guarantee.\n res.headers.set('X-Gnl-Idempotency-Status', (result as { __gnlPriorRun?: boolean })?.__gnlPriorRun ? 'replay' : 'new');\n return res;\n });\n return app;\n}\n"]}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { toUIMessageStream, toUIMessageStreamResponse } from './ui-stream.js';
|
|
2
|
+
export type { GnlInterruptData, ToUIMessageStreamResponseOptions } from './ui-stream.js';
|
|
3
|
+
export { toUIMessages } from './messages.js';
|
|
4
|
+
export type { ToUIMessagesOptions, Interrupt } from './messages.js';
|
|
5
|
+
export { createChatRoute } from './chat-route.js';
|
|
6
|
+
export type { CreateChatRouteOptions } from './chat-route.js';
|
|
7
|
+
export { maskSentinelOutput } from './sentinel-mask.js';
|
|
8
|
+
export type { MaskedToolOutput } from './sentinel-mask.js';
|
|
9
|
+
export { approvalPayload, approve } from './approve.js';
|
|
10
|
+
export type { ApprovableInterrupt, ApproveOptions } from './approve.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// @gnldev/chat-adapter — public export surface. Vercel AI SDK `useChat` (v5) compatibility for @gnldev/durable
|
|
2
|
+
// Agents: sentinel-masked UI message streaming, journal→UIMessage history reconstruction, and a Hono
|
|
3
|
+
// Chat route (parity with @gnldev/agui's AG-UI adapter, @gnldev/server's own SSE schema).
|
|
4
|
+
export { toUIMessageStream, toUIMessageStreamResponse } from './ui-stream.js';
|
|
5
|
+
export { toUIMessages } from './messages.js';
|
|
6
|
+
export { createChatRoute } from './chat-route.js';
|
|
7
|
+
export { maskSentinelOutput } from './sentinel-mask.js';
|
|
8
|
+
export { approvalPayload, approve } from './approve.js';
|
|
9
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,+GAA+G;AAC/G,qGAAqG;AACrG,0FAA0F;AAC1F,OAAO,EAAE,iBAAiB,EAAE,yBAAyB,EAAE,MAAM,gBAAgB,CAAC;AAG9E,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAG7C,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAGlD,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAGxD,OAAO,EAAE,eAAe,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC","sourcesContent":["// @gnldev/chat-adapter — public export surface. Vercel AI SDK `useChat` (v5) compatibility for @gnldev/durable\n// Agents: sentinel-masked UI message streaming, journal→UIMessage history reconstruction, and a Hono\n// Chat route (parity with @gnldev/agui's AG-UI adapter, @gnldev/server's own SSE schema).\nexport { toUIMessageStream, toUIMessageStreamResponse } from './ui-stream.js';\nexport type { GnlInterruptData, ToUIMessageStreamResponseOptions } from './ui-stream.js';\n\nexport { toUIMessages } from './messages.js';\nexport type { ToUIMessagesOptions, Interrupt } from './messages.js';\n\nexport { createChatRoute } from './chat-route.js';\nexport type { CreateChatRouteOptions } from './chat-route.js';\n\nexport { maskSentinelOutput } from './sentinel-mask.js';\nexport type { MaskedToolOutput } from './sentinel-mask.js';\n\nexport { approvalPayload, approve } from './approve.js';\nexport type { ApprovableInterrupt, ApproveOptions } from './approve.js';\n"]}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Interrupt, JournalEntry, ReconstructSeed } from '@gnldev/durable';
|
|
2
|
+
import type { UIMessage } from 'ai';
|
|
3
|
+
export interface ToUIMessagesOptions {
|
|
4
|
+
/**
|
|
5
|
+
* The run's invisible `:input` record (see persistInput/runKeys.input in run.ts — NOT returned by
|
|
6
|
+
* `journal.readRun()`), fetched separately by the caller and passed through unchanged: same shape as
|
|
7
|
+
* `reconstructState`'s own `seed` parameter.
|
|
8
|
+
*/
|
|
9
|
+
seed?: ReconstructSeed;
|
|
10
|
+
/** Prefix for deterministic UIMessage ids (`${runId}:msg:${i}`). Defaults to `'run'`. */
|
|
11
|
+
runId?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Reconstructs `useChat`-compatible `UIMessage[]` from a run's journal entries (`journal.readRun(runId)`).
|
|
15
|
+
* Mapping: user/assistant text → `text` parts; assistant tool-call + its resolved tool-result → ONE
|
|
16
|
+
* `tool-${toolName}` part (`state: 'output-available'`, sentinel-masked the SAME way as the live stream
|
|
17
|
+
* see sentinel-mask.ts); an assistant tool-call with NO tool-result yet → `state: 'input-available'`;
|
|
18
|
+
* Reasoning content parts → `reasoning` parts. `tool`-role journal-derived messages are never emitted as
|
|
19
|
+
* their own `UIMessage` — they're merged into the owning assistant message's tool part.
|
|
20
|
+
*/
|
|
21
|
+
export declare function toUIMessages(entries: JournalEntry[], opts?: ToUIMessagesOptions): UIMessage[];
|
|
22
|
+
export type { Interrupt };
|
package/dist/messages.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Journal history → `useChat({ messages: … })`-shaped `UIMessage[]`. Built on @gnldev/durable's
|
|
2
|
+
// `reconstructState` (packages/durable/src/time-travel.ts) — a PURE walk over `JournalEntry[]` that
|
|
3
|
+
// already solves the hard part (matching tool-calls to tool-results, including the args-mode
|
|
4
|
+
// idempotency dedup cases documented at the top of time-travel.ts). We deliberately do NOT touch
|
|
5
|
+
// time-travel.ts (shared, sensitive file with its own test suite) — this module only re-derives what it
|
|
6
|
+
// needs ON TOP of `reconstructState`'s output.
|
|
7
|
+
//
|
|
8
|
+
// V1 HONESTY NOTE (documented limitation, not silently dropped): `reconstructState`'s own assistant-
|
|
9
|
+
// message shape only special-cases `type: 'text'` and `type: 'tool-call'` content parts (see
|
|
10
|
+
// time-travel.ts ~151-153) — it drops `reasoning` parts. To recover reasoning (and to correctly handle
|
|
11
|
+
// STREAMED runs, whose journal shape is `{ parts, rest }` rather than `{ content }` — see
|
|
12
|
+
// durable-model.ts's wrapStream/wrapGenerate split), this module re-reads the RAW `model` journal entries
|
|
13
|
+
// itself (paired 1:1, in order, with reconstructState's assistant-role messages — see `seedLen` below)
|
|
14
|
+
// instead of relying on reconstructState's simplified content. Files/sources ARE dropped for v1 (no
|
|
15
|
+
// `file`/`source` UIMessage parts are produced) — a real limitation, called out here rather than papered
|
|
16
|
+
// over.
|
|
17
|
+
import { reconstructState, settleModelContent } from '@gnldev/durable';
|
|
18
|
+
import { maskSentinelOutput } from './sentinel-mask.js';
|
|
19
|
+
function parseToolInput(input) {
|
|
20
|
+
if (typeof input !== 'string')
|
|
21
|
+
return input;
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(input);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return input; // best-effort — see time-travel.ts's own `parseModelInput` for the same fallback policy
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function textFromContent(content) {
|
|
30
|
+
if (typeof content === 'string')
|
|
31
|
+
return content;
|
|
32
|
+
if (Array.isArray(content))
|
|
33
|
+
return content.filter((p) => p?.type === 'text').map((p) => p.text ?? '').join('');
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
// F2 — durability review: the local `contentFromModelValue` copy was verified byte-for-byte
|
|
37
|
+
// semantically identical to durable's `settleModelContent` (both settle `{content}` AND streamed
|
|
38
|
+
// `{parts,rest}` records, both emit text/reasoning/tool-call) — so the copy was deleted and the
|
|
39
|
+
// shared implementation is imported above. One source; the two can no longer drift.
|
|
40
|
+
/**
|
|
41
|
+
* Reconstructs `useChat`-compatible `UIMessage[]` from a run's journal entries (`journal.readRun(runId)`).
|
|
42
|
+
* Mapping: user/assistant text → `text` parts; assistant tool-call + its resolved tool-result → ONE
|
|
43
|
+
* `tool-${toolName}` part (`state: 'output-available'`, sentinel-masked the SAME way as the live stream
|
|
44
|
+
* see sentinel-mask.ts); an assistant tool-call with NO tool-result yet → `state: 'input-available'`;
|
|
45
|
+
* Reasoning content parts → `reasoning` parts. `tool`-role journal-derived messages are never emitted as
|
|
46
|
+
* their own `UIMessage` — they're merged into the owning assistant message's tool part.
|
|
47
|
+
*/
|
|
48
|
+
export function toUIMessages(entries, opts = {}) {
|
|
49
|
+
const runId = opts.runId ?? 'run';
|
|
50
|
+
const { messages } = reconstructState(entries, entries.length, opts.seed);
|
|
51
|
+
// Mirrors reconstructState's OWN seed-prepend rule (time-travel.ts) so we know which leading slice of
|
|
52
|
+
// `messages` came from the seed (pre-existing/rolled-over history — no raw provider parts available
|
|
53
|
+
// for it here) vs. which came from actually walking `entries` (and can be paired with a raw `model`
|
|
54
|
+
// journal entry below).
|
|
55
|
+
const seedLen = Array.isArray(opts.seed?.messages) ? opts.seed.messages.length : typeof opts.seed?.prompt === 'string' ? 1 : 0;
|
|
56
|
+
const modelEntries = entries.filter((e) => e.kind === 'model');
|
|
57
|
+
let modelIdx = 0;
|
|
58
|
+
// ToolCallId -> raw (unmasked) tool-result output. Built once from every 'tool'-role message
|
|
59
|
+
// ReconstructState produced — position doesn't matter, toolCallId is unique within a run. A SUSPENDED
|
|
60
|
+
// tool-call still gets an entry here (reconstructState renders a 'tool' message for it too — see
|
|
61
|
+
// time-travel.ts PASS 3 — with `output` being the raw `__gnl_suspend` sentinel), which is exactly what
|
|
62
|
+
// lets the masking below apply to suspended-in-history tool calls, not just live ones.
|
|
63
|
+
const toolOutputs = new Map();
|
|
64
|
+
for (const m of messages) {
|
|
65
|
+
if (m.role !== 'tool')
|
|
66
|
+
continue;
|
|
67
|
+
for (const p of m.content ?? []) {
|
|
68
|
+
if (p?.type === 'tool-result')
|
|
69
|
+
toolOutputs.set(p.toolCallId, p.output);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const out = [];
|
|
73
|
+
messages.forEach((m, i) => {
|
|
74
|
+
if (m.role === 'tool')
|
|
75
|
+
return; // merged into the owning assistant message's tool part (see toolOutputs above)
|
|
76
|
+
const id = `${runId}:msg:${i}`;
|
|
77
|
+
if (m.role !== 'assistant') {
|
|
78
|
+
out.push({
|
|
79
|
+
id,
|
|
80
|
+
role: m.role === 'system' ? 'system' : 'user',
|
|
81
|
+
parts: [{ type: 'text', text: textFromContent(m.content) }],
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const rawContent = i >= seedLen ? settleModelContent(modelEntries[modelIdx++]?.value) : undefined;
|
|
86
|
+
const contentForParts = rawContent ?? (Array.isArray(m.content) ? m.content : []);
|
|
87
|
+
const parts = [];
|
|
88
|
+
for (const p of contentForParts) {
|
|
89
|
+
if (p?.type === 'text' && p.text) {
|
|
90
|
+
parts.push({ type: 'text', text: p.text, state: 'done' });
|
|
91
|
+
}
|
|
92
|
+
else if (p?.type === 'reasoning' && p.text) {
|
|
93
|
+
parts.push({ type: 'reasoning', text: p.text, state: 'done' });
|
|
94
|
+
}
|
|
95
|
+
else if (p?.type === 'tool-call') {
|
|
96
|
+
const toolCallId = p.toolCallId;
|
|
97
|
+
const toolName = p.toolName;
|
|
98
|
+
const input = parseToolInput(p.input);
|
|
99
|
+
if (toolOutputs.has(toolCallId)) {
|
|
100
|
+
const { display } = maskSentinelOutput(toolOutputs.get(toolCallId), toolName);
|
|
101
|
+
parts.push({ type: `tool-${toolName}`, toolCallId, state: 'output-available', input, output: display });
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
// No tool-result journaled at all yet (still mid-flight — distinct from a SUSPENDED record,
|
|
105
|
+
// which DOES have an entry in `toolOutputs`, see the note above).
|
|
106
|
+
parts.push({ type: `tool-${toolName}`, toolCallId, state: 'input-available', input });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (parts.length === 0)
|
|
111
|
+
parts.push({ type: 'text', text: '', state: 'done' });
|
|
112
|
+
out.push({ id, role: 'assistant', parts });
|
|
113
|
+
});
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=messages.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"messages.js","sourceRoot":"","sources":["../src/messages.ts"],"names":[],"mappings":"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","sourcesContent":["// Journal history → `useChat({ messages: … })`-shaped `UIMessage[]`. Built on @gnldev/durable's\n// `reconstructState` (packages/durable/src/time-travel.ts) — a PURE walk over `JournalEntry[]` that\n// already solves the hard part (matching tool-calls to tool-results, including the args-mode\n// idempotency dedup cases documented at the top of time-travel.ts). We deliberately do NOT touch\n// time-travel.ts (shared, sensitive file with its own test suite) — this module only re-derives what it\n// needs ON TOP of `reconstructState`'s output.\n//\n// V1 HONESTY NOTE (documented limitation, not silently dropped): `reconstructState`'s own assistant-\n// message shape only special-cases `type: 'text'` and `type: 'tool-call'` content parts (see\n// time-travel.ts ~151-153) — it drops `reasoning` parts. To recover reasoning (and to correctly handle\n// STREAMED runs, whose journal shape is `{ parts, rest }` rather than `{ content }` — see\n// durable-model.ts's wrapStream/wrapGenerate split), this module re-reads the RAW `model` journal entries\n// itself (paired 1:1, in order, with reconstructState's assistant-role messages — see `seedLen` below)\n// instead of relying on reconstructState's simplified content. Files/sources ARE dropped for v1 (no\n// `file`/`source` UIMessage parts are produced) — a real limitation, called out here rather than papered\n// over.\nimport { reconstructState, settleModelContent } from '@gnldev/durable';\nimport type { Interrupt, JournalEntry, ReconstructSeed } from '@gnldev/durable';\nimport type { UIMessage } from 'ai';\nimport { maskSentinelOutput } from './sentinel-mask.js';\n\nexport interface ToUIMessagesOptions {\n /**\n * The run's invisible `:input` record (see persistInput/runKeys.input in run.ts — NOT returned by\n * `journal.readRun()`), fetched separately by the caller and passed through unchanged: same shape as\n * `reconstructState`'s own `seed` parameter.\n */\n seed?: ReconstructSeed;\n /** Prefix for deterministic UIMessage ids (`${runId}:msg:${i}`). Defaults to `'run'`. */\n runId?: string;\n}\n\nfunction parseToolInput(input: unknown): unknown {\n if (typeof input !== 'string') return input;\n try {\n return JSON.parse(input);\n } catch {\n return input; // best-effort — see time-travel.ts's own `parseModelInput` for the same fallback policy\n }\n}\n\nfunction textFromContent(content: unknown): string {\n if (typeof content === 'string') return content;\n if (Array.isArray(content)) return content.filter((p: any) => p?.type === 'text').map((p: any) => p.text ?? '').join('');\n return '';\n}\n\n// F2 — durability review: the local `contentFromModelValue` copy was verified byte-for-byte\n// semantically identical to durable's `settleModelContent` (both settle `{content}` AND streamed\n// `{parts,rest}` records, both emit text/reasoning/tool-call) — so the copy was deleted and the\n// shared implementation is imported above. One source; the two can no longer drift.\n\n/**\n * Reconstructs `useChat`-compatible `UIMessage[]` from a run's journal entries (`journal.readRun(runId)`).\n * Mapping: user/assistant text → `text` parts; assistant tool-call + its resolved tool-result → ONE\n * `tool-${toolName}` part (`state: 'output-available'`, sentinel-masked the SAME way as the live stream\n * see sentinel-mask.ts); an assistant tool-call with NO tool-result yet → `state: 'input-available'`;\n * Reasoning content parts → `reasoning` parts. `tool`-role journal-derived messages are never emitted as\n * their own `UIMessage` — they're merged into the owning assistant message's tool part.\n */\nexport function toUIMessages(entries: JournalEntry[], opts: ToUIMessagesOptions = {}): UIMessage[] {\n const runId = opts.runId ?? 'run';\n const { messages } = reconstructState(entries, entries.length, opts.seed);\n\n // Mirrors reconstructState's OWN seed-prepend rule (time-travel.ts) so we know which leading slice of\n // `messages` came from the seed (pre-existing/rolled-over history — no raw provider parts available\n // for it here) vs. which came from actually walking `entries` (and can be paired with a raw `model`\n // journal entry below).\n const seedLen = Array.isArray(opts.seed?.messages) ? opts.seed!.messages!.length : typeof opts.seed?.prompt === 'string' ? 1 : 0;\n\n const modelEntries = entries.filter((e) => e.kind === 'model');\n let modelIdx = 0;\n\n // ToolCallId -> raw (unmasked) tool-result output. Built once from every 'tool'-role message\n // ReconstructState produced — position doesn't matter, toolCallId is unique within a run. A SUSPENDED\n // tool-call still gets an entry here (reconstructState renders a 'tool' message for it too — see\n // time-travel.ts PASS 3 — with `output` being the raw `__gnl_suspend` sentinel), which is exactly what\n // lets the masking below apply to suspended-in-history tool calls, not just live ones.\n const toolOutputs = new Map<string, unknown>();\n for (const m of messages) {\n if (m.role !== 'tool') continue;\n for (const p of (m.content as any[]) ?? []) {\n if (p?.type === 'tool-result') toolOutputs.set(p.toolCallId, p.output);\n }\n }\n\n const out: UIMessage[] = [];\n messages.forEach((m: any, i: number) => {\n if (m.role === 'tool') return; // merged into the owning assistant message's tool part (see toolOutputs above)\n const id = `${runId}:msg:${i}`;\n if (m.role !== 'assistant') {\n out.push({\n id,\n role: m.role === 'system' ? 'system' : 'user',\n parts: [{ type: 'text', text: textFromContent(m.content) }],\n } as UIMessage);\n return;\n }\n const rawContent = i >= seedLen ? settleModelContent(modelEntries[modelIdx++]?.value) : undefined;\n const contentForParts: any[] = rawContent ?? (Array.isArray(m.content) ? m.content : []);\n const parts: UIMessage['parts'] = [];\n for (const p of contentForParts) {\n if (p?.type === 'text' && p.text) {\n parts.push({ type: 'text', text: p.text, state: 'done' });\n } else if (p?.type === 'reasoning' && p.text) {\n parts.push({ type: 'reasoning', text: p.text, state: 'done' });\n } else if (p?.type === 'tool-call') {\n const toolCallId: string = p.toolCallId;\n const toolName: string = p.toolName;\n const input = parseToolInput(p.input);\n if (toolOutputs.has(toolCallId)) {\n const { display } = maskSentinelOutput(toolOutputs.get(toolCallId), toolName);\n parts.push({ type: `tool-${toolName}`, toolCallId, state: 'output-available', input, output: display } as any);\n } else {\n // No tool-result journaled at all yet (still mid-flight — distinct from a SUSPENDED record,\n // which DOES have an entry in `toolOutputs`, see the note above).\n parts.push({ type: `tool-${toolName}`, toolCallId, state: 'input-available', input } as any);\n }\n }\n }\n if (parts.length === 0) parts.push({ type: 'text', text: '', state: 'done' });\n out.push({ id, role: 'assistant', parts } as UIMessage);\n });\n\n return out;\n}\n\n// Re-exported for callers who want to inspect a raw Interrupt shape alongside toUIMessages' output\n// (e.g. to render an approval banner keyed by toolCallId) without a separate @gnldev/durable import.\nexport type { Interrupt };\n"]}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Interrupt } from '@gnldev/durable';
|
|
2
|
+
export interface MaskedToolOutput {
|
|
3
|
+
/** What the client should see in place of the raw tool output — either the untouched original value,
|
|
4
|
+
* Or a masked `{ pending: 'approval', ... }` / `{ blocked: true, ... }` replacement. */
|
|
5
|
+
display: unknown;
|
|
6
|
+
/**
|
|
7
|
+
* Present only when `display` masked a `__gnl_suspend` sentinel — the question(s) a human can
|
|
8
|
+
* actually answer, for the caller to surface via a `data-gnl-interrupt` chunk (ui-stream.ts) or
|
|
9
|
+
* equivalent.
|
|
10
|
+
*
|
|
11
|
+
* A LIST, and the singular it replaces was not a style choice — it was structurally short. When a
|
|
12
|
+
* delegated sub-agent hits a human gate, the PARENT's record suspends too, and that sentinel is
|
|
13
|
+
* necessarily keyed by the parent's toolCallId: a proxy id with no question behind it. The engine
|
|
14
|
+
* answers this in one place (`surfacedInterrupts`) by surfacing the CHILD's interrupts instead —
|
|
15
|
+
* and a child run can be sitting on more than one. Handing back the first and dropping the rest
|
|
16
|
+
* would leave a client that approved everything it was shown still suspended.
|
|
17
|
+
*
|
|
18
|
+
* Ordinary suspends are unaffected: one interrupt in, a one-element array out, same fields.
|
|
19
|
+
*/
|
|
20
|
+
interrupts?: Interrupt[];
|
|
21
|
+
/**
|
|
22
|
+
* @deprecated Use `interrupts`. Kept because this field shipped, and filled with `interrupts[0]`
|
|
23
|
+
* so existing readers keep working — but it CANNOT represent a nested suspend carrying more than
|
|
24
|
+
* one child question, which is the reason `interrupts` exists. Reading it is reading the first
|
|
25
|
+
* question and silently ignoring the others.
|
|
26
|
+
*/
|
|
27
|
+
interrupt?: Interrupt;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Masks a tool-result `output` value if (and only if) it carries one of the three sentinels; passes
|
|
31
|
+
* everything else through UNCHANGED. `toolNameHint` backfills `toolName` for a suspend sentinel that
|
|
32
|
+
* (in older journal records) might not carry its own `toolName` field.
|
|
33
|
+
*/
|
|
34
|
+
export declare function maskSentinelOutput(output: unknown, toolNameHint?: string): MaskedToolOutput;
|