laohuang 0.3.2 → 0.4.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 +142 -16
- package/dist/agent.d.ts +166 -0
- package/dist/agent.js +858 -0
- package/dist/agent.js.map +1 -0
- package/dist/bash-runner.d.ts +79 -0
- package/dist/bash-runner.js +464 -0
- package/dist/bash-runner.js.map +1 -0
- package/dist/cancellation.d.ts +36 -0
- package/dist/cancellation.js +123 -0
- package/dist/cancellation.js.map +1 -0
- package/dist/cli.d.ts +117 -0
- package/dist/cli.js +1307 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +21 -0
- package/dist/client.js +17 -0
- package/dist/client.js.map +1 -0
- package/dist/commands.d.ts +123 -0
- package/dist/commands.js +660 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +51 -0
- package/dist/config.js +183 -0
- package/dist/config.js.map +1 -0
- package/dist/credentials.d.ts +12 -0
- package/dist/credentials.js +102 -0
- package/dist/credentials.js.map +1 -0
- package/dist/events.d.ts +288 -0
- package/dist/events.js +838 -0
- package/dist/events.js.map +1 -0
- package/dist/model-adapter.d.ts +138 -0
- package/dist/model-adapter.js +244 -0
- package/dist/model-adapter.js.map +1 -0
- package/dist/model-selection.d.ts +67 -0
- package/dist/model-selection.js +148 -0
- package/dist/model-selection.js.map +1 -0
- package/dist/model-stream.d.ts +128 -0
- package/dist/model-stream.js +582 -0
- package/dist/model-stream.js.map +1 -0
- package/dist/project-instructions.d.ts +99 -0
- package/dist/project-instructions.js +348 -0
- package/dist/project-instructions.js.map +1 -0
- package/dist/providers.d.ts +9 -0
- package/dist/providers.js +26 -0
- package/dist/providers.js.map +1 -0
- package/dist/routing.d.ts +170 -0
- package/dist/routing.js +669 -0
- package/dist/routing.js.map +1 -0
- package/dist/semantic-classifier.d.ts +66 -0
- package/dist/semantic-classifier.js +86 -0
- package/dist/semantic-classifier.js.map +1 -0
- package/dist/session.d.ts +162 -0
- package/dist/session.js +871 -0
- package/dist/session.js.map +1 -0
- package/dist/system-prompt.d.ts +16 -0
- package/dist/system-prompt.js +42 -0
- package/dist/system-prompt.js.map +1 -0
- package/dist/terminal/editor.d.ts +161 -0
- package/dist/terminal/editor.js +1060 -0
- package/dist/terminal/editor.js.map +1 -0
- package/dist/terminal/input.d.ts +61 -0
- package/dist/terminal/input.js +276 -0
- package/dist/terminal/input.js.map +1 -0
- package/dist/terminal/markdown.d.ts +20 -0
- package/dist/terminal/markdown.js +621 -0
- package/dist/terminal/markdown.js.map +1 -0
- package/dist/terminal/screen.d.ts +66 -0
- package/dist/terminal/screen.js +624 -0
- package/dist/terminal/screen.js.map +1 -0
- package/dist/terminal/theme.d.ts +23 -0
- package/dist/terminal/theme.js +101 -0
- package/dist/terminal/theme.js.map +1 -0
- package/dist/terminal/ui.d.ts +285 -0
- package/dist/terminal/ui.js +1815 -0
- package/dist/terminal/ui.js.map +1 -0
- package/dist/tools.d.ts +95 -0
- package/dist/tools.js +444 -0
- package/dist/tools.js.map +1 -0
- package/dist/ui-state.d.ts +51 -0
- package/dist/ui-state.js +194 -0
- package/dist/ui-state.js.map +1 -0
- package/dist/web.d.ts +56 -0
- package/dist/web.js +247 -0
- package/dist/web.js.map +1 -0
- package/package.json +24 -21
- package/bin/laohuang.js +0 -10
- package/lib/launcher.js +0 -133
- package/vendor/laohuangcode-0.3.2-py3-none-any.whl +0 -0
package/dist/session.js
ADDED
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
/** Single-active-task runtime coordination for laoHuangCode. */
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { CancellationError } from "./cancellation.js";
|
|
4
|
+
import { EventBus, EventKind, EventSource, } from "./events.js";
|
|
5
|
+
import { DeadLetterQueue, EventRouter, HeldQueue, PendingQueue, QueueOverflowError, Scheduler, TaskRegistry, TaskState, combineInput, } from "./routing.js";
|
|
6
|
+
export const SessionState = {
|
|
7
|
+
Idle: "idle",
|
|
8
|
+
Running: "running",
|
|
9
|
+
Cancelling: "cancelling",
|
|
10
|
+
Stopped: "stopped",
|
|
11
|
+
};
|
|
12
|
+
const TERMINAL_TASK_STATES = new Set([
|
|
13
|
+
TaskState.Cancelled,
|
|
14
|
+
TaskState.Completed,
|
|
15
|
+
TaskState.Failed,
|
|
16
|
+
]);
|
|
17
|
+
const ROUTE_STRATEGIES = new Set([
|
|
18
|
+
"execute",
|
|
19
|
+
"steer",
|
|
20
|
+
"follow_up",
|
|
21
|
+
"cancel",
|
|
22
|
+
"reject",
|
|
23
|
+
]);
|
|
24
|
+
function makeBatch(events) {
|
|
25
|
+
return {
|
|
26
|
+
events,
|
|
27
|
+
content: combineInput(events),
|
|
28
|
+
eventIds: events.map((item) => item.event.event_id),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const EMPTY_BATCH = {
|
|
32
|
+
events: [],
|
|
33
|
+
content: "",
|
|
34
|
+
eventIds: [],
|
|
35
|
+
};
|
|
36
|
+
const assertCodingAgentIsRunner = true;
|
|
37
|
+
void assertCodingAgentIsRunner;
|
|
38
|
+
function sameEventIds(inflight, eventIds) {
|
|
39
|
+
if (inflight.length !== eventIds.length) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
return inflight.every((item, index) => item.event.event_id === eventIds[index]);
|
|
43
|
+
}
|
|
44
|
+
/** Capabilities exposed to one model/tool worker invocation. */
|
|
45
|
+
export class TaskContext {
|
|
46
|
+
sessionId;
|
|
47
|
+
taskId;
|
|
48
|
+
#session;
|
|
49
|
+
#claimedInputEventIds = [];
|
|
50
|
+
constructor(session, taskId) {
|
|
51
|
+
this.#session = session;
|
|
52
|
+
this.sessionId = session.sessionId;
|
|
53
|
+
this.taskId = taskId;
|
|
54
|
+
}
|
|
55
|
+
get cancelToken() {
|
|
56
|
+
const record = this.#session.taskRegistry.get(this.taskId);
|
|
57
|
+
if (record === null) {
|
|
58
|
+
throw new Error(`task no longer exists: ${this.taskId}`);
|
|
59
|
+
}
|
|
60
|
+
return record.cancelToken;
|
|
61
|
+
}
|
|
62
|
+
get eventBus() {
|
|
63
|
+
return this.#session.eventBus;
|
|
64
|
+
}
|
|
65
|
+
isActive() {
|
|
66
|
+
return this.#session.isTaskActive(this.taskId);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Normalize and route a model/tool callback before fan-out. Await it to
|
|
70
|
+
* preserve canonical ordering: the routing decision is published before the
|
|
71
|
+
* event itself. Option names follow the events.ts EventBus.publish
|
|
72
|
+
* convention (snake_case), matching agent.ts's AgentContext.publish.
|
|
73
|
+
*/
|
|
74
|
+
publish(kind, options) {
|
|
75
|
+
return this.#session.publishInternalEvent(kind, {
|
|
76
|
+
source: options.source,
|
|
77
|
+
taskId: this.taskId,
|
|
78
|
+
correlationId: options.correlation_id ?? null,
|
|
79
|
+
payload: options.payload,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
setState(state) {
|
|
83
|
+
return this.#session.setRunningState(this.taskId, state);
|
|
84
|
+
}
|
|
85
|
+
modelStarted() {
|
|
86
|
+
return this.setState(TaskState.RunningModel);
|
|
87
|
+
}
|
|
88
|
+
/** Acknowledge claimed input only once the SDK opened its request. */
|
|
89
|
+
modelRequestOpened() {
|
|
90
|
+
const acknowledged = this.#session.ackClaimedInput(this.taskId, this.#claimedInputEventIds);
|
|
91
|
+
if (acknowledged) {
|
|
92
|
+
this.#claimedInputEventIds = [];
|
|
93
|
+
}
|
|
94
|
+
return acknowledged;
|
|
95
|
+
}
|
|
96
|
+
toolsStarted() {
|
|
97
|
+
return this.setState(TaskState.RunningTools);
|
|
98
|
+
}
|
|
99
|
+
/** Atomically take one compatible pending batch for this task. */
|
|
100
|
+
safePoint() {
|
|
101
|
+
return this.#session.drainPending(this.taskId);
|
|
102
|
+
}
|
|
103
|
+
/** @internal Used by the session worker around each runner invocation. */
|
|
104
|
+
setClaimedInput(batch) {
|
|
105
|
+
this.#claimedInputEventIds = batch === null ? [] : batch.eventIds;
|
|
106
|
+
}
|
|
107
|
+
commitInput(callback, rollback = () => { }) {
|
|
108
|
+
return this.#session.commitClaimedInput(this.taskId, this.#claimedInputEventIds, callback, rollback);
|
|
109
|
+
}
|
|
110
|
+
commitPending(batch, callback, rollback = () => { }) {
|
|
111
|
+
const committed = this.#session.commitClaimedInput(this.taskId, batch.eventIds, callback, rollback);
|
|
112
|
+
if (committed) {
|
|
113
|
+
this.#claimedInputEventIds = batch.eventIds;
|
|
114
|
+
}
|
|
115
|
+
return committed;
|
|
116
|
+
}
|
|
117
|
+
/** Atomically reject history commits once cancellation has won. */
|
|
118
|
+
commitIfActive(callback) {
|
|
119
|
+
return this.#session.commitIfActive(this.taskId, callback);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Owns runtime coordination while the CodingAgent owns model/tool logic.
|
|
124
|
+
*
|
|
125
|
+
* The Python original coordinates worker threads with locks; here every
|
|
126
|
+
* critical section is synchronous, so atomicity comes from the event loop.
|
|
127
|
+
* The only yield points are routing (semantic classification may be a network
|
|
128
|
+
* call) and awaiting the runner, mirroring the spots where Python released
|
|
129
|
+
* its coordination lock.
|
|
130
|
+
*/
|
|
131
|
+
export class AgentSession {
|
|
132
|
+
sessionId;
|
|
133
|
+
eventBus;
|
|
134
|
+
taskRegistry;
|
|
135
|
+
pending;
|
|
136
|
+
held;
|
|
137
|
+
deadLetters;
|
|
138
|
+
scheduler;
|
|
139
|
+
router;
|
|
140
|
+
runner;
|
|
141
|
+
#state = SessionState.Idle;
|
|
142
|
+
#worker = null;
|
|
143
|
+
#workerActive = false;
|
|
144
|
+
#closeRequested = false;
|
|
145
|
+
#inflightPending = new Map();
|
|
146
|
+
#inflightRollbacks = new Map();
|
|
147
|
+
#idle = true;
|
|
148
|
+
#idleWaiters = new Set();
|
|
149
|
+
#finalizePromise = null;
|
|
150
|
+
constructor(runner, options = {}) {
|
|
151
|
+
this.sessionId = options.sessionId ?? randomUUID().replaceAll("-", "");
|
|
152
|
+
this.eventBus = options.eventBus ?? new EventBus();
|
|
153
|
+
this.taskRegistry = options.taskRegistry ?? new TaskRegistry();
|
|
154
|
+
this.pending = new PendingQueue();
|
|
155
|
+
this.held = new HeldQueue();
|
|
156
|
+
this.deadLetters = new DeadLetterQueue();
|
|
157
|
+
this.scheduler = new Scheduler({
|
|
158
|
+
pending: this.pending,
|
|
159
|
+
held: this.held,
|
|
160
|
+
deadLetters: this.deadLetters,
|
|
161
|
+
});
|
|
162
|
+
this.router = new EventRouter(this.taskRegistry, {
|
|
163
|
+
semanticClassifier: options.semanticClassifier ?? null,
|
|
164
|
+
safetyPolicy: options.safetyPolicy ?? null,
|
|
165
|
+
});
|
|
166
|
+
this.runner = runner;
|
|
167
|
+
this.eventBus.publish(EventKind.SessionReady, {
|
|
168
|
+
source: EventSource.Session,
|
|
169
|
+
session_id: this.sessionId,
|
|
170
|
+
payload: {},
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
get state() {
|
|
174
|
+
return this.#state;
|
|
175
|
+
}
|
|
176
|
+
get activeTask() {
|
|
177
|
+
return this.taskRegistry.active();
|
|
178
|
+
}
|
|
179
|
+
get pendingCount() {
|
|
180
|
+
return this.pending.size;
|
|
181
|
+
}
|
|
182
|
+
get heldCount() {
|
|
183
|
+
return this.held.size;
|
|
184
|
+
}
|
|
185
|
+
async submitInput(content, options = {}) {
|
|
186
|
+
if (this.#state === SessionState.Stopped) {
|
|
187
|
+
throw new Error("session is stopped");
|
|
188
|
+
}
|
|
189
|
+
const text = content.trim();
|
|
190
|
+
if (!text) {
|
|
191
|
+
throw new Error("input must not be empty");
|
|
192
|
+
}
|
|
193
|
+
const strategy = options.strategy;
|
|
194
|
+
if (strategy !== undefined && !ROUTE_STRATEGIES.has(strategy)) {
|
|
195
|
+
throw new Error(`unknown route strategy: ${strategy}`);
|
|
196
|
+
}
|
|
197
|
+
if (text === "/cancel") {
|
|
198
|
+
const event = this.publishInput(EventKind.InputSlashCommand, text);
|
|
199
|
+
const routed = await this.router.route(event);
|
|
200
|
+
this.cancelRouted(routed);
|
|
201
|
+
return {
|
|
202
|
+
event,
|
|
203
|
+
routed,
|
|
204
|
+
taskId: routed.decision.taskId,
|
|
205
|
+
queued: false,
|
|
206
|
+
control: true,
|
|
207
|
+
rejected: false,
|
|
208
|
+
reason: "",
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const kind = text.startsWith("/")
|
|
212
|
+
? EventKind.InputSlashCommand
|
|
213
|
+
: EventKind.InputUserMessage;
|
|
214
|
+
const event = this.publishInput(kind, text, strategy);
|
|
215
|
+
const routingTaskId = this.taskRegistry.activeTaskId;
|
|
216
|
+
// Layer-three classification may make a network request. The event loop
|
|
217
|
+
// stays free while awaiting it, so the worker can finish, cancel, or
|
|
218
|
+
// reach a safe point; the decision is repaired afterwards if it did.
|
|
219
|
+
const routed = this.refreshRouteForCurrentTask(await this.router.route(event), routingTaskId);
|
|
220
|
+
// From here on the section is synchronous and therefore atomic.
|
|
221
|
+
this.publishRoute(routed);
|
|
222
|
+
const scheduled = this.scheduler.schedule(routed);
|
|
223
|
+
let taskId = routed.decision.taskId;
|
|
224
|
+
if (routed.decision.destination === "new_task") {
|
|
225
|
+
taskId = this.startTask(text, event.event_id);
|
|
226
|
+
}
|
|
227
|
+
else if (routed.decision.destination === "pending") {
|
|
228
|
+
this.eventBus.publish(EventKind.InputPending, {
|
|
229
|
+
source: EventSource.Session,
|
|
230
|
+
session_id: this.sessionId,
|
|
231
|
+
task_id: taskId,
|
|
232
|
+
correlation_id: event.event_id,
|
|
233
|
+
payload: { pending_count: this.pending.size },
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
else if (routed.decision.destination === "held") {
|
|
237
|
+
this.eventBus.publish(EventKind.InputHeld, {
|
|
238
|
+
source: EventSource.Session,
|
|
239
|
+
session_id: this.sessionId,
|
|
240
|
+
task_id: taskId,
|
|
241
|
+
correlation_id: event.event_id,
|
|
242
|
+
payload: { held_count: this.held.size },
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
if (scheduled.rejected) {
|
|
246
|
+
this.eventBus.publish(EventKind.RoutingRejected, {
|
|
247
|
+
source: EventSource.Router,
|
|
248
|
+
session_id: this.sessionId,
|
|
249
|
+
task_id: taskId,
|
|
250
|
+
correlation_id: event.event_id,
|
|
251
|
+
payload: {
|
|
252
|
+
reason: scheduled.reason,
|
|
253
|
+
destination: "drop",
|
|
254
|
+
layer: 4,
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
event,
|
|
260
|
+
routed,
|
|
261
|
+
taskId,
|
|
262
|
+
queued: scheduled.queued,
|
|
263
|
+
control: routed.decision.destination === "control",
|
|
264
|
+
rejected: scheduled.rejected,
|
|
265
|
+
reason: scheduled.reason,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
/** Repair a decision if task state changed during semantic routing. */
|
|
269
|
+
refreshRouteForCurrentTask(routed, routingTaskId) {
|
|
270
|
+
const decision = routed.decision;
|
|
271
|
+
const active = this.taskRegistry.active();
|
|
272
|
+
const destination = decision.destination;
|
|
273
|
+
if (routingTaskId !== null) {
|
|
274
|
+
const original = this.taskRegistry.get(routingTaskId);
|
|
275
|
+
if (active !== null && active.taskId === routingTaskId) {
|
|
276
|
+
if (active.state !== TaskState.Cancelling) {
|
|
277
|
+
return routed;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
else if (active === null &&
|
|
281
|
+
original !== null &&
|
|
282
|
+
original.state === TaskState.Completed) {
|
|
283
|
+
return {
|
|
284
|
+
event: routed.event,
|
|
285
|
+
decision: {
|
|
286
|
+
taskId: null,
|
|
287
|
+
destination: "new_task",
|
|
288
|
+
timing: "immediate",
|
|
289
|
+
strategy: "execute",
|
|
290
|
+
confidence: 1.0,
|
|
291
|
+
reason: "original task completed while routing; start follow-up task",
|
|
292
|
+
layer: 4,
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
event: routed.event,
|
|
298
|
+
decision: {
|
|
299
|
+
taskId: routingTaskId,
|
|
300
|
+
destination: "held",
|
|
301
|
+
timing: "after_cancel",
|
|
302
|
+
strategy: "follow_up",
|
|
303
|
+
confidence: 1.0,
|
|
304
|
+
reason: "original task stopped or changed while routing; hold input",
|
|
305
|
+
layer: 4,
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
const stale = (destination === "new_task" && active !== null) ||
|
|
310
|
+
((destination === "pending" || destination === "held") &&
|
|
311
|
+
(active === null || decision.taskId !== active.taskId));
|
|
312
|
+
if (!stale) {
|
|
313
|
+
return routed;
|
|
314
|
+
}
|
|
315
|
+
let replacement;
|
|
316
|
+
if (active === null) {
|
|
317
|
+
replacement = {
|
|
318
|
+
taskId: null,
|
|
319
|
+
destination: "new_task",
|
|
320
|
+
timing: "immediate",
|
|
321
|
+
strategy: "execute",
|
|
322
|
+
confidence: 1.0,
|
|
323
|
+
reason: "task state changed while routing; start a new task",
|
|
324
|
+
layer: 4,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
else if (active.state === TaskState.Cancelling) {
|
|
328
|
+
replacement = {
|
|
329
|
+
taskId: active.taskId,
|
|
330
|
+
destination: "held",
|
|
331
|
+
timing: "after_cancel",
|
|
332
|
+
strategy: "follow_up",
|
|
333
|
+
confidence: 1.0,
|
|
334
|
+
reason: "task began cancelling while routing; hold input",
|
|
335
|
+
layer: 4,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
replacement = {
|
|
340
|
+
taskId: active.taskId,
|
|
341
|
+
destination: "pending",
|
|
342
|
+
timing: "safe_point",
|
|
343
|
+
strategy: "follow_up",
|
|
344
|
+
confidence: 1.0,
|
|
345
|
+
reason: "active task changed while routing; queue safe follow-up",
|
|
346
|
+
layer: 4,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
return { event: routed.event, decision: replacement };
|
|
350
|
+
}
|
|
351
|
+
requestCancel(reason = "cancelled by user") {
|
|
352
|
+
const event = this.eventBus.publish(EventKind.InputCancelRequested, {
|
|
353
|
+
source: EventSource.User,
|
|
354
|
+
session_id: this.sessionId,
|
|
355
|
+
payload: { reason },
|
|
356
|
+
});
|
|
357
|
+
// EventRouter.route is async, but for InputCancelRequested the outcome is
|
|
358
|
+
// pinned by the layer-four safety arbiter regardless of classifier or
|
|
359
|
+
// policy verdicts, so the decision can be produced synchronously. (The
|
|
360
|
+
// policy callback itself is not invoked on this fast path.)
|
|
361
|
+
const active = this.taskRegistry.active();
|
|
362
|
+
const routed = {
|
|
363
|
+
event,
|
|
364
|
+
decision: {
|
|
365
|
+
taskId: active?.taskId ?? null,
|
|
366
|
+
destination: "control",
|
|
367
|
+
timing: "immediate",
|
|
368
|
+
strategy: "cancel",
|
|
369
|
+
confidence: 1.0,
|
|
370
|
+
reason: "safety arbiter preserves immediate cancellation",
|
|
371
|
+
layer: 4,
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
return this.cancelRouted(routed, reason);
|
|
375
|
+
}
|
|
376
|
+
/** Command-facing alias for task-scoped cancellation. */
|
|
377
|
+
cancelActiveTask() {
|
|
378
|
+
return this.requestCancel("cancelled by user");
|
|
379
|
+
}
|
|
380
|
+
queueStatus() {
|
|
381
|
+
return {
|
|
382
|
+
pending: this.pending.size,
|
|
383
|
+
held: this.held.size,
|
|
384
|
+
deadLetters: this.deadLetters.size,
|
|
385
|
+
pendingTokens: this.pending.estimatedTokens,
|
|
386
|
+
heldTokens: this.held.estimatedTokens,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
/** Publish user-facing local feedback in canonical event order. */
|
|
390
|
+
publishNotice(text, options = {}) {
|
|
391
|
+
return this.eventBus.publish(EventKind.UiMessage, {
|
|
392
|
+
source: EventSource.Session,
|
|
393
|
+
session_id: this.sessionId,
|
|
394
|
+
payload: { text: text, style: options.style ?? "" },
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
clearQueues() {
|
|
398
|
+
return (this.pending.clear() + this.held.clear() + this.deadLetters.clear());
|
|
399
|
+
}
|
|
400
|
+
/** Normalize and route a model/tool callback before fan-out. */
|
|
401
|
+
async publishInternalEvent(kind, options) {
|
|
402
|
+
const event = this.eventBus.factory.create(kind, {
|
|
403
|
+
source: options.source,
|
|
404
|
+
session_id: this.sessionId,
|
|
405
|
+
task_id: options.taskId,
|
|
406
|
+
correlation_id: options.correlationId,
|
|
407
|
+
payload: options.payload,
|
|
408
|
+
});
|
|
409
|
+
const routed = await this.router.route(event);
|
|
410
|
+
this.publishRoute(routed);
|
|
411
|
+
if (routed.decision.destination === "drop") {
|
|
412
|
+
this.deadLetters.put(event, routed.decision.reason);
|
|
413
|
+
return event;
|
|
414
|
+
}
|
|
415
|
+
return this.eventBus.publishEvent(event);
|
|
416
|
+
}
|
|
417
|
+
cancelRouted(routed, reason = "cancelled by user") {
|
|
418
|
+
this.publishRoute(routed);
|
|
419
|
+
const taskId = routed.decision.taskId;
|
|
420
|
+
if (taskId === null) {
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
const record = this.taskRegistry.get(taskId);
|
|
424
|
+
if (record === null || TERMINAL_TASK_STATES.has(record.state)) {
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
this.taskRegistry.transition(taskId, TaskState.Cancelling);
|
|
428
|
+
this.setSessionState(SessionState.Cancelling);
|
|
429
|
+
this.holdTaskInputs(taskId, "held because its task is cancelling");
|
|
430
|
+
this.publishTaskState(taskId, TaskState.Cancelling);
|
|
431
|
+
return record.cancelToken.cancel(reason);
|
|
432
|
+
}
|
|
433
|
+
waitForIdle(timeoutMs) {
|
|
434
|
+
if (this.#idle) {
|
|
435
|
+
return Promise.resolve(true);
|
|
436
|
+
}
|
|
437
|
+
if (timeoutMs !== undefined && timeoutMs <= 0) {
|
|
438
|
+
return Promise.resolve(false);
|
|
439
|
+
}
|
|
440
|
+
return new Promise((resolve) => {
|
|
441
|
+
let timer;
|
|
442
|
+
const onIdle = () => {
|
|
443
|
+
if (timer !== undefined) {
|
|
444
|
+
clearTimeout(timer);
|
|
445
|
+
}
|
|
446
|
+
this.#idleWaiters.delete(onIdle);
|
|
447
|
+
resolve(true);
|
|
448
|
+
};
|
|
449
|
+
this.#idleWaiters.add(onIdle);
|
|
450
|
+
if (timeoutMs !== undefined) {
|
|
451
|
+
timer = setTimeout(() => {
|
|
452
|
+
this.#idleWaiters.delete(onIdle);
|
|
453
|
+
resolve(false);
|
|
454
|
+
}, timeoutMs);
|
|
455
|
+
// A pending wait must never hold the process open on its own.
|
|
456
|
+
timer.unref();
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
isTaskActive(taskId) {
|
|
461
|
+
const active = this.taskRegistry.active();
|
|
462
|
+
return active !== null && active.taskId === taskId;
|
|
463
|
+
}
|
|
464
|
+
/** Start all held user inputs as one new task when currently idle. */
|
|
465
|
+
resumeHeld() {
|
|
466
|
+
if (this.taskRegistry.active() !== null) {
|
|
467
|
+
return 0;
|
|
468
|
+
}
|
|
469
|
+
const held = this.held.drain();
|
|
470
|
+
const content = combineInput(held);
|
|
471
|
+
if (!content) {
|
|
472
|
+
return 0;
|
|
473
|
+
}
|
|
474
|
+
this.startTask(content, null);
|
|
475
|
+
return held.length;
|
|
476
|
+
}
|
|
477
|
+
clearHeld() {
|
|
478
|
+
return this.held.drain().length;
|
|
479
|
+
}
|
|
480
|
+
async close(options = {}) {
|
|
481
|
+
const wait = options.wait ?? true;
|
|
482
|
+
if (this.#state === SessionState.Stopped) {
|
|
483
|
+
// A previous close may still be draining subscriber mailboxes; a
|
|
484
|
+
// returned close() must imply subscribers saw the terminal event.
|
|
485
|
+
await this.#finalizePromise;
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
this.#closeRequested = true;
|
|
489
|
+
if (this.activeTask !== null) {
|
|
490
|
+
this.requestCancel("session closed");
|
|
491
|
+
}
|
|
492
|
+
let clean = true;
|
|
493
|
+
if (wait) {
|
|
494
|
+
// Idle is only published by the owning worker after it settles, so a
|
|
495
|
+
// true result implies the worker has fully returned.
|
|
496
|
+
clean = await this.waitForIdle(options.timeoutMs);
|
|
497
|
+
}
|
|
498
|
+
else if (this.#workerActive) {
|
|
499
|
+
clean = false;
|
|
500
|
+
}
|
|
501
|
+
if (!clean) {
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
await this.finalizeStop();
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
507
|
+
finalizeStop() {
|
|
508
|
+
if (this.#state === SessionState.Stopped) {
|
|
509
|
+
// settleWorker may already have started finalization; join it so
|
|
510
|
+
// callers still observe the bus fully closed.
|
|
511
|
+
return this.#finalizePromise ?? Promise.resolve();
|
|
512
|
+
}
|
|
513
|
+
this.#state = SessionState.Stopped;
|
|
514
|
+
this.eventBus.publish(EventKind.SessionStopped, {
|
|
515
|
+
source: EventSource.Session,
|
|
516
|
+
session_id: this.sessionId,
|
|
517
|
+
payload: {},
|
|
518
|
+
});
|
|
519
|
+
// Python's close() runs bus.close() synchronously before returning, so
|
|
520
|
+
// subscribers are guaranteed to have seen the terminal event once the
|
|
521
|
+
// returned promise resolves.
|
|
522
|
+
this.#finalizePromise = this.eventBus.close();
|
|
523
|
+
return this.#finalizePromise;
|
|
524
|
+
}
|
|
525
|
+
publishInput(kind, content, strategy) {
|
|
526
|
+
const payload = { content };
|
|
527
|
+
if (strategy !== undefined) {
|
|
528
|
+
payload.strategy = strategy;
|
|
529
|
+
}
|
|
530
|
+
const options = {
|
|
531
|
+
source: EventSource.User,
|
|
532
|
+
session_id: this.sessionId,
|
|
533
|
+
payload,
|
|
534
|
+
};
|
|
535
|
+
return kind === EventKind.InputSlashCommand
|
|
536
|
+
? this.eventBus.publish(EventKind.InputSlashCommand, options)
|
|
537
|
+
: this.eventBus.publish(EventKind.InputUserMessage, options);
|
|
538
|
+
}
|
|
539
|
+
publishRoute(routed) {
|
|
540
|
+
const decision = routed.decision;
|
|
541
|
+
const kind = decision.destination === "drop"
|
|
542
|
+
? EventKind.RoutingRejected
|
|
543
|
+
: EventKind.RoutingDecided;
|
|
544
|
+
this.eventBus.publish(kind, {
|
|
545
|
+
source: EventSource.Router,
|
|
546
|
+
session_id: this.sessionId,
|
|
547
|
+
task_id: decision.taskId,
|
|
548
|
+
correlation_id: routed.event.event_id,
|
|
549
|
+
payload: {
|
|
550
|
+
destination: decision.destination,
|
|
551
|
+
timing: decision.timing,
|
|
552
|
+
strategy: decision.strategy,
|
|
553
|
+
confidence: decision.confidence,
|
|
554
|
+
reason: decision.reason,
|
|
555
|
+
layer: decision.layer,
|
|
556
|
+
},
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
startTask(content, inputEventId) {
|
|
560
|
+
const taskId = randomUUID().replaceAll("-", "");
|
|
561
|
+
this.taskRegistry.register(taskId);
|
|
562
|
+
this.setIdle(false);
|
|
563
|
+
this.setSessionState(SessionState.Running);
|
|
564
|
+
this.eventBus.publish(EventKind.TaskStarted, {
|
|
565
|
+
source: EventSource.Session,
|
|
566
|
+
session_id: this.sessionId,
|
|
567
|
+
task_id: taskId,
|
|
568
|
+
correlation_id: inputEventId,
|
|
569
|
+
payload: {
|
|
570
|
+
pending_count: this.pending.size,
|
|
571
|
+
held_count: this.held.size,
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
const workerKey = {};
|
|
575
|
+
this.#worker = workerKey;
|
|
576
|
+
this.#workerActive = true;
|
|
577
|
+
const worker = this.runTask(workerKey, taskId, content);
|
|
578
|
+
// The task body handles runner failures itself; this guard only prevents
|
|
579
|
+
// an unhandled rejection if event publication itself fails during
|
|
580
|
+
// teardown (the Python original lost those to the daemon thread as well).
|
|
581
|
+
void worker.catch(() => { });
|
|
582
|
+
return taskId;
|
|
583
|
+
}
|
|
584
|
+
async runTask(workerKey, taskId, content) {
|
|
585
|
+
const context = new TaskContext(this, taskId);
|
|
586
|
+
let currentInput = content;
|
|
587
|
+
let currentBatch = null;
|
|
588
|
+
let result = null;
|
|
589
|
+
try {
|
|
590
|
+
for (;;) {
|
|
591
|
+
context.cancelToken.throwIfCancelled();
|
|
592
|
+
this.setRunningState(taskId, TaskState.RunningModel);
|
|
593
|
+
context.setClaimedInput(currentBatch);
|
|
594
|
+
result = await this.invokeRunner(currentInput, context);
|
|
595
|
+
if (currentBatch !== null) {
|
|
596
|
+
this.ackClaimedInput(taskId, currentBatch.eventIds);
|
|
597
|
+
}
|
|
598
|
+
context.cancelToken.throwIfCancelled();
|
|
599
|
+
let cancelledAtBoundary = false;
|
|
600
|
+
// Synchronous critical section: drain or complete atomically.
|
|
601
|
+
const batch = this.drainPending(taskId);
|
|
602
|
+
if (batch.events.length > 0) {
|
|
603
|
+
currentInput = batch.content;
|
|
604
|
+
currentBatch = batch;
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
const record = this.taskRegistry.get(taskId);
|
|
608
|
+
cancelledAtBoundary =
|
|
609
|
+
record === null ||
|
|
610
|
+
record.state === TaskState.Cancelling ||
|
|
611
|
+
record.cancelToken.isCancelled();
|
|
612
|
+
if (!cancelledAtBoundary) {
|
|
613
|
+
this.taskRegistry.transition(taskId, TaskState.Completed, result === null ? {} : { result });
|
|
614
|
+
this.eventBus.publish(EventKind.TaskCompleted, {
|
|
615
|
+
source: EventSource.Session,
|
|
616
|
+
session_id: this.sessionId,
|
|
617
|
+
task_id: taskId,
|
|
618
|
+
payload: {
|
|
619
|
+
result: result ?? "",
|
|
620
|
+
pending_count: this.pending.size,
|
|
621
|
+
held_count: this.held.size,
|
|
622
|
+
},
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
if (cancelledAtBoundary) {
|
|
626
|
+
this.finishCancelled(taskId);
|
|
627
|
+
}
|
|
628
|
+
break;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
catch (error) {
|
|
632
|
+
if (error instanceof CancellationError) {
|
|
633
|
+
this.finishCancelled(taskId);
|
|
634
|
+
}
|
|
635
|
+
else {
|
|
636
|
+
const record = this.taskRegistry.get(taskId);
|
|
637
|
+
if (record !== null && record.cancelToken.isCancelled()) {
|
|
638
|
+
this.finishCancelled(taskId);
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
642
|
+
this.holdTaskInputs(taskId, "held because its task failed");
|
|
643
|
+
this.taskRegistry.transition(taskId, TaskState.Failed, {
|
|
644
|
+
error: message,
|
|
645
|
+
});
|
|
646
|
+
this.eventBus.publish(EventKind.TaskFailed, {
|
|
647
|
+
source: EventSource.Session,
|
|
648
|
+
session_id: this.sessionId,
|
|
649
|
+
task_id: taskId,
|
|
650
|
+
payload: {
|
|
651
|
+
error: message,
|
|
652
|
+
pending_count: this.pending.size,
|
|
653
|
+
held_count: this.held.size,
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
finally {
|
|
660
|
+
this.settleWorker(workerKey);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
settleWorker(workerKey) {
|
|
664
|
+
let shouldFinalize = false;
|
|
665
|
+
// A follow-up task may start after this task transitions to a terminal
|
|
666
|
+
// TaskState but before its worker returns. Only the worker which still
|
|
667
|
+
// owns the Session may publish the shared idle state; an older worker
|
|
668
|
+
// must not clobber its successor.
|
|
669
|
+
const ownsSession = this.#worker === workerKey;
|
|
670
|
+
if (ownsSession) {
|
|
671
|
+
this.#workerActive = false;
|
|
672
|
+
}
|
|
673
|
+
const noActiveTask = this.taskRegistry.active() === null;
|
|
674
|
+
if (ownsSession && noActiveTask) {
|
|
675
|
+
if (this.#state !== SessionState.Stopped) {
|
|
676
|
+
this.setSessionState(SessionState.Idle);
|
|
677
|
+
}
|
|
678
|
+
this.setIdle(true);
|
|
679
|
+
shouldFinalize = this.#closeRequested;
|
|
680
|
+
}
|
|
681
|
+
if (shouldFinalize) {
|
|
682
|
+
// No one awaits this path (close() already returned false or was never
|
|
683
|
+
// called); mirror the worker guard so a bus teardown failure cannot
|
|
684
|
+
// surface as an unhandled rejection.
|
|
685
|
+
void this.finalizeStop().catch(() => { });
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
/** @internal Test hook mirroring Python's ``_settle_worker``. */
|
|
689
|
+
_settleWorker(workerKey) {
|
|
690
|
+
this.settleWorker(workerKey);
|
|
691
|
+
}
|
|
692
|
+
finishCancelled(taskId) {
|
|
693
|
+
const record = this.taskRegistry.get(taskId);
|
|
694
|
+
if (record === null || TERMINAL_TASK_STATES.has(record.state)) {
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
this.holdTaskInputs(taskId, "held because its task was cancelled");
|
|
698
|
+
this.taskRegistry.transition(taskId, TaskState.Cancelled);
|
|
699
|
+
this.eventBus.publish(EventKind.TaskCancelled, {
|
|
700
|
+
source: EventSource.Session,
|
|
701
|
+
session_id: this.sessionId,
|
|
702
|
+
task_id: taskId,
|
|
703
|
+
payload: {
|
|
704
|
+
reason: record.cancelToken.reason ?? "cancelled",
|
|
705
|
+
pending_count: this.pending.size,
|
|
706
|
+
held_count: this.held.size,
|
|
707
|
+
},
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
/** Rollback and preserve every unacknowledged input for a task. */
|
|
711
|
+
holdTaskInputs(taskId, reason) {
|
|
712
|
+
const rollback = this.#inflightRollbacks.get(taskId);
|
|
713
|
+
this.#inflightRollbacks.delete(taskId);
|
|
714
|
+
if (rollback !== undefined) {
|
|
715
|
+
try {
|
|
716
|
+
rollback();
|
|
717
|
+
}
|
|
718
|
+
catch {
|
|
719
|
+
// A faulty rollback must not lose the inputs being preserved.
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
const inflight = this.#inflightPending.get(taskId) ?? [];
|
|
723
|
+
this.#inflightPending.delete(taskId);
|
|
724
|
+
const items = [...this.pending.drainTask(taskId), ...inflight];
|
|
725
|
+
let heldCount = 0;
|
|
726
|
+
for (const pending of items) {
|
|
727
|
+
const held = {
|
|
728
|
+
event: pending.event,
|
|
729
|
+
decision: {
|
|
730
|
+
...pending.decision,
|
|
731
|
+
destination: "held",
|
|
732
|
+
timing: "after_cancel",
|
|
733
|
+
strategy: "follow_up",
|
|
734
|
+
reason,
|
|
735
|
+
layer: 4,
|
|
736
|
+
},
|
|
737
|
+
};
|
|
738
|
+
try {
|
|
739
|
+
this.held.put(held);
|
|
740
|
+
heldCount += 1;
|
|
741
|
+
}
|
|
742
|
+
catch (error) {
|
|
743
|
+
if (!(error instanceof QueueOverflowError)) {
|
|
744
|
+
throw error;
|
|
745
|
+
}
|
|
746
|
+
this.deadLetters.put(pending.event, "held queue is full");
|
|
747
|
+
this.eventBus.publish(EventKind.RoutingRejected, {
|
|
748
|
+
source: EventSource.Router,
|
|
749
|
+
session_id: this.sessionId,
|
|
750
|
+
task_id: taskId,
|
|
751
|
+
correlation_id: pending.event.event_id,
|
|
752
|
+
payload: { reason: "held queue is full" },
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return heldCount;
|
|
757
|
+
}
|
|
758
|
+
async invokeRunner(content, context) {
|
|
759
|
+
const runner = this.runner;
|
|
760
|
+
const fn = typeof runner === "function" ? runner : runner.run.bind(runner);
|
|
761
|
+
return (await fn(content, context)) ?? null;
|
|
762
|
+
}
|
|
763
|
+
/** @internal Used by {@link TaskContext}. */
|
|
764
|
+
setRunningState(taskId, state) {
|
|
765
|
+
if (state !== TaskState.RunningModel && state !== TaskState.RunningTools) {
|
|
766
|
+
throw new Error("TaskContext can only enter model or tool running state");
|
|
767
|
+
}
|
|
768
|
+
const record = this.taskRegistry.get(taskId);
|
|
769
|
+
if (record === null ||
|
|
770
|
+
record.state === TaskState.Cancelling ||
|
|
771
|
+
TERMINAL_TASK_STATES.has(record.state)) {
|
|
772
|
+
return false;
|
|
773
|
+
}
|
|
774
|
+
if (record.state !== state) {
|
|
775
|
+
this.taskRegistry.transition(taskId, state);
|
|
776
|
+
this.publishTaskState(taskId, state);
|
|
777
|
+
}
|
|
778
|
+
return true;
|
|
779
|
+
}
|
|
780
|
+
/** @internal Used by {@link TaskContext}. */
|
|
781
|
+
commitIfActive(taskId, callback) {
|
|
782
|
+
const record = this.taskRegistry.get(taskId);
|
|
783
|
+
if (record === null ||
|
|
784
|
+
record.state === TaskState.Cancelling ||
|
|
785
|
+
record.cancelToken.isCancelled()) {
|
|
786
|
+
return false;
|
|
787
|
+
}
|
|
788
|
+
callback();
|
|
789
|
+
return true;
|
|
790
|
+
}
|
|
791
|
+
/** @internal Used by {@link TaskContext}. */
|
|
792
|
+
commitClaimedInput(taskId, eventIds, callback, rollback) {
|
|
793
|
+
const record = this.taskRegistry.get(taskId);
|
|
794
|
+
if (record === null ||
|
|
795
|
+
record.state === TaskState.Cancelling ||
|
|
796
|
+
record.cancelToken.isCancelled()) {
|
|
797
|
+
return false;
|
|
798
|
+
}
|
|
799
|
+
if (eventIds.length > 0) {
|
|
800
|
+
const inflight = this.#inflightPending.get(taskId) ?? [];
|
|
801
|
+
if (!sameEventIds(inflight, eventIds)) {
|
|
802
|
+
return false;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
callback();
|
|
806
|
+
if (eventIds.length > 0) {
|
|
807
|
+
this.#inflightRollbacks.set(taskId, rollback);
|
|
808
|
+
}
|
|
809
|
+
return true;
|
|
810
|
+
}
|
|
811
|
+
/** @internal Used by {@link TaskContext} and the worker loop. */
|
|
812
|
+
ackClaimedInput(taskId, eventIds) {
|
|
813
|
+
if (eventIds.length === 0) {
|
|
814
|
+
return true;
|
|
815
|
+
}
|
|
816
|
+
const record = this.taskRegistry.get(taskId);
|
|
817
|
+
const inflight = this.#inflightPending.get(taskId) ?? [];
|
|
818
|
+
if (record === null ||
|
|
819
|
+
record.state === TaskState.Cancelling ||
|
|
820
|
+
record.cancelToken.isCancelled() ||
|
|
821
|
+
!sameEventIds(inflight, eventIds)) {
|
|
822
|
+
return false;
|
|
823
|
+
}
|
|
824
|
+
this.#inflightPending.delete(taskId);
|
|
825
|
+
this.#inflightRollbacks.delete(taskId);
|
|
826
|
+
return true;
|
|
827
|
+
}
|
|
828
|
+
publishTaskState(taskId, state) {
|
|
829
|
+
this.eventBus.publish(EventKind.TaskStateChanged, {
|
|
830
|
+
source: EventSource.Session,
|
|
831
|
+
session_id: this.sessionId,
|
|
832
|
+
task_id: taskId,
|
|
833
|
+
payload: {
|
|
834
|
+
state,
|
|
835
|
+
state_name: state.toUpperCase(),
|
|
836
|
+
pending_count: this.pending.size,
|
|
837
|
+
held_count: this.held.size,
|
|
838
|
+
},
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
/** @internal Used by {@link TaskContext.safePoint} and the worker loop. */
|
|
842
|
+
drainPending(taskId) {
|
|
843
|
+
const record = this.taskRegistry.get(taskId);
|
|
844
|
+
if (record === null || record.state === TaskState.Cancelling) {
|
|
845
|
+
return EMPTY_BATCH;
|
|
846
|
+
}
|
|
847
|
+
if (this.#inflightPending.has(taskId)) {
|
|
848
|
+
throw new Error("pending batch is already in flight");
|
|
849
|
+
}
|
|
850
|
+
const events = this.scheduler.safePoint(taskId);
|
|
851
|
+
if (events.length > 0) {
|
|
852
|
+
this.#inflightPending.set(taskId, events);
|
|
853
|
+
this.publishTaskState(taskId, record.state);
|
|
854
|
+
}
|
|
855
|
+
return makeBatch(events);
|
|
856
|
+
}
|
|
857
|
+
setSessionState(state) {
|
|
858
|
+
this.#state = state;
|
|
859
|
+
}
|
|
860
|
+
setIdle(value) {
|
|
861
|
+
this.#idle = value;
|
|
862
|
+
if (value) {
|
|
863
|
+
const waiters = [...this.#idleWaiters];
|
|
864
|
+
this.#idleWaiters.clear();
|
|
865
|
+
for (const resolve of waiters) {
|
|
866
|
+
resolve();
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
//# sourceMappingURL=session.js.map
|