@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 ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 Karaca Yılmaz (https://gnl.dev)
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,181 @@
1
+ # @gnldev/chat-adapter
2
+
3
+ Compatibility with the Vercel AI SDK's UI layer: run a durable agent on the server, render it with
4
+ `useChat` on the client.
5
+
6
+ ## Install
7
+
8
+ > Install: `pnpm add @gnldev/chat-adapter` — or use it from a [repo clone](https://github.com/Karaca7/gnldev): `pnpm install && pnpm -r build`.
9
+
10
+ ```bash
11
+ npm i @gnldev/chat-adapter
12
+ ```
13
+
14
+ ## Chat route
15
+
16
+ ```ts
17
+ import { createChatRoute } from '@gnldev/chat-adapter';
18
+
19
+ app.route('/api', createChatRoute({ gnl }));
20
+ // POST /api/agents/:name/chat — useChat({ api: '/api/agents/pay/chat' }) works unchanged.
21
+ ```
22
+
23
+ The route streams back in the AI SDK's UI-message format, so an existing `useChat` frontend works
24
+ unchanged — while the run behind it is journaled, replayable, and its side effects are
25
+ [at-most-once](../durable/README.md#what-never-charged-twice-actually-means).
26
+
27
+ You normally do NOT need `resolveRunId`: the default derivation `${body.id}:${lastMessage.id}` gives
28
+ one durable run PER TURN (a network retry of the same turn replays; a new turn runs fresh).
29
+ Anti-pattern to avoid: `resolveRunId: (_c, body) => body.id` — useChat's `body.id` is stable for the
30
+ WHOLE conversation, so every later turn would replay turn 1 from the journal forever. What that
31
+ derived string *is* — a name the engine hashes into an id, or the id itself — depends on whether the
32
+ route can name the user; see [the idempotency contract](#the-idempotency-contract).
33
+
34
+ ## Who is this request for? (`identity` / `resolveResourceId`)
35
+
36
+ This route ships with **no auth of its own** — deliberately, and the same posture as `@gnldev/agui`.
37
+ What that leaves you responsible for is one thing: naming the end user each run acts for.
38
+
39
+ **Why it is not optional.** GNL has no end-user identity of its own. An end user is a *subject* a
40
+ trusted application names, not a principal GNL authenticates. The engine treats a few reserved
41
+ context keys as "the server established this" — and an early version of this route forwarded
42
+ `body.context` verbatim, so the reserved key arrived from whoever sent the request. Measured against
43
+ a running app: a plain POST carrying `{"context":{"__gnl_resourceId":"VICTIM"}}` produced a run owned
44
+ by that name, and the ownership stamp followed it.
45
+
46
+ The route now **always seals** the context, so that specific forgery is closed whether or not you
47
+ pass a resolver. What a resolver decides is the other half: whether the run has an owner at all.
48
+
49
+ ```ts
50
+ import { createChatRoute } from '@gnldev/chat-adapter';
51
+
52
+ const chat = createChatRoute({ gnl }, {
53
+ // ONE hook for both fields. The same signature @gnldev/agui's route takes.
54
+ identity: (req) => {
55
+ const session = db.sessions.get(req.headers.get('cookie')); // YOUR session store
56
+ return session ? { resourceId: session.userId, threadId: session.conversationId } : undefined;
57
+ },
58
+ });
59
+ ```
60
+
61
+ `identity` receives the **web `Request`**, not the Hono context, so a host bridging this route from
62
+ Express or Fastify can use it. It is called once per request and may return `undefined`.
63
+
64
+ **Read it from something the server trusts** — a session cookie, a verified JWT,
65
+ `principalOf(req)?.id` — and **never from the request body**. A body-supplied subject is the caller
66
+ naming whoever they like, which is the hole the context seal exists to close.
67
+
68
+ **If you give none.** Nothing is asserted and nothing is forged: runs are born **ownerless**. That is
69
+ safe against impersonation and weak in the other direction — an ownership gate with no owner to
70
+ compare against refuses nobody, so the protection reads as present and is not. Memory also has
71
+ nothing to scope on, so per-user recall and `listThreads` have no subject to key by. In
72
+ `NODE_ENV=production` the route says so once, at construction, with a `console.warn` — it never
73
+ throws, because a deployment whose boundary genuinely lives in front of this route is not broken.
74
+
75
+ **Precedence**, field by field:
76
+
77
+ | Field | Order |
78
+ |---|---|
79
+ | `resourceId` | `resolveResourceId(c, body)` → `identity(req).resourceId` → *(none)* |
80
+ | `threadId` | `resolveThreadId(c, body)` → `identity(req).threadId` → `body.threadId` → `body.id` → the runId |
81
+
82
+ `resolveResourceId` / `resolveThreadId` still win: they are the existing contract, and a newer
83
+ convenience must not quietly take a working deployment's answer away. `identity` outranks the body,
84
+ because it is server-derived and the body is not.
85
+
86
+ **Honest bound.** A resolver reading an *unauthenticated* request asserts a subject nobody verified.
87
+ Put auth in front of this route — or compose `@gnldev/server`'s `createRestApi` auth middleware
88
+ around it — or the subject is only as trustworthy as the caller.
89
+
90
+ ## The idempotency contract
91
+
92
+ **Two regimes, decided by whether the route can name a subject.** The per-turn key
93
+ (`${body.id}:${lastMessage.id}`, or an `Idempotency-Key` header when a gateway sends one) is this
94
+ route's name for *the work this turn is*. When `identity` / `resolveResourceId` gives that turn an
95
+ owner, the key is promoted to a **`workKey`**: the engine derives the run's id from it
96
+ (`run1_<digest>`) and the string you sent stops being a journal key. When there is nobody to name —
97
+ the anonymous quickstart, no auth, no session store — the same string stays the raw runId it has
98
+ always been, byte for byte. Deriving an id from a name needs an *address* to make it unique within,
99
+ and a route with no subject has none; refusing those requests would replace a working first five
100
+ minutes with an error message. Your retry contract is identical in both: the same message ids
101
+ produce the same key, and the same key lands on the same run.
102
+
103
+ One migration note, because the regime is decided by the resolver: **adopting this version — or
104
+ wiring `identity` into a deployment that ran without it — changes which id an in-flight turn's retry
105
+ lands on** (raw key on the old pods, `run1_` on the new). During that window a retried turn can run
106
+ once more. Close the window by draining in-flight requests over the deploy rather than rolling
107
+ through it.
108
+
109
+ - **`X-Gnl-Run-Id` on every response** (success and error): the opaque id of the run this call
110
+ landed on — a **correlation handle** for logs, traces and Studio. It is **not your retry key**: to
111
+ retry, send the same turn again (the same conversation id and the same last-message id). Explicit
112
+ `body.runId` and `resolveRunId` still win, and both stay raw — they name an *id*, and a host
113
+ holding one has already decided the addressing. The `Idempotency-Key` header is read AFTER them (a
114
+ gateway-stamped header must not silently override an application decision).
115
+ - **A per-run lock is ON by default** (`lock: { ttlMs: 300_000 }`): two concurrent requests with the
116
+ same runId (double-click, two tabs, a retry racing the original) no longer both execute — the
117
+ loser gets a typed `409 { code: 'run_busy', resumable: true }` + `Retry-After`, and retrying the
118
+ same runId lands on the journal replay. `lock: false` restores the old behavior. Scope note: this
119
+ serializes CONCURRENT duplicates; serial retries were already deduped by the runId derivation.
120
+ - **`X-Gnl-Idempotency-Status` on success responses**: `new` on a fresh run, `replay` when this
121
+ runId had prior journaled input (a retry/resume landing on journal state) — an observability
122
+ contract for client-side reconciliation, not a byte-identity guarantee.
123
+ - **Typed errors instead of a flat 400**: `run_thread_mismatch` / `run_input_mismatch` /
124
+ `run_actor_mismatch` / `run_swept` → 409 without `resumable` (fix the id, not the request);
125
+ `run_busy` → 409 + `Retry-After`; `retry_limit_exceeded` → 422; upstream provider failures →
126
+ 429/502/504. Malformed `messages` stays a 400 — with the header contract intact.
127
+
128
+ ## Approvals round-trip (`approve` / `approvalPayload`)
129
+
130
+ When a tool suspends (a guard's `require-approval`, the `confirm` field, a duplicate/semantic
131
+ question), the stream carries a `data-gnl-interrupt` chunk whose entries include the **suspended
132
+ run's `runId`** — the approval's ADDRESS. A naive client that just re-POSTs its messages derives a
133
+ FRESH runId from the new last-message id: the approval lands on a brand-new run and the suspended
134
+ one waits forever. Use the helpers:
135
+
136
+ ```ts
137
+ import { approvalPayload, approve } from '@gnldev/chat-adapter';
138
+
139
+ // useChat-style: merge the payload into YOUR next request body (same conversation id, same messages)
140
+ sendMessage(undefined, { body: approvalPayload(interrupt) }); // { runId, approvals: { [toolCallId]: true } }
141
+
142
+ // headless/manual: a convenience fetch that re-POSTs and returns the streaming Response
143
+ await approve('/api/agents/pay/chat', { interrupt, chatId, messages });
144
+ ```
145
+
146
+ `approvalPayload` THROWS on an interrupt without `runId` rather than silently targeting a fresh run.
147
+ Product rules, stated plainly: approval is a BUTTON — the route reads decisions only from
148
+ `body.approvals`; a user typing "yes, do it" starts a fresh turn, it approves nothing. And
149
+ "regenerate" with the same runId gets the journal REPLAY (the safe default); genuinely re-running a
150
+ side effect goes through the approval ladder, never a silent re-execution.
151
+
152
+ ## Rebuilding history from the journal
153
+
154
+ ```ts
155
+ import { toUIMessages } from '@gnldev/chat-adapter';
156
+
157
+ const messages = toUIMessages(await journal.list(runId));
158
+ ```
159
+
160
+ The journal is the source of truth, so a reconnecting client can rebuild the conversation without
161
+ the server holding session state.
162
+
163
+ ## Exports
164
+
165
+ | Export | What it is |
166
+ |---|---|
167
+ | `createChatRoute` | A handler that runs an agent and streams UI messages |
168
+ | `toUIMessageStream` / `toUIMessageStreamResponse` | Converts a durable stream into the UI-message wire format, masking the internal sentinels |
169
+ | `toUIMessages` | Journal records → `UIMessage[]` for history reconstruction |
170
+ | `approvalPayload` / `approve` | The approval round-trip helpers — land the decision on the SUSPENDED run (see above) |
171
+ | `maskSentinelOutput` | The shared sentinel-masking primitive (used by both live streaming and history) |
172
+
173
+ ## A note on history
174
+
175
+ `useChat` posts the entire client-side history on every turn. When the agent has memory and a
176
+ `threadId`, the server owns the history instead — the client's copy is a view, not the record. That
177
+ contract is enforced in the core, not here.
178
+
179
+ ## License
180
+
181
+ Apache-2.0 — see [LICENSE](./LICENSE).
@@ -0,0 +1,32 @@
1
+ /** The minimum an approval needs from a `data-gnl-interrupt` entry (see GnlInterruptData). */
2
+ export interface ApprovableInterrupt {
3
+ toolCallId: string;
4
+ /** The suspended run's id — REQUIRED to route the approval (older servers that don't stamp it
5
+ * Can't be addressed reliably; see `approvalPayload`'s throw). */
6
+ runId?: string;
7
+ }
8
+ /**
9
+ * The body fields that turn a chat POST into an approval of `interrupt`. Merge into the SAME request
10
+ * shape you normally send (same conversation `id`, same `messages`) — runId precedence in the route
11
+ * guarantees these fields win over derivation.
12
+ */
13
+ export declare function approvalPayload(interrupt: ApprovableInterrupt, approved?: boolean): {
14
+ runId: string;
15
+ approvals: Record<string, boolean>;
16
+ };
17
+ export interface ApproveOptions {
18
+ interrupt: ApprovableInterrupt;
19
+ /** The SAME UIMessage history the original turn sent — the journal's frozen input wins on replay,
20
+ * But the route still converts these (and the thread guard needs the same conversation). */
21
+ messages: unknown[];
22
+ /** UseChat's stable conversation id (`body.id`) — REQUIRED when the original turn had one: threadId
23
+ * Defaults to it, and a mismatched thread is refused with 409 run_thread_mismatch. */
24
+ chatId?: string;
25
+ threadId?: string;
26
+ approved?: boolean;
27
+ headers?: Record<string, string>;
28
+ /** Override for non-global-fetch environments/tests. */
29
+ fetchImpl?: typeof fetch;
30
+ }
31
+ /** Re-POSTs the approval to the chat endpoint and returns the raw streaming Response. */
32
+ export declare function approve(endpoint: string, opts: ApproveOptions): Promise<Response>;
@@ -0,0 +1,46 @@
1
+ // FAZ-2 — the approval round-trip, fixed at the CLIENT edge. The failure this closes (heyet kararı
2
+ // 1.8): a suspended tool emits a `data-gnl-interrupt`; the user clicks "approve"; a naive client
3
+ // re-POSTs its messages and the route derives a FRESH runId from the new last-message id — the
4
+ // approval lands on a brand-new run, the suspended run stays suspended forever (retention keeps
5
+ // suspended runs deliberately → unbounded accumulation), and the tool never fires. The fix is
6
+ // addressing: an approval must travel with the SUSPENDED run's id (`interrupt.runId`, stamped by
7
+ // ui-stream.ts) and the SAME conversation identity (`body.id`/threadId — a different thread 409s on
8
+ // the frozen-input guard).
9
+ //
10
+ // Two surfaces, by integration style:
11
+ // `approvalPayload(interrupt)` — pure: the body fields to merge into YOUR request (useChat's
12
+ // `sendMessage(..., { body })`, a custom fetch, a proxy). No I/O, no deps.
13
+ // `approve(endpoint, {...})` — convenience fetch for headless/manual flows: re-POSTs the SAME
14
+ // Messages + the payload and returns the raw streaming Response (the caller renders/drains it).
15
+ //
16
+ // Approval is a BUTTON, not prose: the route reads decisions ONLY from `body.approvals`
17
+ // (chat-route.ts) — a user typing "yes, do it" into the chat input does NOT approve anything, it
18
+ // starts a fresh turn. Likewise "regenerate" on a completed turn re-POSTs the same runId and gets the
19
+ // journal REPLAY (safe default); genuinely re-running a side effect goes through this approval
20
+ // ladder, never through a silent re-execution.
21
+ /**
22
+ * The body fields that turn a chat POST into an approval of `interrupt`. Merge into the SAME request
23
+ * shape you normally send (same conversation `id`, same `messages`) — runId precedence in the route
24
+ * guarantees these fields win over derivation.
25
+ */
26
+ export function approvalPayload(interrupt, approved = true) {
27
+ if (!interrupt.runId) {
28
+ throw new Error('@gnldev/chat-adapter: approvalPayload needs interrupt.runId — this server did not stamp runId onto the interrupt (upgrade the route/ui-stream), and approving by a DERIVED id would target a fresh run instead of the suspended one.');
29
+ }
30
+ return { runId: interrupt.runId, approvals: { [interrupt.toolCallId]: approved } };
31
+ }
32
+ /** Re-POSTs the approval to the chat endpoint and returns the raw streaming Response. */
33
+ export async function approve(endpoint, opts) {
34
+ const doFetch = opts.fetchImpl ?? fetch;
35
+ return doFetch(endpoint, {
36
+ method: 'POST',
37
+ headers: { 'content-type': 'application/json', ...(opts.headers ?? {}) },
38
+ body: JSON.stringify({
39
+ ...(opts.chatId !== undefined ? { id: opts.chatId } : {}),
40
+ ...(opts.threadId !== undefined ? { threadId: opts.threadId } : {}),
41
+ messages: opts.messages,
42
+ ...approvalPayload(opts.interrupt, opts.approved ?? true),
43
+ }),
44
+ });
45
+ }
46
+ //# sourceMappingURL=approve.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"approve.js","sourceRoot":"","sources":["../src/approve.ts"],"names":[],"mappings":"AAAA,mGAAmG;AACnG,iGAAiG;AACjG,+FAA+F;AAC/F,gGAAgG;AAChG,8FAA8F;AAC9F,iGAAiG;AACjG,oGAAoG;AACpG,2BAA2B;AAC3B,EAAE;AACF,sCAAsC;AACtC,+FAA+F;AAC/F,8EAA8E;AAC9E,kGAAkG;AAClG,mGAAmG;AACnG,EAAE;AACF,wFAAwF;AACxF,iGAAiG;AACjG,sGAAsG;AACtG,+FAA+F;AAC/F,+CAA+C;AAU/C;;;;GAIG;AACH,MAAM,UAAU,eAAe,CAC7B,SAA8B,EAC9B,QAAQ,GAAG,IAAI;IAEf,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC;QACrB,MAAM,IAAI,KAAK,CACb,sOAAsO,CACvO,CAAC;IACJ,CAAC;IACD,OAAO,EAAE,KAAK,EAAE,SAAS,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,UAAU,CAAC,EAAE,QAAQ,EAAE,EAAE,CAAC;AACrF,CAAC;AAiBD,yFAAyF;AACzF,MAAM,CAAC,KAAK,UAAU,OAAO,CAAC,QAAgB,EAAE,IAAoB;IAClE,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,IAAI,KAAK,CAAC;IACxC,OAAO,OAAO,CAAC,QAAQ,EAAE;QACvB,MAAM,EAAE,MAAM;QACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE,GAAG,CAAC,IAAI,CAAC,OAAO,IAAI,EAAE,CAAC,EAAE;QACxE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;YACnB,GAAG,CAAC,IAAI,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACzD,GAAG,CAAC,IAAI,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACnE,QAAQ,EAAE,IAAI,CAAC,QAAQ;YACvB,GAAG,eAAe,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,QAAQ,IAAI,IAAI,CAAC;SAC1D,CAAC;KACH,CAAC,CAAC;AACL,CAAC","sourcesContent":["// FAZ-2 — the approval round-trip, fixed at the CLIENT edge. The failure this closes (heyet kararı\n// 1.8): a suspended tool emits a `data-gnl-interrupt`; the user clicks \"approve\"; a naive client\n// re-POSTs its messages and the route derives a FRESH runId from the new last-message id — the\n// approval lands on a brand-new run, the suspended run stays suspended forever (retention keeps\n// suspended runs deliberately → unbounded accumulation), and the tool never fires. The fix is\n// addressing: an approval must travel with the SUSPENDED run's id (`interrupt.runId`, stamped by\n// ui-stream.ts) and the SAME conversation identity (`body.id`/threadId — a different thread 409s on\n// the frozen-input guard).\n//\n// Two surfaces, by integration style:\n// `approvalPayload(interrupt)` — pure: the body fields to merge into YOUR request (useChat's\n// `sendMessage(..., { body })`, a custom fetch, a proxy). No I/O, no deps.\n// `approve(endpoint, {...})` — convenience fetch for headless/manual flows: re-POSTs the SAME\n// Messages + the payload and returns the raw streaming Response (the caller renders/drains it).\n//\n// Approval is a BUTTON, not prose: the route reads decisions ONLY from `body.approvals`\n// (chat-route.ts) — a user typing \"yes, do it\" into the chat input does NOT approve anything, it\n// starts a fresh turn. Likewise \"regenerate\" on a completed turn re-POSTs the same runId and gets the\n// journal REPLAY (safe default); genuinely re-running a side effect goes through this approval\n// ladder, never through a silent re-execution.\n\n/** The minimum an approval needs from a `data-gnl-interrupt` entry (see GnlInterruptData). */\nexport interface ApprovableInterrupt {\n toolCallId: string;\n /** The suspended run's id — REQUIRED to route the approval (older servers that don't stamp it\n * Can't be addressed reliably; see `approvalPayload`'s throw). */\n runId?: string;\n}\n\n/**\n * The body fields that turn a chat POST into an approval of `interrupt`. Merge into the SAME request\n * shape you normally send (same conversation `id`, same `messages`) — runId precedence in the route\n * guarantees these fields win over derivation.\n */\nexport function approvalPayload(\n interrupt: ApprovableInterrupt,\n approved = true,\n): { runId: string; approvals: Record<string, boolean> } {\n if (!interrupt.runId) {\n throw new Error(\n '@gnldev/chat-adapter: approvalPayload needs interrupt.runId — this server did not stamp runId onto the interrupt (upgrade the route/ui-stream), and approving by a DERIVED id would target a fresh run instead of the suspended one.',\n );\n }\n return { runId: interrupt.runId, approvals: { [interrupt.toolCallId]: approved } };\n}\n\nexport interface ApproveOptions {\n interrupt: ApprovableInterrupt;\n /** The SAME UIMessage history the original turn sent — the journal's frozen input wins on replay,\n * But the route still converts these (and the thread guard needs the same conversation). */\n messages: unknown[];\n /** UseChat's stable conversation id (`body.id`) — REQUIRED when the original turn had one: threadId\n * Defaults to it, and a mismatched thread is refused with 409 run_thread_mismatch. */\n chatId?: string;\n threadId?: string;\n approved?: boolean;\n headers?: Record<string, string>;\n /** Override for non-global-fetch environments/tests. */\n fetchImpl?: typeof fetch;\n}\n\n/** Re-POSTs the approval to the chat endpoint and returns the raw streaming Response. */\nexport async function approve(endpoint: string, opts: ApproveOptions): Promise<Response> {\n const doFetch = opts.fetchImpl ?? fetch;\n return doFetch(endpoint, {\n method: 'POST',\n headers: { 'content-type': 'application/json', ...(opts.headers ?? {}) },\n body: JSON.stringify({\n ...(opts.chatId !== undefined ? { id: opts.chatId } : {}),\n ...(opts.threadId !== undefined ? { threadId: opts.threadId } : {}),\n messages: opts.messages,\n ...approvalPayload(opts.interrupt, opts.approved ?? true),\n }),\n });\n}\n"]}
@@ -0,0 +1,107 @@
1
+ import type { Context } from 'hono';
2
+ import { Hono } from 'hono';
3
+ import { createGnl } from '@gnldev/durable';
4
+ import type { CreateGnlConfig, GnlIdentity } from '@gnldev/durable';
5
+ export interface CreateChatRouteOptions {
6
+ /** Resolve the durable `runId` (exactly-once key for THIS request) from the request/body. */
7
+ resolveRunId?: (c: Context, body: any) => string | undefined;
8
+ /** Resolve the conversation `threadId` (memory continuity across requests) from the request/body. */
9
+ resolveThreadId?: (c: Context, body: any) => string | undefined;
10
+ /**
11
+ * WHO this request acts for — the end user's `resourceId`, read from something the SERVER trusts
12
+ * (a session cookie, a verified JWT, `principalOf(c.req.raw)?.id`) and NEVER from the body.
13
+ *
14
+ * WHY IT EXISTS. GNL has no end-user identity: an end user is a SUBJECT that a trusted application
15
+ * names, not a principal GNL authenticates (`resolveResourceId` in @gnldev/server states this).
16
+ * The engine treats the reserved context keys as "the server established this", and this route used
17
+ * to forward `body.context` verbatim — so the reserved key arrived from whoever sent the request.
18
+ * MEASURED against a running app: a plain POST carrying
19
+ * `{"context":{"__gnl_resourceId":"KURBAN-KULLANICI"}}` produced a run owned by that name, and the
20
+ * ownership stamp followed it. The seal that exists precisely to prevent this (registry.ts's
21
+ * `sealRequestContext`, whose own comment names the attack) was never applied on this path.
22
+ *
23
+ * The route now ALWAYS seals. With no resolver the seal carries no identity, which STRIPS the
24
+ * reserved keys: a forged subject cannot get through, and none is asserted either.
25
+ *
26
+ * HONEST BOUND — it decides what every ownership guarantee downstream is worth: this route ships
27
+ * with NO auth of its own (see the createChatRoute JSDoc). A resolver reading an unauthenticated
28
+ * request asserts a subject nobody verified. Put auth in front of this route, or the subject is
29
+ * only as trustworthy as the caller.
30
+ */
31
+ resolveResourceId?: (c: Context, body: any) => string | undefined;
32
+ /**
33
+ * WHO and WHICH CONVERSATION, in one hook — the same signature @gnldev/agui's route takes, so the
34
+ * function a host writes once works on both.
35
+ *
36
+ * It exists because the two hooks above are two hooks. A host wiring identity had to write
37
+ * `resolveResourceId` AND `resolveThreadId`, and on the sibling adapter the second one had a
38
+ * different shape and, for a while, no effect at all (see agui's route.ts note on the dead
39
+ * `threadId` line). Answering "who is this request for" twice is how one of the answers ends up
40
+ * missing.
41
+ *
42
+ * Takes the web `Request`, not the Hono `Context` — the precedent is @gnldev/server's
43
+ * `OrgOptions.resolve`, and the reason is the same: a host binding this route from Express or
44
+ * Fastify has a Request and no Context.
45
+ *
46
+ * PRECEDENCE: `resolveResourceId` / `resolveThreadId` still WIN, field by field. They are the
47
+ * existing contract and a new convenience must not silently take a working deployment's answer
48
+ * away. This fills whichever of the two the host did not supply.
49
+ *
50
+ * Called ONCE per request, for the reason already written below about `subject`: a resolver that
51
+ * reads the request may answer differently the second time.
52
+ *
53
+ * HONEST BOUND — unchanged from `resolveResourceId`: this route ships with no auth of its own. A
54
+ * resolver reading an unauthenticated request asserts a subject nobody verified.
55
+ */
56
+ identity?: GnlIdentity;
57
+ /**
58
+ * FAZ-2 — per-run concurrency lock, ON by default (`{ ttlMs: 300_000 }`). Two CONCURRENT requests
59
+ * with the same runId (double-click, two tabs, a retry racing the original) used to BOTH execute;
60
+ * Now the loser gets the typed `409 run_busy` (+ Retry-After) and the winner's journal replay
61
+ * answers the retry. `lock: false` restores the old behavior. The streamed lock SELF-RENEWS on a
62
+ * ttl/2 heartbeat (engine parity with run()), so ttlMs is the crash-takeover window — the generous
63
+ * 5-minute default simply keeps takeover conservative.
64
+ */
65
+ lock?: {
66
+ ttlMs?: number;
67
+ } | false;
68
+ }
69
+ /**
70
+ * Produces a single-endpoint Hono router from a createGnl config (or an already-built `gnl` instance)
71
+ * that a `useChat({ api: '.../agents/:name/chat' })` client can talk to:
72
+ * POST /agents/:name/chat { id?, messages: UIMessage[], runId?, threadId?, approvals? } → UI message stream
73
+ * Deliberately kept small — SAME posture as @gnldev/agui's `createAguiRoute`: NO auth/org/budget gates (if
74
+ * needed, wrap this route, or compose @gnldev/server's createRestApi's auth middleware around it — see README).
75
+ *
76
+ * IDENTITY precedence: `body.runId` > `opts.resolveRunId(...)` > the TURN'S NAME
77
+ * (`Idempotency-Key` header, else DERIVED `${body.id}:${lastMessage.id}`) > a generated id. The
78
+ * derivation is the load-bearing default: `body.id` is useChat's STABLE per-conversation id — using
79
+ * it ALONE would make every later turn replay turn 1 from the journal (withDurableModel replays
80
+ * `runId:model:0` and the model never runs again). Combining it with the LAST message's id (useChat
81
+ * stamps a fresh id per message) gives one exactly-once run PER TURN, and makes a network retry of
82
+ * the SAME turn land on the same run (deduped replay — free idempotency) while a NEW turn runs fresh.
83
+ * `threadId` defaults to `body.id` (the conversation), NOT the per-turn key — conversation memory
84
+ * must span turns.
85
+ *
86
+ * TWO REGIMES, decided by whether the route can name a SUBJECT (package #5, §7). With one, the
87
+ * turn's name is a `workKey` and the engine derives an opaque `run1_` id from it; without one it
88
+ * stays the raw id it has always been, because deriving needs an address (§6) and this route's
89
+ * quickstart names nobody. The retry contract is identical either way: the same message ids produce
90
+ * the same key, and the same key lands on the same run.
91
+ *
92
+ * SCOPE of that idempotency (FAZ-2): serial retries dedupe via journal replay, and CONCURRENT
93
+ * duplicates are now serialized by the default per-run lock (`CreateChatRouteOptions.lock`) — the
94
+ * loser gets the typed 409 `run_busy` + Retry-After instead of a second execution. The effective
95
+ * RunId is echoed on every response as `X-Gnl-Run-Id` and stamped onto interrupt chunks — a
96
+ * CORRELATION handle, not the retry key: to retry, send the same turn again. One exception, agui's
97
+ * too: a pre-run identity refusal (unknown agent, unaddressable scope) carries no header, because
98
+ * the header names a run and none exists yet on that path.
99
+ *
100
+ * Body `workKey` is deliberately NOT read here (the REST, agui and workflow doors all take it):
101
+ * this route speaks the useChat wire format, where the turn IS the work — the per-turn key above,
102
+ * or a gateway's `Idempotency-Key`, is what gets promoted. A field the UI library never sends would
103
+ * be dead surface with a live failure mode (a stale key silently pinning every turn to one run).
104
+ */
105
+ export declare function createChatRoute(config: CreateGnlConfig | {
106
+ gnl: ReturnType<typeof createGnl>;
107
+ }, opts?: CreateChatRouteOptions): Hono;