@gnldev/server 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/edge-errors.d.ts +32 -0
- package/dist/edge-errors.js +71 -0
- package/dist/edge-errors.js.map +1 -0
- package/dist/handler.d.ts +65 -0
- package/dist/handler.js +39 -0
- package/dist/handler.js.map +1 -0
- package/dist/index.d.ts +222 -0
- package/dist/index.js +2121 -0
- package/dist/index.js.map +1 -0
- package/dist/node.d.ts +35 -0
- package/dist/node.js +94 -0
- package/dist/node.js.map +1 -0
- package/dist/openapi.d.ts +1 -0
- package/dist/openapi.js +215 -0
- package/dist/openapi.js.map +1 -0
- package/dist/sse.d.ts +57 -0
- package/dist/sse.js +228 -0
- package/dist/sse.js.map +1 -0
- package/package.json +71 -0
package/dist/sse.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Pumps the streamDurable result (AI SDK StreamTextResult) into SSE.
|
|
2
|
+
// Schema (event → JSON data):
|
|
3
|
+
// text-delta {text} · tool-call {toolCallId,toolName,input} · tool-result {toolCallId,toolName,output}
|
|
4
|
+
// reasoning-start {id} · reasoning-delta {id,text} · reasoning-end {id} (P0.1: thinking models)
|
|
5
|
+
// tool-input-start {toolCallId,toolName} · tool-input-delta {toolCallId,delta} · tool-input-end {toolCallId}
|
|
6
|
+
// source {sourceType,id,url,title} · file {mediaType,base64} · step-start {} · step-finish {finishReason,usage}
|
|
7
|
+
// tool-error {toolCallId,toolName,error} (NON-terminal — the loop may continue; distinct from `error`)
|
|
8
|
+
// raw {type} (unrecognized part marker — see the default case) · error {error} (terminal)
|
|
9
|
+
// interrupt {interrupts[]} (derived when the stream ends) · done {runId,finishReason,usage}
|
|
10
|
+
// The same schema is also used in the @gnldev/studio playground → @gnldev/client can connect to either endpoint.
|
|
11
|
+
//
|
|
12
|
+
// P0.1 the old switch knew only 4 part types and had NO default — every
|
|
13
|
+
// reasoning-*/source/file/step-*/tool-input-* part from the AI SDK fullStream was SILENTLY DROPPED
|
|
14
|
+
// (a reasoning model's entire thinking trace vanished with no error). Now every known part has a case,
|
|
15
|
+
// a few are DELIBERATELY not events ('start'/'finish' → covered by done; 'text-start'/'text-end' → the
|
|
16
|
+
// schema's text framing is delta-only; 'abort'/'raw' → transport-internal), and anything else emits a
|
|
17
|
+
// `raw {type}` marker (type only, payload withheld — future/unknown parts can never silently vanish
|
|
18
|
+
// again, and can never leak internal payloads either).
|
|
19
|
+
//
|
|
20
|
+
// RESUMABLE SSE: a deterministic, increasing `id:` starting from 0 is attached to every written event
|
|
21
|
+
// (the SSE `id:` field — see https://html.spec.whatwg.org/multipage/server-sent-events.html). Calling the
|
|
22
|
+
// stream AGAIN with the same runId is deterministic journal replay (withDurableModel/durableTools
|
|
23
|
+
// read from the journal, model/tool do NOT actually re-run) → fullStream produces the same sequence of
|
|
24
|
+
// parts, so the emitted event sequence (and its ids) is also IDENTICAL from start to finish. That's why
|
|
25
|
+
// id numbering needs no EXTRA state — it's just a "which event number is this" counter.
|
|
26
|
+
//
|
|
27
|
+
// Client disconnect-recovery: the browser's EventSource automatically sends the last `id:` it saw via
|
|
28
|
+
// the `Last-Event-ID` header when reconnecting (spec behavior). If `lastEventId` is given to
|
|
29
|
+
// `pipeAgentStream`, events with id <= lastEventId are still PRODUCED (fullStream still runs from the
|
|
30
|
+
// start — replay is cheap, no model/tool call) but are NOT WRITTEN TO THE CLIENT; only those with id >
|
|
31
|
+
// lastEventId are sent. This is opt-in behavior: if lastEventId isn't given (existing callers), all
|
|
32
|
+
// events are written — behavior stays identical apart from the addition of id (existing sse.test.ts
|
|
33
|
+
// assertions look at the event/data fields, they don't care about id).
|
|
34
|
+
import { streamSSE } from 'hono/streaming';
|
|
35
|
+
import { limitBreachFromSteps, blockedFromSteps, surfacedInterrupts, BLOCKED_ERROR_CODES } from '@gnldev/durable';
|
|
36
|
+
import { EDGE_ERROR_CODES } from './edge-errors.js';
|
|
37
|
+
/**
|
|
38
|
+
* `streamSSE` plus the two headers a live stream needs to survive the trip to the browser.
|
|
39
|
+
*
|
|
40
|
+
* Hono sets `Cache-Control: no-cache`, which says "don't serve this from cache" and says nothing
|
|
41
|
+
* about re-encoding. So a compression middleware in the host's chain happily takes the stream and
|
|
42
|
+
* buffers it: measured on a real Express app, `compression()` turned 13 progressive chunks with the
|
|
43
|
+
* first at 750ms into ONE chunk delivered at the end. Status 200, no error, no live screen — the
|
|
44
|
+
* failure is invisible from both sides. `no-transform` is the standard way to say don't, and
|
|
45
|
+
* `compression` honours it (measured: first byte 1520ms -> 302ms with the flag on).
|
|
46
|
+
*
|
|
47
|
+
* `X-Accel-Buffering: no` is the nginx-specific half, and measurement narrowed where it matters to
|
|
48
|
+
* one square of a 2x2 — behind a real nginx, same stream dripping five deltas 300ms apart:
|
|
49
|
+
*
|
|
50
|
+
* HTTP/1.1 + gzip, no header → ONE chunk at 1511ms (collapsed)
|
|
51
|
+
* HTTP/1.1 + gzip, header → 306, 606, 911, 1211, 1511
|
|
52
|
+
* HTTP/2 + gzip, no header → 316, 616, 916, 1217, 1518 (fine without it)
|
|
53
|
+
* HTTP/2 + gzip, header → 312, 612, 913, 1214, 1514
|
|
54
|
+
*
|
|
55
|
+
* So it is load-bearing exactly when the CLIENT speaks HTTP/1.1 to a proxy that gzips, and inert
|
|
56
|
+
* everywhere else — including HTTP/2, which is what a browser usually gets over TLS. That leaves
|
|
57
|
+
* plenty of real traffic in the square that breaks: internal clients on plain HTTP, curl's default,
|
|
58
|
+
* anything not a modern browser. Worth a header; not worth believing it covers more than it does.
|
|
59
|
+
*
|
|
60
|
+
* Set AFTER `streamSSE` on purpose: it writes `Cache-Control` itself, so anything set on the context
|
|
61
|
+
* beforehand is overwritten. Patching the returned Response is what actually survives.
|
|
62
|
+
*
|
|
63
|
+
* Kept IN SYNC with packages/studio/src/sse.ts.
|
|
64
|
+
*/
|
|
65
|
+
export function sseResponse(c, cb, onError) {
|
|
66
|
+
const res = streamSSE(c, cb, onError);
|
|
67
|
+
res.headers.set('Cache-Control', 'no-cache, no-transform');
|
|
68
|
+
res.headers.set('X-Accel-Buffering', 'no');
|
|
69
|
+
return res;
|
|
70
|
+
}
|
|
71
|
+
function hasSuspend(part) {
|
|
72
|
+
return part?.type === 'tool-result' && !!part.output?.__gnl_suspend;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Extracts suspended tool calls (Interrupt) from a completed step list — the SAME logic as
|
|
76
|
+
* runDurable, and now literally the same function: the sentinel goes straight into durable's
|
|
77
|
+
* `surfacedInterrupts`.
|
|
78
|
+
*
|
|
79
|
+
* It used to push the RAW sentinel, and that was wrong in exactly one shape — the one that matters
|
|
80
|
+
* most on this channel. A sub-agent that hits a human gate suspends its PARENT's record too, and
|
|
81
|
+
* that record is keyed by the parent's proxy call id because the suspend record, the replay and
|
|
82
|
+
* `consumeExistingRecord` all work through it. The engine surfaces the CHILD's interrupts and
|
|
83
|
+
* durable-tool deliberately IGNORES an answer addressed to the proxy — so a client following the
|
|
84
|
+
* standard contract (`approvals[interrupt.toolCallId] = true`) against this stream was answering an
|
|
85
|
+
* id the engine drops on the floor. Chat/SSE is the actual end-user channel: the question appeared,
|
|
86
|
+
* the human approved it, and nothing happened.
|
|
87
|
+
*/
|
|
88
|
+
export function interruptsFromSteps(steps) {
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const step of steps ?? []) {
|
|
91
|
+
for (const part of step?.content ?? []) {
|
|
92
|
+
if (hasSuspend(part))
|
|
93
|
+
out.push(...surfacedInterrupts(part.output.__gnl_suspend));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
/** Stream fullStream as SSE, then send interrupt + done events. Returns a Hono Response. */
|
|
99
|
+
export function pipeAgentStream(c, runId, result, opts) {
|
|
100
|
+
const lastEventId = opts?.lastEventId;
|
|
101
|
+
return sseResponse(c, async (stream) => {
|
|
102
|
+
// Deterministic counter starting from 0: on replay with the same runId, fullStream produces the
|
|
103
|
+
// same sequence → the same events are emitted in the same order → the same ids come out (see the note at the top of the file).
|
|
104
|
+
let nextId = 0;
|
|
105
|
+
const emit = async (event, data) => {
|
|
106
|
+
const id = nextId++;
|
|
107
|
+
if (lastEventId != null && id <= lastEventId)
|
|
108
|
+
return; // client already saw it — production is cheap, skip
|
|
109
|
+
await stream.writeSSE({ event, data: JSON.stringify(data), id: String(id) });
|
|
110
|
+
};
|
|
111
|
+
try {
|
|
112
|
+
for await (const part of result.fullStream) {
|
|
113
|
+
if (stream.aborted)
|
|
114
|
+
break;
|
|
115
|
+
switch (part.type) {
|
|
116
|
+
case 'text-delta': {
|
|
117
|
+
const text = part.text ?? part.delta ?? '';
|
|
118
|
+
if (text)
|
|
119
|
+
await emit('text-delta', { text });
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
case 'tool-call':
|
|
123
|
+
await emit('tool-call', { toolCallId: part.toolCallId, toolName: part.toolName, input: part.input });
|
|
124
|
+
break;
|
|
125
|
+
case 'tool-result':
|
|
126
|
+
if (part.output?.__gnl_suspend)
|
|
127
|
+
break; // the suspend sentinel is carried by the interrupt event
|
|
128
|
+
if (part.output?.__gnl_limit_exceeded)
|
|
129
|
+
break; // Decision #2: the limit sentinel is an INTERNAL API — it doesn't leak, it's carried by the error event
|
|
130
|
+
if (part.output?.__gnl_blocked)
|
|
131
|
+
break; // K1: the blocked sentinel is also an INTERNAL API — it's carried by the error event
|
|
132
|
+
await emit('tool-result', { toolCallId: part.toolCallId, toolName: part.toolName, output: part.output });
|
|
133
|
+
break;
|
|
134
|
+
// P0.1: reasoning (thinking) trace — id groups the deltas of one reasoning block.
|
|
135
|
+
case 'reasoning-start':
|
|
136
|
+
await emit('reasoning-start', { id: part.id });
|
|
137
|
+
break;
|
|
138
|
+
case 'reasoning-delta': {
|
|
139
|
+
const text = part.text ?? part.delta ?? '';
|
|
140
|
+
if (text)
|
|
141
|
+
await emit('reasoning-delta', { id: part.id, text });
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
case 'reasoning-end':
|
|
145
|
+
await emit('reasoning-end', { id: part.id });
|
|
146
|
+
break;
|
|
147
|
+
// P0.1: incremental tool-argument streaming (live args UI) — the complete `tool-call` event still follows.
|
|
148
|
+
case 'tool-input-start':
|
|
149
|
+
await emit('tool-input-start', { toolCallId: part.toolCallId ?? part.id, toolName: part.toolName });
|
|
150
|
+
break;
|
|
151
|
+
case 'tool-input-delta':
|
|
152
|
+
await emit('tool-input-delta', { toolCallId: part.toolCallId ?? part.id, delta: part.delta });
|
|
153
|
+
break;
|
|
154
|
+
case 'tool-input-end':
|
|
155
|
+
await emit('tool-input-end', { toolCallId: part.toolCallId ?? part.id });
|
|
156
|
+
break;
|
|
157
|
+
case 'source':
|
|
158
|
+
await emit('source', { sourceType: part.sourceType, id: part.id, url: part.url, title: part.title });
|
|
159
|
+
break;
|
|
160
|
+
case 'file':
|
|
161
|
+
await emit('file', { mediaType: part.file?.mediaType, base64: part.file?.base64 });
|
|
162
|
+
break;
|
|
163
|
+
case 'start-step':
|
|
164
|
+
await emit('step-start', {});
|
|
165
|
+
break;
|
|
166
|
+
case 'finish-step':
|
|
167
|
+
await emit('step-finish', { finishReason: part.finishReason, usage: part.usage });
|
|
168
|
+
break;
|
|
169
|
+
// P0.1: a tool that THREW — NON-terminal (the model sees the error and may continue), so it is
|
|
170
|
+
// NOT the terminal `error` event; a distinct event keeps the "error = terminal" contract intact.
|
|
171
|
+
case 'tool-error':
|
|
172
|
+
await emit('tool-error', { toolCallId: part.toolCallId, toolName: part.toolName, error: String(part.error?.message ?? part.error) });
|
|
173
|
+
break;
|
|
174
|
+
case 'error':
|
|
175
|
+
await emit('error', { error: String(part.error?.message ?? part.error) });
|
|
176
|
+
break;
|
|
177
|
+
// Deliberately no event: 'start'/'finish' (covered by done), 'text-start'/'text-end' (text
|
|
178
|
+
// framing is delta-only in this schema), 'abort' (the client aborted — it isn't listening),
|
|
179
|
+
// 'raw' (provider-internal passthrough, opt-in AI SDK debug surface).
|
|
180
|
+
case 'start':
|
|
181
|
+
case 'finish':
|
|
182
|
+
case 'text-start':
|
|
183
|
+
case 'text-end':
|
|
184
|
+
case 'abort':
|
|
185
|
+
case 'raw':
|
|
186
|
+
break;
|
|
187
|
+
default:
|
|
188
|
+
// Unknown/future part type: emit a type-only marker — never silently dropped, never leaks payload.
|
|
189
|
+
await emit('raw', { type: part.type });
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const steps = await result.steps;
|
|
194
|
+
// Decision #2: a run-limit violation (maxToolCalls/loop sentinel) → a terminal `error` event,
|
|
195
|
+
// NO `done` (consistent with the existing catch-path error contract: error = terminal).
|
|
196
|
+
const breach = limitBreachFromSteps(steps);
|
|
197
|
+
if (breach) {
|
|
198
|
+
await emit('error', {
|
|
199
|
+
error: breach.message,
|
|
200
|
+
code: breach.kind === 'loop' ? EDGE_ERROR_CODES.toolLoopDetected : EDGE_ERROR_CODES.runLimitExceeded,
|
|
201
|
+
detail: breach.detail,
|
|
202
|
+
});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
// K1: the blocked sentinel (side-effect/retry/busy) → same contract as a limit violation: terminal error, no done.
|
|
206
|
+
const blocked = blockedFromSteps(steps);
|
|
207
|
+
if (blocked) {
|
|
208
|
+
await emit('error', { error: blocked.message, code: BLOCKED_ERROR_CODES[blocked.code] ?? 'run_busy', detail: blocked.detail });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const interrupts = interruptsFromSteps(steps);
|
|
212
|
+
if (interrupts.length)
|
|
213
|
+
await emit('interrupt', { interrupts });
|
|
214
|
+
const finishReason = await Promise.resolve(result.finishReason).catch(() => undefined);
|
|
215
|
+
const usage = await Promise.resolve(result.usage).catch(() => undefined);
|
|
216
|
+
// Replay-disclosure zarfı BİLEREK YOK (ölçüldü): SSE dizisi W3 sözleşmesiyle DETERMİNİSTİKTİR —
|
|
217
|
+
// aynı runId'nin replay'i bayt-aynı event'leri üretmelidir; zarf ise koşum-anı meta'sıdır (ilk
|
|
218
|
+
// koşumda window'lu, replay'de self'li) ve done-frame'e girince diziyi koşumdan koşuma
|
|
219
|
+
// farklılaştırıp resumable-SSE replay pinini kırar. Stream yüzeyinde zarf, sonucun LAZY
|
|
220
|
+
// `replayedToolCalls` alanındadır — host onFinish'te okur; SSE'ye taşımak v2'nin ayrı işi.
|
|
221
|
+
await emit('done', { runId, finishReason, usage });
|
|
222
|
+
}
|
|
223
|
+
catch (e) {
|
|
224
|
+
await emit('error', { error: String(e?.message ?? e) });
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
//# sourceMappingURL=sse.js.map
|
package/dist/sse.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sse.js","sourceRoot":"","sources":["../src/sse.ts"],"names":[],"mappings":"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","sourcesContent":["// Pumps the streamDurable result (AI SDK StreamTextResult) into SSE.\n// Schema (event → JSON data):\n// text-delta {text} · tool-call {toolCallId,toolName,input} · tool-result {toolCallId,toolName,output}\n// reasoning-start {id} · reasoning-delta {id,text} · reasoning-end {id} (P0.1: thinking models)\n// tool-input-start {toolCallId,toolName} · tool-input-delta {toolCallId,delta} · tool-input-end {toolCallId}\n// source {sourceType,id,url,title} · file {mediaType,base64} · step-start {} · step-finish {finishReason,usage}\n// tool-error {toolCallId,toolName,error} (NON-terminal — the loop may continue; distinct from `error`)\n// raw {type} (unrecognized part marker — see the default case) · error {error} (terminal)\n// interrupt {interrupts[]} (derived when the stream ends) · done {runId,finishReason,usage}\n// The same schema is also used in the @gnldev/studio playground → @gnldev/client can connect to either endpoint.\n//\n// P0.1 the old switch knew only 4 part types and had NO default — every\n// reasoning-*/source/file/step-*/tool-input-* part from the AI SDK fullStream was SILENTLY DROPPED\n// (a reasoning model's entire thinking trace vanished with no error). Now every known part has a case,\n// a few are DELIBERATELY not events ('start'/'finish' → covered by done; 'text-start'/'text-end' → the\n// schema's text framing is delta-only; 'abort'/'raw' → transport-internal), and anything else emits a\n// `raw {type}` marker (type only, payload withheld — future/unknown parts can never silently vanish\n// again, and can never leak internal payloads either).\n//\n// RESUMABLE SSE: a deterministic, increasing `id:` starting from 0 is attached to every written event\n// (the SSE `id:` field — see https://html.spec.whatwg.org/multipage/server-sent-events.html). Calling the\n// stream AGAIN with the same runId is deterministic journal replay (withDurableModel/durableTools\n// read from the journal, model/tool do NOT actually re-run) → fullStream produces the same sequence of\n// parts, so the emitted event sequence (and its ids) is also IDENTICAL from start to finish. That's why\n// id numbering needs no EXTRA state — it's just a \"which event number is this\" counter.\n//\n// Client disconnect-recovery: the browser's EventSource automatically sends the last `id:` it saw via\n// the `Last-Event-ID` header when reconnecting (spec behavior). If `lastEventId` is given to\n// `pipeAgentStream`, events with id <= lastEventId are still PRODUCED (fullStream still runs from the\n// start — replay is cheap, no model/tool call) but are NOT WRITTEN TO THE CLIENT; only those with id >\n// lastEventId are sent. This is opt-in behavior: if lastEventId isn't given (existing callers), all\n// events are written — behavior stays identical apart from the addition of id (existing sse.test.ts\n// assertions look at the event/data fields, they don't care about id).\nimport { streamSSE } from 'hono/streaming';\nimport type { Context } from 'hono';\nimport { limitBreachFromSteps, blockedFromSteps, surfacedInterrupts, BLOCKED_ERROR_CODES } from '@gnldev/durable';\nimport type { Interrupt } from '@gnldev/durable';\nimport { EDGE_ERROR_CODES } from './edge-errors.js';\n\n/**\n * `streamSSE` plus the two headers a live stream needs to survive the trip to the browser.\n *\n * Hono sets `Cache-Control: no-cache`, which says \"don't serve this from cache\" and says nothing\n * about re-encoding. So a compression middleware in the host's chain happily takes the stream and\n * buffers it: measured on a real Express app, `compression()` turned 13 progressive chunks with the\n * first at 750ms into ONE chunk delivered at the end. Status 200, no error, no live screen — the\n * failure is invisible from both sides. `no-transform` is the standard way to say don't, and\n * `compression` honours it (measured: first byte 1520ms -> 302ms with the flag on).\n *\n * `X-Accel-Buffering: no` is the nginx-specific half, and measurement narrowed where it matters to\n * one square of a 2x2 — behind a real nginx, same stream dripping five deltas 300ms apart:\n *\n * HTTP/1.1 + gzip, no header → ONE chunk at 1511ms (collapsed)\n * HTTP/1.1 + gzip, header → 306, 606, 911, 1211, 1511\n * HTTP/2 + gzip, no header → 316, 616, 916, 1217, 1518 (fine without it)\n * HTTP/2 + gzip, header → 312, 612, 913, 1214, 1514\n *\n * So it is load-bearing exactly when the CLIENT speaks HTTP/1.1 to a proxy that gzips, and inert\n * everywhere else — including HTTP/2, which is what a browser usually gets over TLS. That leaves\n * plenty of real traffic in the square that breaks: internal clients on plain HTTP, curl's default,\n * anything not a modern browser. Worth a header; not worth believing it covers more than it does.\n *\n * Set AFTER `streamSSE` on purpose: it writes `Cache-Control` itself, so anything set on the context\n * beforehand is overwritten. Patching the returned Response is what actually survives.\n *\n * Kept IN SYNC with packages/studio/src/sse.ts.\n */\nexport function sseResponse(\n c: Context,\n cb: Parameters<typeof streamSSE>[1],\n onError?: Parameters<typeof streamSSE>[2],\n): Response {\n const res = streamSSE(c, cb, onError);\n res.headers.set('Cache-Control', 'no-cache, no-transform');\n res.headers.set('X-Accel-Buffering', 'no');\n return res;\n}\n\nfunction hasSuspend(part: any): boolean {\n return part?.type === 'tool-result' && !!part.output?.__gnl_suspend;\n}\n\n/**\n * Extracts suspended tool calls (Interrupt) from a completed step list — the SAME logic as\n * runDurable, and now literally the same function: the sentinel goes straight into durable's\n * `surfacedInterrupts`.\n *\n * It used to push the RAW sentinel, and that was wrong in exactly one shape — the one that matters\n * most on this channel. A sub-agent that hits a human gate suspends its PARENT's record too, and\n * that record is keyed by the parent's proxy call id because the suspend record, the replay and\n * `consumeExistingRecord` all work through it. The engine surfaces the CHILD's interrupts and\n * durable-tool deliberately IGNORES an answer addressed to the proxy — so a client following the\n * standard contract (`approvals[interrupt.toolCallId] = true`) against this stream was answering an\n * id the engine drops on the floor. Chat/SSE is the actual end-user channel: the question appeared,\n * the human approved it, and nothing happened.\n */\nexport function interruptsFromSteps(steps: any[]): Interrupt[] {\n const out: Interrupt[] = [];\n for (const step of steps ?? []) {\n for (const part of step?.content ?? []) {\n if (hasSuspend(part)) out.push(...surfacedInterrupts(part.output.__gnl_suspend));\n }\n }\n return out;\n}\n\n/** pipeAgentStream options (opt-in — if not given, behavior is identical to before except for the id field). */\nexport interface PipeAgentStreamOptions {\n /**\n * The last event id the client saw (resolved from the Last-Event-ID header or body.lastEventId).\n * If given, events with id <= lastEventId are NOT WRITTEN (production still runs from the start — replay is cheap).\n */\n lastEventId?: number;\n}\n\n/** Stream fullStream as SSE, then send interrupt + done events. Returns a Hono Response. */\nexport function pipeAgentStream(c: Context, runId: string, result: any, opts?: PipeAgentStreamOptions) {\n const lastEventId = opts?.lastEventId;\n return sseResponse(c, async (stream) => {\n // Deterministic counter starting from 0: on replay with the same runId, fullStream produces the\n // same sequence → the same events are emitted in the same order → the same ids come out (see the note at the top of the file).\n let nextId = 0;\n const emit = async (event: string, data: unknown) => {\n const id = nextId++;\n if (lastEventId != null && id <= lastEventId) return; // client already saw it — production is cheap, skip\n await stream.writeSSE({ event, data: JSON.stringify(data), id: String(id) });\n };\n try {\n for await (const part of result.fullStream) {\n if (stream.aborted) break;\n switch (part.type) {\n case 'text-delta': {\n const text = part.text ?? part.delta ?? '';\n if (text) await emit('text-delta', { text });\n break;\n }\n case 'tool-call':\n await emit('tool-call', { toolCallId: part.toolCallId, toolName: part.toolName, input: part.input });\n break;\n case 'tool-result':\n if (part.output?.__gnl_suspend) break; // the suspend sentinel is carried by the interrupt event\n if (part.output?.__gnl_limit_exceeded) break; // Decision #2: the limit sentinel is an INTERNAL API — it doesn't leak, it's carried by the error event\n if (part.output?.__gnl_blocked) break; // K1: the blocked sentinel is also an INTERNAL API — it's carried by the error event\n await emit('tool-result', { toolCallId: part.toolCallId, toolName: part.toolName, output: part.output });\n break;\n // P0.1: reasoning (thinking) trace — id groups the deltas of one reasoning block.\n case 'reasoning-start':\n await emit('reasoning-start', { id: part.id });\n break;\n case 'reasoning-delta': {\n const text = part.text ?? part.delta ?? '';\n if (text) await emit('reasoning-delta', { id: part.id, text });\n break;\n }\n case 'reasoning-end':\n await emit('reasoning-end', { id: part.id });\n break;\n // P0.1: incremental tool-argument streaming (live args UI) — the complete `tool-call` event still follows.\n case 'tool-input-start':\n await emit('tool-input-start', { toolCallId: part.toolCallId ?? part.id, toolName: part.toolName });\n break;\n case 'tool-input-delta':\n await emit('tool-input-delta', { toolCallId: part.toolCallId ?? part.id, delta: part.delta });\n break;\n case 'tool-input-end':\n await emit('tool-input-end', { toolCallId: part.toolCallId ?? part.id });\n break;\n case 'source':\n await emit('source', { sourceType: part.sourceType, id: part.id, url: part.url, title: part.title });\n break;\n case 'file':\n await emit('file', { mediaType: part.file?.mediaType, base64: part.file?.base64 });\n break;\n case 'start-step':\n await emit('step-start', {});\n break;\n case 'finish-step':\n await emit('step-finish', { finishReason: part.finishReason, usage: part.usage });\n break;\n // P0.1: a tool that THREW — NON-terminal (the model sees the error and may continue), so it is\n // NOT the terminal `error` event; a distinct event keeps the \"error = terminal\" contract intact.\n case 'tool-error':\n await emit('tool-error', { toolCallId: part.toolCallId, toolName: part.toolName, error: String((part as any).error?.message ?? (part as any).error) });\n break;\n case 'error':\n await emit('error', { error: String((part as any).error?.message ?? (part as any).error) });\n break;\n // Deliberately no event: 'start'/'finish' (covered by done), 'text-start'/'text-end' (text\n // framing is delta-only in this schema), 'abort' (the client aborted — it isn't listening),\n // 'raw' (provider-internal passthrough, opt-in AI SDK debug surface).\n case 'start': case 'finish': case 'text-start': case 'text-end': case 'abort': case 'raw':\n break;\n default:\n // Unknown/future part type: emit a type-only marker — never silently dropped, never leaks payload.\n await emit('raw', { type: part.type });\n break;\n }\n }\n const steps = await result.steps;\n // Decision #2: a run-limit violation (maxToolCalls/loop sentinel) → a terminal `error` event,\n // NO `done` (consistent with the existing catch-path error contract: error = terminal).\n const breach = limitBreachFromSteps(steps);\n if (breach) {\n await emit('error', {\n error: breach.message,\n code: breach.kind === 'loop' ? EDGE_ERROR_CODES.toolLoopDetected : EDGE_ERROR_CODES.runLimitExceeded,\n detail: breach.detail,\n });\n return;\n }\n // K1: the blocked sentinel (side-effect/retry/busy) → same contract as a limit violation: terminal error, no done.\n const blocked = blockedFromSteps(steps);\n if (blocked) {\n await emit('error', { error: blocked.message, code: BLOCKED_ERROR_CODES[blocked.code] ?? 'run_busy', detail: blocked.detail });\n return;\n }\n const interrupts = interruptsFromSteps(steps);\n if (interrupts.length) await emit('interrupt', { interrupts });\n const finishReason = await Promise.resolve(result.finishReason).catch(() => undefined);\n const usage = await Promise.resolve(result.usage).catch(() => undefined);\n // Replay-disclosure zarfı BİLEREK YOK (ölçüldü): SSE dizisi W3 sözleşmesiyle DETERMİNİSTİKTİR —\n // aynı runId'nin replay'i bayt-aynı event'leri üretmelidir; zarf ise koşum-anı meta'sıdır (ilk\n // koşumda window'lu, replay'de self'li) ve done-frame'e girince diziyi koşumdan koşuma\n // farklılaştırıp resumable-SSE replay pinini kırar. Stream yüzeyinde zarf, sonucun LAZY\n // `replayedToolCalls` alanındadır — host onFinish'te okur; SSE'ye taşımak v2'nin ayrı işi.\n await emit('done', { runId, finishReason, usage });\n } catch (e: any) {\n await emit('error', { error: String(e?.message ?? e) });\n }\n });\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gnldev/server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "Apache-2.0",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=22.13.0"
|
|
7
|
+
},
|
|
8
|
+
"description": "Auto-REST + OpenAPI for @gnldev/durable agents: createGnl registry → HTTP router (run/resume/runs + openapi.json).",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"ai",
|
|
11
|
+
"agent",
|
|
12
|
+
"llm",
|
|
13
|
+
"typescript",
|
|
14
|
+
"ai-sdk",
|
|
15
|
+
"durable",
|
|
16
|
+
"exactly-once",
|
|
17
|
+
"rest",
|
|
18
|
+
"hono",
|
|
19
|
+
"openapi",
|
|
20
|
+
"fetch-handler"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./node": {
|
|
31
|
+
"types": "./dist/node.d.ts",
|
|
32
|
+
"default": "./dist/node.js"
|
|
33
|
+
},
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist"
|
|
38
|
+
],
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@hono/node-server": "^1.19.17",
|
|
41
|
+
"hono": "^4.13.2",
|
|
42
|
+
"@gnldev/auth": "^0.1.0",
|
|
43
|
+
"@gnldev/workflow": "^0.1.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@gnldev/durable": "^0.1.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"ai": "^7.0.0",
|
|
50
|
+
"zod": "^3.25.0",
|
|
51
|
+
"@gnldev/durable": "0.1.0"
|
|
52
|
+
},
|
|
53
|
+
"author": "Karaca Yılmaz (https://gnl.dev)",
|
|
54
|
+
"homepage": "https://gnl.dev",
|
|
55
|
+
"bugs": {
|
|
56
|
+
"url": "https://github.com/Karaca7/gnldev/issues"
|
|
57
|
+
},
|
|
58
|
+
"repository": {
|
|
59
|
+
"type": "git",
|
|
60
|
+
"url": "git+https://github.com/Karaca7/gnldev.git",
|
|
61
|
+
"directory": "packages/server"
|
|
62
|
+
},
|
|
63
|
+
"publishConfig": {
|
|
64
|
+
"access": "public"
|
|
65
|
+
},
|
|
66
|
+
"scripts": {
|
|
67
|
+
"build": "tsc -p tsconfig.json",
|
|
68
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
69
|
+
"test": "vitest run"
|
|
70
|
+
}
|
|
71
|
+
}
|