@haikit/server 0.11.0 → 0.13.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/README.md +74 -0
- package/dist/routes.d.ts +9 -6
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +166 -8
- package/dist/routes.js.map +1 -1
- package/dist/runtime.d.ts +30 -4
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +151 -9
- package/dist/runtime.js.map +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +69 -0
- package/dist/store.js.map +1 -1
- package/package.json +2 -2
- package/src/routes.ts +167 -10
- package/src/runtime.ts +169 -9
- package/src/store.ts +65 -0
package/src/routes.ts
CHANGED
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import { isConversationBusy, isStaleLease } from "@haikit/core";
|
|
3
|
-
import type { Emit, WireEvent } from "@haikit/core";
|
|
3
|
+
import type { Emit, NoticeRecord, WireEvent } from "@haikit/core";
|
|
4
4
|
import type { Hai } from "./runtime.js";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Node http handler for the three routes
|
|
8
|
-
* request.
|
|
7
|
+
* Node http handler for the three routes, and a fourth when the app sends
|
|
8
|
+
* notices. Returns true if it handled the request.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
* loop, start because the init tool it runs can render surfaces.
|
|
12
|
-
* interact route accepts: {handle, action, value} and nothing
|
|
13
|
-
* cannot name a tool, a handler, or an action target.
|
|
10
|
+
* The three POST routes stream SSE: chat and interact because both can resume
|
|
11
|
+
* the agent loop, start because the init tool it runs can render surfaces.
|
|
12
|
+
* Note what the interact route accepts: {handle, action, value} and nothing
|
|
13
|
+
* else. The client cannot name a tool, a handler, or an action target.
|
|
14
|
+
*
|
|
15
|
+
* `GET /events` streams a conversation's notices. It accepts nothing from the
|
|
16
|
+
* browser beyond which conversation, and where to resume.
|
|
14
17
|
*/
|
|
15
18
|
export function nodeHandler(hai: Hai, basePath = "/hai") {
|
|
16
19
|
return async function handle(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
17
20
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
18
|
-
if (
|
|
19
|
-
|
|
21
|
+
if (!url.pathname.startsWith(basePath)) return false;
|
|
20
22
|
const route = url.pathname.slice(basePath.length);
|
|
23
|
+
|
|
24
|
+
if (req.method === "GET" && route === "/events" && hai.sendsNotices) {
|
|
25
|
+
await streamNotices(hai, req, res, url);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (req.method !== "POST") return false;
|
|
21
30
|
if (route !== "/chat" && route !== "/interact" && route !== "/start") return false;
|
|
22
31
|
|
|
23
32
|
const body = await readJson(req);
|
|
@@ -45,7 +54,12 @@ export function nodeHandler(hai: Hai, basePath = "/hai") {
|
|
|
45
54
|
|
|
46
55
|
const emit = openSSE(res);
|
|
47
56
|
if (route === "/chat" || route === "/start") {
|
|
48
|
-
emit({
|
|
57
|
+
emit({
|
|
58
|
+
type: "hello",
|
|
59
|
+
conversationId: conversation.id,
|
|
60
|
+
model: hai.config.model.id,
|
|
61
|
+
...(hai.sendsNotices ? { events: true as const } : {}),
|
|
62
|
+
});
|
|
49
63
|
}
|
|
50
64
|
|
|
51
65
|
let superseded = false;
|
|
@@ -91,6 +105,149 @@ export function nodeHandler(hai: Hai, basePath = "/hai") {
|
|
|
91
105
|
};
|
|
92
106
|
}
|
|
93
107
|
|
|
108
|
+
/**
|
|
109
|
+
* The longest a notice waits to be read. A store's `watch` makes it sooner;
|
|
110
|
+
* this is what still finds a notice whose wake-up was lost.
|
|
111
|
+
*/
|
|
112
|
+
const POLL_MS = 2_000;
|
|
113
|
+
/** Proxies drop a stream that says nothing for long; a comment keeps it open. */
|
|
114
|
+
const HEARTBEAT_MS = 25_000;
|
|
115
|
+
|
|
116
|
+
/** Notices read from the store at a time, so a long backlog goes out page by page. */
|
|
117
|
+
const PAGE = 100;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Stream a conversation's notices: every one after where the browser has got
|
|
121
|
+
* to (`Last-Event-ID` on a reconnect, else `?after=`), then each as it lands.
|
|
122
|
+
* Takes no lease and writes nothing. Each notice goes out as its payload; its
|
|
123
|
+
* model text is not in it: that reaches the browser only as part of the
|
|
124
|
+
* history, in the `context` event, once a turn has taken it in.
|
|
125
|
+
*
|
|
126
|
+
* Bounded both ways. The store is read a page at a time, so a conversation
|
|
127
|
+
* with years of notices is never held in memory at once, and nothing more is
|
|
128
|
+
* written while the socket is still taking what was, so a reader that is slow
|
|
129
|
+
* cannot make the server buffer every notice for it.
|
|
130
|
+
*/
|
|
131
|
+
async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse, url: URL) {
|
|
132
|
+
const { store } = hai.config;
|
|
133
|
+
const conversationId = url.searchParams.get("conversationId") ?? "";
|
|
134
|
+
const resume = Number(req.headers["last-event-id"] ?? url.searchParams.get("after") ?? 0);
|
|
135
|
+
let after = Number.isSafeInteger(resume) && resume > 0 ? resume : 0;
|
|
136
|
+
|
|
137
|
+
const first = await store.getNotices(conversationId, after, PAGE);
|
|
138
|
+
if (first === null) {
|
|
139
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
140
|
+
res.end(JSON.stringify({ error: "unknown conversation" }));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
res.writeHead(200, {
|
|
145
|
+
"content-type": "text/event-stream",
|
|
146
|
+
"cache-control": "no-cache",
|
|
147
|
+
connection: "keep-alive",
|
|
148
|
+
});
|
|
149
|
+
// Sent now, not with the first frame: with nothing to catch up on, that is a
|
|
150
|
+
// heartbeat away, and the browser's request would hang open until then.
|
|
151
|
+
res.flushHeaders();
|
|
152
|
+
const aborter = new AbortController();
|
|
153
|
+
const { signal } = aborter;
|
|
154
|
+
res.on("close", () => aborter.abort());
|
|
155
|
+
|
|
156
|
+
// Resolves once the socket has taken what is queued, or the browser has gone.
|
|
157
|
+
const write = async (chunk: string) => {
|
|
158
|
+
if (res.write(chunk)) return;
|
|
159
|
+
await new Promise<void>((resolve) => {
|
|
160
|
+
const done = () => {
|
|
161
|
+
res.off("drain", done);
|
|
162
|
+
signal.removeEventListener("abort", done);
|
|
163
|
+
resolve();
|
|
164
|
+
};
|
|
165
|
+
res.on("drain", done);
|
|
166
|
+
signal.addEventListener("abort", done);
|
|
167
|
+
});
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// Everything after `after`, a page at a time, until a page comes back short.
|
|
171
|
+
// A conversation deleted since the stream opened ends it: there is nothing
|
|
172
|
+
// more to wait for, and the browser's reconnect is then refused.
|
|
173
|
+
const catchUp = async (page: NoticeRecord[] | null = null) => {
|
|
174
|
+
for (;;) {
|
|
175
|
+
const records = page ?? (await store.getNotices(conversationId, after, PAGE));
|
|
176
|
+
if (records === null) return aborter.abort();
|
|
177
|
+
for (const n of records) {
|
|
178
|
+
if (signal.aborted) return;
|
|
179
|
+
if (n.seq <= after) continue;
|
|
180
|
+
const event: WireEvent = {
|
|
181
|
+
type: "notice",
|
|
182
|
+
seq: n.seq,
|
|
183
|
+
name: n.name,
|
|
184
|
+
version: n.version,
|
|
185
|
+
payload: n.payload,
|
|
186
|
+
...(n.handle === undefined ? {} : { handle: n.handle }),
|
|
187
|
+
};
|
|
188
|
+
await write(`id: ${n.seq}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
189
|
+
after = n.seq;
|
|
190
|
+
}
|
|
191
|
+
if (records.length < PAGE || signal.aborted) return;
|
|
192
|
+
page = null;
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// a heartbeat is only for a stream gone quiet, never one already backed up
|
|
197
|
+
const heartbeat = setInterval(() => res.writableNeedDrain || res.write(": ping\n\n"), HEARTBEAT_MS);
|
|
198
|
+
const wakes = wakeups(store.watch?.(conversationId, signal), signal)[Symbol.asyncIterator]();
|
|
199
|
+
try {
|
|
200
|
+
await catchUp(first);
|
|
201
|
+
// Watching starts before the next read, so a notice that landed after the
|
|
202
|
+
// backlog was read is either in that read or wakes the loop.
|
|
203
|
+
let next = wakes.next();
|
|
204
|
+
await catchUp();
|
|
205
|
+
while (!signal.aborted) {
|
|
206
|
+
if ((await next).done) break;
|
|
207
|
+
next = wakes.next();
|
|
208
|
+
await catchUp();
|
|
209
|
+
}
|
|
210
|
+
} finally {
|
|
211
|
+
clearInterval(heartbeat);
|
|
212
|
+
aborter.abort();
|
|
213
|
+
await wakes.return?.();
|
|
214
|
+
res.end();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* When the events route reads next: every POLL_MS, and sooner whenever the
|
|
220
|
+
* store's `watch` says a notice may have landed. `watch` alone is not enough.
|
|
221
|
+
* A wake-up can be lost, and a LISTEN connection can drop, and either would
|
|
222
|
+
* leave the stream open on heartbeats with a notice it never reads. A `watch`
|
|
223
|
+
* that ends or fails leaves the reads every POLL_MS to carry on.
|
|
224
|
+
*/
|
|
225
|
+
async function* wakeups(watch: AsyncIterable<void> | undefined, signal: AbortSignal): AsyncIterable<void> {
|
|
226
|
+
const steps = watch?.[Symbol.asyncIterator]();
|
|
227
|
+
// started now, so the watch is listening before the caller's next read
|
|
228
|
+
let step: Promise<unknown> | null = steps?.next().then((r) => !r.done, () => false) ?? null;
|
|
229
|
+
try {
|
|
230
|
+
while (!signal.aborted) {
|
|
231
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
232
|
+
let onAbort: (() => void) | undefined;
|
|
233
|
+
const tick = new Promise<"tick">((resolve) => {
|
|
234
|
+
onAbort = () => resolve("tick");
|
|
235
|
+
timer = setTimeout(onAbort, POLL_MS);
|
|
236
|
+
signal.addEventListener("abort", onAbort);
|
|
237
|
+
});
|
|
238
|
+
const woke = await Promise.race(step ? [tick, step] : [tick]);
|
|
239
|
+
clearTimeout(timer);
|
|
240
|
+
signal.removeEventListener("abort", onAbort!);
|
|
241
|
+
if (signal.aborted) return;
|
|
242
|
+
// the watch stepped: listen for the next; it ended or failed: stop listening
|
|
243
|
+
if (woke !== "tick") step = woke ? steps!.next().then((r) => !r.done, () => false) : null;
|
|
244
|
+
yield;
|
|
245
|
+
}
|
|
246
|
+
} finally {
|
|
247
|
+
await steps?.return?.();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
94
251
|
function openSSE(res: ServerResponse): Emit {
|
|
95
252
|
res.writeHead(200, {
|
|
96
253
|
"content-type": "text/event-stream",
|
package/src/runtime.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
2
|
estTokens,
|
|
3
|
+
type AnyNoticeImpl,
|
|
3
4
|
isStaleLease,
|
|
4
5
|
makeCap,
|
|
5
6
|
type AnySurfaceImpl,
|
|
6
7
|
type Conversation,
|
|
7
8
|
type Emit,
|
|
8
9
|
type ModelAdapter,
|
|
10
|
+
type Notify,
|
|
11
|
+
type Progress,
|
|
9
12
|
type StoreAdapter,
|
|
10
13
|
type Tool,
|
|
11
14
|
type ToolCtx,
|
|
@@ -47,6 +50,12 @@ export interface HaiConfig {
|
|
|
47
50
|
* model gets "already ran" back instead of running it again.
|
|
48
51
|
*/
|
|
49
52
|
init?: Tool;
|
|
53
|
+
/**
|
|
54
|
+
* The notices this app sends with `hai.notify`. A notice not listed here is
|
|
55
|
+
* refused, as an unregistered surface is. With any listed, the browser opens
|
|
56
|
+
* the events stream (`GET {base}/events`) to receive them.
|
|
57
|
+
*/
|
|
58
|
+
notices?: AnyNoticeImpl[];
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
/**
|
|
@@ -96,6 +105,7 @@ export class Hai {
|
|
|
96
105
|
readonly config: HaiConfig;
|
|
97
106
|
private readonly surfaces = new Map<string, AnySurfaceImpl>();
|
|
98
107
|
private readonly tools = new Map<string, Tool>();
|
|
108
|
+
private readonly notices = new Map<string, AnyNoticeImpl>();
|
|
99
109
|
/** What the model gets as its system prompt: the app's, then the scope. */
|
|
100
110
|
readonly system: string;
|
|
101
111
|
private seq = 0;
|
|
@@ -109,6 +119,10 @@ export class Hai {
|
|
|
109
119
|
this.surfaces.set(s.surface.name, s);
|
|
110
120
|
}
|
|
111
121
|
for (const t of config.tools) this.tools.set(t.name, t);
|
|
122
|
+
for (const n of config.notices ?? []) {
|
|
123
|
+
if (this.notices.has(n.notice.name)) throw new Error(`two notices are named "${n.notice.name}"`);
|
|
124
|
+
this.notices.set(n.notice.name, n);
|
|
125
|
+
}
|
|
112
126
|
// query_ui is DERIVED, never authored. It cannot drift from the surfaces
|
|
113
127
|
// that actually exist, and it always exists.
|
|
114
128
|
const queryUi = this.buildQueryUiTool();
|
|
@@ -122,6 +136,68 @@ export class Hai {
|
|
|
122
136
|
}
|
|
123
137
|
}
|
|
124
138
|
|
|
139
|
+
/** Whether this app sends notices, so the browser should open the events stream. */
|
|
140
|
+
get sendsNotices(): boolean {
|
|
141
|
+
return this.notices.size > 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Send a notice to a conversation: its payload to the browser now, over the
|
|
146
|
+
* events stream, and its model text to the model in the next user message
|
|
147
|
+
* the conversation records.
|
|
148
|
+
*
|
|
149
|
+
* Takes no lease, so it works while a turn is streaming, while one is
|
|
150
|
+
* parked, and with nobody connected. It never starts a turn.
|
|
151
|
+
*/
|
|
152
|
+
readonly notify: Notify = async (conversationId, notice, payload, options = {}) => {
|
|
153
|
+
const name = notice?.notice?.name;
|
|
154
|
+
if (this.notices.get(name) !== notice) {
|
|
155
|
+
throw new Error(`notice "${name}" is not registered: list it in createHai({ notices })`);
|
|
156
|
+
}
|
|
157
|
+
// Validated before it is stored: notify is called from webhooks and job
|
|
158
|
+
// queues, whose data comes from outside this process.
|
|
159
|
+
const parsed = notice.notice.payload.parse(payload);
|
|
160
|
+
const model: unknown = notice.impl.model(parsed);
|
|
161
|
+
if (model !== null && typeof model !== "string") {
|
|
162
|
+
throw new TypeError(`notice "${name}": model() must return a string or null`);
|
|
163
|
+
}
|
|
164
|
+
// Checked against the stored surfaces, which needs no lease. One left behind
|
|
165
|
+
// by an overtaken turn may pass as well, until it is swept, unlike a click
|
|
166
|
+
// on it: a click changes the history, and the handle here only says where
|
|
167
|
+
// the browser shows the notice. The model never sees it.
|
|
168
|
+
const { handle } = options;
|
|
169
|
+
if (handle !== undefined && !(await this.config.store.getPayload(handle, conversationId))) {
|
|
170
|
+
throw new Error(`notice "${name}": no surface ${handle} in conversation ${conversationId}`);
|
|
171
|
+
}
|
|
172
|
+
const record = await this.config.store.putNotice({
|
|
173
|
+
conversationId,
|
|
174
|
+
name,
|
|
175
|
+
version: notice.notice.version,
|
|
176
|
+
payload: parsed,
|
|
177
|
+
model,
|
|
178
|
+
...(handle === undefined ? {} : { handle }),
|
|
179
|
+
});
|
|
180
|
+
return { seq: record.seq };
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The notices this conversation's history hasn't taken in yet: their model
|
|
185
|
+
* text as blocks for the user message this request is about to record, and
|
|
186
|
+
* the number to set `noticedThrough` to when it does. Set it only where the
|
|
187
|
+
* message is pushed, so a request refused before then takes nothing in.
|
|
188
|
+
*/
|
|
189
|
+
private async unreadNotices(conversation: Conversation): Promise<{ blocks: TextBlock[]; through: number }> {
|
|
190
|
+
const through = conversation.noticedThrough ?? 0;
|
|
191
|
+
if (!this.notices.size) return { blocks: [], through };
|
|
192
|
+
const fresh = (await this.config.store.getNotices(conversation.id, through)) ?? [];
|
|
193
|
+
return {
|
|
194
|
+
blocks: fresh.flatMap((n) =>
|
|
195
|
+
n.model === null ? [] : [{ type: "text" as const, text: `[App notification: ${n.name}] ${n.model}` }],
|
|
196
|
+
),
|
|
197
|
+
through: fresh.at(-1)?.seq ?? through,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
125
201
|
private nid(prefix: string) {
|
|
126
202
|
return `${prefix}_${++this.seq}`;
|
|
127
203
|
}
|
|
@@ -215,6 +291,10 @@ export class Hai {
|
|
|
215
291
|
|
|
216
292
|
// The first message is the one that starts the conversation.
|
|
217
293
|
const starting = conversation.messages.length === 0;
|
|
294
|
+
// Notices ride in the user's message, ahead of what they typed: they
|
|
295
|
+
// arrived before it.
|
|
296
|
+
const unread = await this.unreadNotices(conversation);
|
|
297
|
+
const message = withText(unread.blocks, text);
|
|
218
298
|
|
|
219
299
|
// The user typed while a tool was parked. Every tool_use in a turn must
|
|
220
300
|
// receive a tool_result, so close the pending one out honestly first.
|
|
@@ -240,9 +320,10 @@ export class Hai {
|
|
|
240
320
|
|
|
241
321
|
emit({ type: "block_start", block: { kind: "user", id: this.nid("b"), text } });
|
|
242
322
|
if (starting && this.config.init) {
|
|
243
|
-
if (await this.runInit(conversation,
|
|
323
|
+
if (await this.runInit(conversation, message, emit, unread.through)) return; // parked on its surface
|
|
244
324
|
} else {
|
|
245
|
-
conversation.messages.push({ role: "user", content:
|
|
325
|
+
conversation.messages.push({ role: "user", content: message });
|
|
326
|
+
conversation.noticedThrough = unread.through;
|
|
246
327
|
}
|
|
247
328
|
await this.runTurn(conversation, emit);
|
|
248
329
|
}
|
|
@@ -255,22 +336,29 @@ export class Hai {
|
|
|
255
336
|
*/
|
|
256
337
|
async start(conversation: Conversation, emit: Emit): Promise<void> {
|
|
257
338
|
if (!this.config.init || conversation.messages.length > 0) return;
|
|
258
|
-
|
|
339
|
+
const unread = await this.unreadNotices(conversation);
|
|
340
|
+
if (await this.runInit(conversation, withText(unread.blocks, STARTED), emit, unread.through)) return; // parked on its surface
|
|
259
341
|
conversation.status = "idle";
|
|
260
342
|
emit({ type: "status", status: "idle" });
|
|
261
343
|
await this.emitContext(conversation, emit);
|
|
262
344
|
}
|
|
263
345
|
|
|
264
346
|
/**
|
|
265
|
-
* Run the init tool as the conversation's first tool call, after `
|
|
266
|
-
* the user's first message, or STARTED when it runs before one
|
|
267
|
-
*
|
|
347
|
+
* Run the init tool as the conversation's first tool call, after `message` —
|
|
348
|
+
* the user's first message, or STARTED when it runs before one, with any
|
|
349
|
+
* unread notices, which take the conversation's notices through `through`.
|
|
350
|
+
* Returns true when it parked the conversation on an elicit surface.
|
|
268
351
|
*
|
|
269
352
|
* Nothing reaches the history until the tool has succeeded — not even the
|
|
270
353
|
* user's message — so a refused start leaves the conversation exactly as
|
|
271
354
|
* the route found it, and the next message starts it again.
|
|
272
355
|
*/
|
|
273
|
-
private async runInit(
|
|
356
|
+
private async runInit(
|
|
357
|
+
conversation: Conversation,
|
|
358
|
+
message: string | TextBlock[],
|
|
359
|
+
emit: Emit,
|
|
360
|
+
through: number,
|
|
361
|
+
): Promise<boolean> {
|
|
274
362
|
const init = this.config.init!;
|
|
275
363
|
const call = { type: "tool_use", id: `toolu_init_${newId()}`, name: init.name, input: {} };
|
|
276
364
|
const toolBlockId = this.nid("b");
|
|
@@ -306,7 +394,8 @@ export class Hai {
|
|
|
306
394
|
const ms = Date.now() - started;
|
|
307
395
|
for (const e of held) emit(e);
|
|
308
396
|
|
|
309
|
-
conversation.messages.push({ role: "user", content:
|
|
397
|
+
conversation.messages.push({ role: "user", content: message });
|
|
398
|
+
conversation.noticedThrough = through;
|
|
310
399
|
conversation.messages.push({ role: "assistant", content: [call] });
|
|
311
400
|
|
|
312
401
|
if (asked) {
|
|
@@ -388,11 +477,15 @@ export class Hai {
|
|
|
388
477
|
throw new Error(`action failed: ${(err as Error).message}`);
|
|
389
478
|
}
|
|
390
479
|
|
|
480
|
+
// Read after the handler, so a notice its own work sent rides with the click.
|
|
481
|
+
const unread = await this.unreadNotices(conversation);
|
|
482
|
+
|
|
391
483
|
// what recording the click changes, so a failed save below can undo it
|
|
392
484
|
const before = {
|
|
393
485
|
messages: conversation.messages.length,
|
|
394
486
|
frozen: conversation.frozen.length,
|
|
395
487
|
status: conversation.status,
|
|
488
|
+
noticedThrough: conversation.noticedThrough,
|
|
396
489
|
};
|
|
397
490
|
if (pending) {
|
|
398
491
|
// resolve: the gate above let it through only for the waiting surface
|
|
@@ -408,14 +501,17 @@ export class Hai {
|
|
|
408
501
|
// so nothing about the search has reached the model yet.
|
|
409
502
|
content: `${pending.digest}\n${label}`,
|
|
410
503
|
},
|
|
504
|
+
// after the tool results, which the API requires to come first
|
|
505
|
+
...unread.blocks,
|
|
411
506
|
],
|
|
412
507
|
});
|
|
413
508
|
conversation.pending = null;
|
|
414
509
|
conversation.status = "idle"; // answered; the save below records that
|
|
415
510
|
} else {
|
|
416
511
|
// inform: enriches the conversation without having blocked it
|
|
417
|
-
conversation.messages.push({ role: "user", content: `[UI interaction] ${label}` });
|
|
512
|
+
conversation.messages.push({ role: "user", content: withText(unread.blocks, `[UI interaction] ${label}`) });
|
|
418
513
|
}
|
|
514
|
+
conversation.noticedThrough = unread.through;
|
|
419
515
|
|
|
420
516
|
// Commit the click before the model runs. The handler may have written,
|
|
421
517
|
// and a model turn can outlast the lease; saved now, the click stays on
|
|
@@ -436,6 +532,7 @@ export class Hai {
|
|
|
436
532
|
conversation.frozen.length = before.frozen;
|
|
437
533
|
conversation.pending = pending;
|
|
438
534
|
conversation.status = before.status;
|
|
535
|
+
conversation.noticedThrough = before.noticedThrough;
|
|
439
536
|
throw err;
|
|
440
537
|
}
|
|
441
538
|
|
|
@@ -706,9 +803,35 @@ export class Hai {
|
|
|
706
803
|
// set once the tool has returned or thrown: a render after that shows nothing
|
|
707
804
|
let done = false;
|
|
708
805
|
|
|
806
|
+
// Progress is throttled, not queued: a tool that reports every row would
|
|
807
|
+
// otherwise flood the stream with frames nobody can read. The first goes
|
|
808
|
+
// out at once, later ones at most every PROGRESS_MS, carrying every field
|
|
809
|
+
// named since the last, and whatever is still waiting when the tool
|
|
810
|
+
// returns goes out then.
|
|
811
|
+
let unsent: Progress | null = null;
|
|
812
|
+
let lastSent = 0;
|
|
813
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
814
|
+
const flushProgress = () => {
|
|
815
|
+
if (timer) clearTimeout(timer);
|
|
816
|
+
timer = null;
|
|
817
|
+
if (!unsent) return;
|
|
818
|
+
emit({ type: "progress", toolId: toolBlockId, ...unsent });
|
|
819
|
+
unsent = null;
|
|
820
|
+
lastSent = Date.now();
|
|
821
|
+
};
|
|
822
|
+
|
|
709
823
|
const ctx: ToolCtx = {
|
|
710
824
|
conversationId: conversation.id,
|
|
711
825
|
text: (model) => ({ model }),
|
|
826
|
+
progress: (progress) => {
|
|
827
|
+
if (done) return;
|
|
828
|
+
const fields = progressFields(progress);
|
|
829
|
+
if (!fields) return;
|
|
830
|
+
unsent = { ...unsent, ...fields };
|
|
831
|
+
const wait = lastSent + PROGRESS_MS - Date.now();
|
|
832
|
+
if (wait <= 0) flushProgress();
|
|
833
|
+
else timer ??= setTimeout(flushProgress, wait);
|
|
834
|
+
},
|
|
712
835
|
render: ((impl: AnySurfaceImpl, props: unknown, options?: { mode?: "display" | "elicit" }) => {
|
|
713
836
|
if (done) return Promise.resolve({ model: "Not shown: the tool had already finished." });
|
|
714
837
|
const shown = render(impl, props, options);
|
|
@@ -742,6 +865,8 @@ export class Hai {
|
|
|
742
865
|
// in while the others finish. They finish before anything is decided or
|
|
743
866
|
// undone, so none can open a question, or record a handle, afterwards.
|
|
744
867
|
done = true;
|
|
868
|
+
// the last frame goes out before the row says the call has finished
|
|
869
|
+
flushProgress();
|
|
745
870
|
await Promise.all(rendering);
|
|
746
871
|
// A tool error is information for the model; a lost lease is not. Fencing
|
|
747
872
|
// exists so a superseded turn stops — converting StaleLease into "tool
|
|
@@ -830,6 +955,41 @@ function strictest(recorded: unknown, current: unknown): number | "never" | unde
|
|
|
830
955
|
|
|
831
956
|
const newId = () => globalThis.crypto.randomUUID().replaceAll("-", "").slice(0, 16);
|
|
832
957
|
|
|
958
|
+
interface TextBlock {
|
|
959
|
+
type: "text";
|
|
960
|
+
text: string;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* A user message: `text` after any notice blocks, or `text` alone when there
|
|
965
|
+
* are none — so a conversation with no notices keeps exactly the history it
|
|
966
|
+
* always had.
|
|
967
|
+
*/
|
|
968
|
+
const withText = (blocks: TextBlock[], text: string): string | TextBlock[] =>
|
|
969
|
+
blocks.length ? [...blocks, { type: "text", text }] : text;
|
|
970
|
+
|
|
971
|
+
/** The least time between two progress frames from one tool call. */
|
|
972
|
+
const PROGRESS_MS = 100;
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* The fields of a `ctx.progress` call worth sending, or null if none are.
|
|
976
|
+
* Checked at runtime because JavaScript callers never see the type, and a
|
|
977
|
+
* progress report is not worth failing a tool over: a field that isn't what
|
|
978
|
+
* its type says is dropped, not thrown on. `done` must be a finite number,
|
|
979
|
+
* zero or more, and `total` a finite number more than zero: there is no bar
|
|
980
|
+
* to draw for a workload of nothing.
|
|
981
|
+
*/
|
|
982
|
+
function progressFields(progress: unknown): Progress | null {
|
|
983
|
+
if (!progress || typeof progress !== "object") return null;
|
|
984
|
+
const { message, done, total } = progress as Record<string, unknown>;
|
|
985
|
+
const finite = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n);
|
|
986
|
+
const fields: Progress = {};
|
|
987
|
+
if (typeof message === "string") fields.message = message;
|
|
988
|
+
if (finite(done) && done >= 0) fields.done = done;
|
|
989
|
+
if (finite(total) && total > 0) fields.total = total;
|
|
990
|
+
return Object.keys(fields).length ? fields : null;
|
|
991
|
+
}
|
|
992
|
+
|
|
833
993
|
/** "2 hours", "15 minutes" — for a sentence a person reads. */
|
|
834
994
|
function duration(ms: number): string {
|
|
835
995
|
const units: [number, string][] = [
|
package/src/store.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
ConversationBusy,
|
|
3
3
|
StaleLease,
|
|
4
4
|
type Conversation,
|
|
5
|
+
type NoticeRecord,
|
|
5
6
|
type PayloadRecord,
|
|
6
7
|
type StoreAdapter,
|
|
7
8
|
} from "@haikit/core";
|
|
@@ -49,6 +50,11 @@ export function memoryStore(options: MemoryStoreOptions = {}): StoreAdapter {
|
|
|
49
50
|
}
|
|
50
51
|
const conversations = new Map<string, Conversation>();
|
|
51
52
|
const payloads = new Map<string, PayloadRecord>();
|
|
53
|
+
// conversation id → its notices in seq order, and who is waiting to hear of
|
|
54
|
+
// the next one. Numbered per conversation: nothing awaits between taking a
|
|
55
|
+
// number and appending, so commit order is seq order for free.
|
|
56
|
+
const notices = new Map<string, NoticeRecord[]>();
|
|
57
|
+
const waiting = new Map<string, Set<() => void>>();
|
|
52
58
|
let convSeq = 0;
|
|
53
59
|
let handleSeq = 0;
|
|
54
60
|
|
|
@@ -95,6 +101,7 @@ export function memoryStore(options: MemoryStoreOptions = {}): StoreAdapter {
|
|
|
95
101
|
pending: null,
|
|
96
102
|
leaseUntil: Date.now() + leaseMs,
|
|
97
103
|
leaseToken: newToken(),
|
|
104
|
+
noticedThrough: 0,
|
|
98
105
|
};
|
|
99
106
|
conversations.set(conversation.id, conversation);
|
|
100
107
|
return copy(conversation);
|
|
@@ -129,5 +136,63 @@ export function memoryStore(options: MemoryStoreOptions = {}): StoreAdapter {
|
|
|
129
136
|
}
|
|
130
137
|
return out;
|
|
131
138
|
},
|
|
139
|
+
|
|
140
|
+
/** Unfenced, as the contract says: a notice lands whoever holds the turn. */
|
|
141
|
+
async putNotice(record) {
|
|
142
|
+
if (!conversations.has(record.conversationId)) {
|
|
143
|
+
throw new Error(`conversation ${record.conversationId} does not exist`);
|
|
144
|
+
}
|
|
145
|
+
const list = notices.get(record.conversationId) ?? [];
|
|
146
|
+
const stored: NoticeRecord = copy({ ...record, seq: (list.at(-1)?.seq ?? 0) + 1, createdAt: Date.now() });
|
|
147
|
+
list.push(stored);
|
|
148
|
+
notices.set(record.conversationId, list);
|
|
149
|
+
for (const wake of waiting.get(record.conversationId) ?? []) wake();
|
|
150
|
+
return copy(stored);
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
async getNotices(conversationId, after, limit = Infinity) {
|
|
154
|
+
if (!conversations.has(conversationId)) return null;
|
|
155
|
+
const list = notices.get(conversationId) ?? [];
|
|
156
|
+
// Seq order, so the first one past `after` is found by halving, and only
|
|
157
|
+
// the page asked for is copied: reading a long backlog a page at a time
|
|
158
|
+
// never walks or copies the rest of it.
|
|
159
|
+
let lo = 0;
|
|
160
|
+
let hi = list.length;
|
|
161
|
+
while (lo < hi) {
|
|
162
|
+
const mid = (lo + hi) >> 1;
|
|
163
|
+
if (list[mid].seq > after) hi = mid;
|
|
164
|
+
else lo = mid + 1;
|
|
165
|
+
}
|
|
166
|
+
return copy(list.slice(lo, lo + limit));
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
async *watch(conversationId, signal) {
|
|
170
|
+
// One wake-up stands for any number of notices: a step resolves once
|
|
171
|
+
// something has landed since the last, and the caller reads them all.
|
|
172
|
+
let landed = false;
|
|
173
|
+
let wake: (() => void) | null = null;
|
|
174
|
+
const listener = () => {
|
|
175
|
+
landed = true;
|
|
176
|
+
wake?.();
|
|
177
|
+
};
|
|
178
|
+
const onAbort = () => wake?.();
|
|
179
|
+
const set = waiting.get(conversationId) ?? new Set();
|
|
180
|
+
set.add(listener);
|
|
181
|
+
waiting.set(conversationId, set);
|
|
182
|
+
signal.addEventListener("abort", onAbort);
|
|
183
|
+
try {
|
|
184
|
+
while (!signal.aborted) {
|
|
185
|
+
if (!landed) await new Promise<void>((r) => (wake = r));
|
|
186
|
+
wake = null;
|
|
187
|
+
if (signal.aborted) return;
|
|
188
|
+
landed = false;
|
|
189
|
+
yield;
|
|
190
|
+
}
|
|
191
|
+
} finally {
|
|
192
|
+
signal.removeEventListener("abort", onAbort);
|
|
193
|
+
set.delete(listener);
|
|
194
|
+
if (!set.size) waiting.delete(conversationId);
|
|
195
|
+
}
|
|
196
|
+
},
|
|
132
197
|
};
|
|
133
198
|
}
|