wtagent 0.1.0-alpha.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.
@@ -0,0 +1,636 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ cdata,
4
+ parseAgentResponse,
5
+ serializeProtocolError,
6
+ serializeToolResult,
7
+ } from "../protocol/xml-protocol.js";
8
+ import { appendSystemReminder } from "../protocol/markers.js";
9
+ import {
10
+ buildBootstrapPrompt,
11
+ buildResumePrompt,
12
+ } from "../protocol/prompt-builder.js";
13
+ import {
14
+ assistantMessage,
15
+ functionCall,
16
+ functionCallOutput,
17
+ toolResultOutput,
18
+ userMessage,
19
+ } from "../session/canonical-transcript.js";
20
+ import { DEFAULT_LIMITS } from "../shared/limits.js";
21
+ import {
22
+ PolicyDeniedError,
23
+ ProtocolError,
24
+ ToolValidationError,
25
+ } from "../shared/errors.js";
26
+
27
+ function canonicalize(value) {
28
+ if (Array.isArray(value)) {
29
+ return value.map(canonicalize);
30
+ }
31
+ if (value && typeof value === "object") {
32
+ return Object.fromEntries(
33
+ Object.keys(value)
34
+ .sort()
35
+ .map((key) => [key, canonicalize(value[key])]),
36
+ );
37
+ }
38
+ return value;
39
+ }
40
+
41
+ function canonicalArgs(args) {
42
+ return JSON.stringify(canonicalize(args));
43
+ }
44
+
45
+ function deriveToolIdentity({
46
+ sessionId,
47
+ assistantMessageId,
48
+ turn,
49
+ toolCall,
50
+ turnNumber,
51
+ }) {
52
+ const args = canonicalArgs(toolCall.args);
53
+ const messageIdentity = assistantMessageId
54
+ ? `message:${assistantMessageId}`
55
+ : `turn:${turnNumber}:${createHash("sha256").update(turn.raw).digest("hex")}`;
56
+ const operationKey = createHash("sha256")
57
+ .update(sessionId)
58
+ .update("\0")
59
+ .update(messageIdentity)
60
+ .digest("hex");
61
+ const fingerprint = createHash("sha256")
62
+ .update(operationKey)
63
+ .update("\0")
64
+ .update(toolCall.name)
65
+ .update("\0")
66
+ .update(args)
67
+ .digest("hex");
68
+ const requestSignature = createHash("sha256")
69
+ .update(toolCall.name)
70
+ .update("\0")
71
+ .update(args)
72
+ .digest("hex");
73
+ return {
74
+ operationKey,
75
+ callId: `call_${fingerprint.slice(0, 16)}`,
76
+ name: toolCall.name,
77
+ args,
78
+ fingerprint,
79
+ requestSignature,
80
+ };
81
+ }
82
+
83
+ function unknownCompletionResult(toolCall, message = null) {
84
+ return {
85
+ callId: toolCall.id,
86
+ name: toolCall.name,
87
+ ok: false,
88
+ message: message ?? (
89
+ "This tool call may have started, but its completion is unknown. "
90
+ + "It will not be replayed automatically; inspect local state and use "
91
+ + "a deliberate follow-up operation if needed."
92
+ ),
93
+ meta: {
94
+ completionUnknown: true,
95
+ recoverable: true,
96
+ },
97
+ };
98
+ }
99
+
100
+ function deniedResult(toolCall, reasons) {
101
+ return {
102
+ callId: toolCall.id,
103
+ name: toolCall.name,
104
+ ok: false,
105
+ message: `User denied this tool call: ${reasons.join("; ")}`,
106
+ };
107
+ }
108
+
109
+ export class AgentRuntime {
110
+ constructor({
111
+ adapter,
112
+ registry,
113
+ policy,
114
+ session,
115
+ approval,
116
+ onEvent,
117
+ limits = DEFAULT_LIMITS,
118
+ }) {
119
+ this.adapter = adapter;
120
+ this.registry = registry;
121
+ this.policy = policy;
122
+ this.session = session;
123
+ this.approval = approval;
124
+ this.onEvent = onEvent;
125
+ this.limits = limits;
126
+ }
127
+
128
+ async emit(type, payload = {}) {
129
+ const event = await this.session.appendEvent(type, payload);
130
+ await this.onEvent?.(event);
131
+ return event;
132
+ }
133
+
134
+ async sendMessage(text, { files = [] } = {}) {
135
+ try {
136
+ await this.adapter.sendMessage(appendSystemReminder(text), { files });
137
+ } finally {
138
+ const conversationUrl = await this.adapter.getConversationUrl()
139
+ .catch(() => null);
140
+ if (
141
+ conversationUrl
142
+ && conversationUrl !== this.session.state.conversationUrl
143
+ ) {
144
+ await this.session.update({ conversationUrl });
145
+ }
146
+ }
147
+ }
148
+
149
+ async run({
150
+ resume = false,
151
+ instruction = null,
152
+ files = [],
153
+ } = {}) {
154
+ const {
155
+ task,
156
+ projectRoot,
157
+ mode,
158
+ } = this.session.state;
159
+ const previousConversationUrl = this.session.state.conversationUrl;
160
+ await this.session.recoverInterruptedSideEffects();
161
+ const pendingToolResult = this.session.state.pendingToolResult;
162
+
163
+ await this.session.update({
164
+ phase: "initializing",
165
+ runCount: Number(this.session.state.runCount || 0) + 1,
166
+ lastError: null,
167
+ });
168
+ await this.emit("runtime.initializing");
169
+
170
+ await this.adapter.launch();
171
+ await this.emit("browser.started", {
172
+ profileDir: this.adapter.profileDir,
173
+ });
174
+
175
+ let authState = await this.adapter.getAuthState();
176
+ if (authState !== "authenticated") {
177
+ try {
178
+ await this.adapter.waitForManualLogin({ timeoutMs: 8_000 });
179
+ authState = "authenticated";
180
+ } catch {
181
+ // A signed-in ChatGPT page can briefly render its guest shell. Only
182
+ // prompt the user after a short grace period fails.
183
+ }
184
+ }
185
+ if (authState !== "authenticated") {
186
+ await this.session.update({ phase: "auth_required" });
187
+ await this.emit("browser.auth_required");
188
+ // The window may be minimized; bring it forward so the user can log in,
189
+ // then send it back once login is detected.
190
+ await this.adapter.restoreWindow?.();
191
+ try {
192
+ await this.adapter.waitForManualLogin({
193
+ timeoutMs: this.limits.loginTimeoutMs,
194
+ });
195
+ } finally {
196
+ await this.adapter.minimizeWindow?.();
197
+ }
198
+ await this.emit("browser.authenticated");
199
+ }
200
+
201
+ await this.adapter.startConversation(resume ? previousConversationUrl : null);
202
+ // The actual mode may differ from the requested one (Pro limited, fallback,
203
+ // or switcher not found), so report what was really selected.
204
+ let activeMode = mode;
205
+ if (!resume) {
206
+ const modeResult = await this.adapter.selectMode(mode);
207
+ if (modeResult) {
208
+ await this.emit("conversation.mode_selected", {
209
+ requested: mode,
210
+ status: modeResult.status,
211
+ selectedLabel: modeResult.selectedLabel ?? null,
212
+ attempts: modeResult.attempts ?? 0,
213
+ reason: modeResult.reason ?? null,
214
+ });
215
+ if (modeResult.status === "select" || modeResult.status === "already") {
216
+ activeMode = modeResult.selectedLabel ?? mode;
217
+ } else if (modeResult.status === "fallback") {
218
+ activeMode = modeResult.selectedLabel ?? mode;
219
+ } else {
220
+ // Pro not selected and no known fallback label — the real mode is
221
+ // whatever ChatGPT already had, which we cannot name reliably.
222
+ activeMode = null;
223
+ }
224
+ }
225
+ }
226
+ await this.session.update({
227
+ phase: "running",
228
+ conversationUrl: await this.adapter.getConversationUrl(),
229
+ });
230
+ await this.emit("conversation.started", {
231
+ url: this.session.state.conversationUrl,
232
+ mode: activeMode,
233
+ requestedMode: mode,
234
+ });
235
+
236
+ let initialMessage;
237
+ let initialKind;
238
+ // The web transport gets `.web` (XML/marked text). The portable rollout
239
+ // records only the real user message; WTAgent scaffolding stays transport-only.
240
+ let initialTranscript = [];
241
+ // @file attachments (if any) accompany the first user message of this run.
242
+ const attachments = (files ?? []).map((file) => ({
243
+ name: file.name ?? null,
244
+ path: file.path ?? null,
245
+ }));
246
+ const messageOptions = attachments.length > 0 ? { attachments } : {};
247
+ if (resume && pendingToolResult) {
248
+ initialMessage = serializeToolResult(pendingToolResult);
249
+ if (instruction?.trim()) {
250
+ initialMessage += `\n<resume_instruction>${cdata(instruction)}</resume_instruction>`;
251
+ initialTranscript = [userMessage(instruction, messageOptions)];
252
+ }
253
+ initialKind = "pending_tool_result";
254
+ } else if (resume) {
255
+ const prompt = buildResumePrompt({
256
+ instruction,
257
+ state: this.session.state,
258
+ tools: this.registry.list(),
259
+ });
260
+ initialMessage = prompt.web;
261
+ initialTranscript = [userMessage(prompt.user, messageOptions)];
262
+ initialKind = "resume";
263
+ } else {
264
+ const prompt = buildBootstrapPrompt({
265
+ task,
266
+ projectRoot,
267
+ tools: this.registry.list(),
268
+ });
269
+ initialMessage = prompt.web;
270
+ initialTranscript = [userMessage(prompt.user, messageOptions)];
271
+ initialKind = "bootstrap";
272
+ }
273
+
274
+ for (const item of initialTranscript) {
275
+ await this.session.appendTranscriptItem(item);
276
+ }
277
+ await this.sendMessage(initialMessage, { files });
278
+ let awaitingPendingAcknowledgement = Boolean(pendingToolResult);
279
+ let replayGuard = pendingToolResult?.operationSignature
280
+ ? {
281
+ signature: pendingToolResult.operationSignature,
282
+ result: pendingToolResult,
283
+ }
284
+ : null;
285
+ await this.emit("model.message_sent", { kind: initialKind });
286
+
287
+ let protocolErrors = 0;
288
+ const baseTurn = resume ? Number(this.session.state.turn || 0) : 0;
289
+
290
+ for (let step = 1; step <= this.limits.maxTurns; step += 1) {
291
+ const turnNumber = baseTurn + step;
292
+ await this.session.update({ turn: turnNumber, phase: "waiting_model" });
293
+ const raw = await this.adapter.waitForTurnComplete({
294
+ timeoutMs: this.limits.modelTurnTimeoutMs,
295
+ stableWindowMs: this.limits.modelStableWindowMs,
296
+ onDelta: async (delta) => {
297
+ await this.onEvent?.({
298
+ type: "model.streaming",
299
+ sessionId: this.session.sessionId,
300
+ timestamp: new Date().toISOString(),
301
+ payload: { delta },
302
+ });
303
+ },
304
+ });
305
+ if (awaitingPendingAcknowledgement) {
306
+ await this.session.clearPendingToolResult();
307
+ awaitingPendingAcknowledgement = false;
308
+ }
309
+ await this.session.update({
310
+ conversationUrl: await this.adapter.getConversationUrl(),
311
+ });
312
+ await this.emit("model.message_complete", {
313
+ turn: turnNumber,
314
+ raw,
315
+ });
316
+
317
+ let parsed;
318
+ try {
319
+ parsed = parseAgentResponse(raw);
320
+ protocolErrors = 0;
321
+ } catch (error) {
322
+ if (!(error instanceof ProtocolError)) {
323
+ throw error;
324
+ }
325
+ protocolErrors += 1;
326
+ await this.emit("protocol.invalid", {
327
+ message: error.message,
328
+ count: protocolErrors,
329
+ });
330
+ if (protocolErrors >= this.limits.maxProtocolErrors) {
331
+ throw new ProtocolError(
332
+ `Protocol failed ${protocolErrors} consecutive times: ${error.message}`,
333
+ );
334
+ }
335
+ await this.sendMessage(serializeProtocolError(error));
336
+ continue;
337
+ }
338
+
339
+ // Record the assistant's turn in the canonical transcript. The raw XML is
340
+ // the web rendering; the transcript keeps the plain progress message.
341
+ if (parsed.message?.trim()) {
342
+ await this.session.appendTranscriptItem(
343
+ assistantMessage(parsed.message),
344
+ );
345
+ }
346
+
347
+ if (parsed.done) {
348
+ if (!parsed.message.trim()) {
349
+ const error = new ProtocolError(
350
+ "done=true requires a non-empty final message.",
351
+ );
352
+ await this.emit("protocol.invalid", { message: error.message });
353
+ await this.sendMessage(serializeProtocolError(error));
354
+ continue;
355
+ }
356
+ // done=true completes the run. A request may be answered directly
357
+ // (no tool call) or after any number of tools; the runtime does not
358
+ // second-guess whether "enough" work happened — that is the model's
359
+ // and the user's call, not a keyword heuristic.
360
+ await this.session.update({
361
+ phase: "idle",
362
+ lastMessage: parsed.message,
363
+ pendingToolResult: null,
364
+ });
365
+ await this.emit("run.completed", { message: parsed.message });
366
+ return {
367
+ sessionId: this.session.sessionId,
368
+ message: parsed.message,
369
+ };
370
+ }
371
+
372
+ if (parsed.message) {
373
+ await this.emit("model.progress", {
374
+ turn: turnNumber,
375
+ message: parsed.message,
376
+ });
377
+ }
378
+
379
+ if (!parsed.toolCall) {
380
+ // done=false without a tool call means the model is just talking
381
+ // (e.g. asking a clarifying question, explaining its reasoning, or
382
+ // giving a partial answer). The message was already emitted as
383
+ // model.progress above; nudge the model to either finish with
384
+ // done=true or invoke a local tool to make progress.
385
+ await this.sendMessage(
386
+ "If the current request is deliverable, reply with <done>true</done> and the result. "
387
+ + "If you need to take action on the user's machine, request one local tool. "
388
+ + "If you need information from the user, ask one specific question in <message> "
389
+ + "and set <done>true</done> so control returns to the user.",
390
+ );
391
+ continue;
392
+ }
393
+
394
+ const assistantMessageId = await this.adapter
395
+ .getLastAssistantMessageId?.() ?? null;
396
+ const identity = deriveToolIdentity({
397
+ sessionId: this.session.sessionId,
398
+ assistantMessageId,
399
+ turn: parsed,
400
+ toolCall: parsed.toolCall,
401
+ turnNumber,
402
+ });
403
+ const normalizedCall = {
404
+ ...parsed.toolCall,
405
+ id: identity.callId,
406
+ };
407
+ let preparedCall;
408
+ try {
409
+ preparedCall = this.registry.validate(normalizedCall);
410
+ } catch (error) {
411
+ if (!(error instanceof ToolValidationError)) {
412
+ throw error;
413
+ }
414
+
415
+ const result = {
416
+ callId: normalizedCall.id,
417
+ name: normalizedCall.name,
418
+ ok: false,
419
+ message: error.message,
420
+ };
421
+ const fingerprint = identity.fingerprint;
422
+ await this.emit("tool.invalid", {
423
+ id: normalizedCall.id,
424
+ name: normalizedCall.name,
425
+ message: error.message,
426
+ });
427
+ const completionEvent = await this.session.recordToolResult(
428
+ fingerprint,
429
+ result,
430
+ );
431
+ await this.onEvent?.(completionEvent);
432
+ // Record the rejected call and its error output so the transcript stays
433
+ // a faithful, replay-free record of what the model attempted.
434
+ await this.session.appendTranscriptItem(functionCall({
435
+ name: normalizedCall.name,
436
+ args: normalizedCall.args,
437
+ callId: normalizedCall.id,
438
+ }));
439
+ await this.session.appendTranscriptItem(functionCallOutput({
440
+ callId: result.callId,
441
+ output: toolResultOutput(result),
442
+ }));
443
+ await this.sendMessage(serializeToolResult(result));
444
+ awaitingPendingAcknowledgement = true;
445
+ await this.emit("tool.result_sent", {
446
+ id: result.callId,
447
+ name: result.name,
448
+ ok: false,
449
+ });
450
+ continue;
451
+ }
452
+ await this.emit("tool.proposed", {
453
+ id: preparedCall.id,
454
+ name: preparedCall.name,
455
+ args: preparedCall.args,
456
+ });
457
+ await this.session.appendTranscriptItem(functionCall({
458
+ name: preparedCall.name,
459
+ args: preparedCall.args,
460
+ callId: preparedCall.id,
461
+ }));
462
+
463
+ const isReadTool = preparedCall.definition.risk === "read";
464
+ const sideEffect = isReadTool ? null : identity;
465
+ const fingerprint = identity.fingerprint;
466
+ let result;
467
+
468
+ if (
469
+ sideEffect
470
+ && replayGuard?.signature === identity.requestSignature
471
+ ) {
472
+ result = {
473
+ ...replayGuard.result,
474
+ callId: preparedCall.id,
475
+ };
476
+ await this.emit("tool.reused", {
477
+ fingerprint,
478
+ id: preparedCall.id,
479
+ name: preparedCall.name,
480
+ reason: "repeated-after-result",
481
+ });
482
+ await this.session.setPendingToolResult(result);
483
+ }
484
+ // The replay guard is only for the first tool proposal after resending a
485
+ // persisted result during recovery. Later identical proposals are new
486
+ // model turns and may be deliberate operations.
487
+ replayGuard = null;
488
+
489
+ if (!result && sideEffect) {
490
+ const existing = this.session.getSideEffectTool(
491
+ sideEffect.operationKey,
492
+ );
493
+ if (existing && existing.fingerprint !== identity.fingerprint) {
494
+ result = {
495
+ callId: preparedCall.id,
496
+ name: preparedCall.name,
497
+ ok: false,
498
+ message:
499
+ "The same assistant message changed its tool request after it "
500
+ + `was already recorded for ${existing.name}. The operation was not replayed.`,
501
+ };
502
+ await this.emit("tool.conflict", {
503
+ id: preparedCall.id,
504
+ name: preparedCall.name,
505
+ existingName: existing.name,
506
+ });
507
+ await this.session.setPendingToolResult(result);
508
+ } else if (existing?.status === "completed") {
509
+ result = existing.result;
510
+ await this.emit("tool.reused", {
511
+ fingerprint,
512
+ id: preparedCall.id,
513
+ name: preparedCall.name,
514
+ });
515
+ await this.session.setPendingToolResult(result);
516
+ } else if (existing) {
517
+ result = existing.result ?? unknownCompletionResult(preparedCall);
518
+ if (existing.status !== "unknown") {
519
+ const unknownEvent = await this.session.markSideEffectToolUnknown(
520
+ identity,
521
+ result,
522
+ );
523
+ await this.onEvent?.(unknownEvent);
524
+ } else {
525
+ await this.session.setPendingToolResult(result);
526
+ }
527
+ await this.emit("tool.reused_unknown", {
528
+ fingerprint,
529
+ id: preparedCall.id,
530
+ name: preparedCall.name,
531
+ });
532
+ }
533
+ } else if (!result) {
534
+ result = this.session.getToolResult(fingerprint);
535
+ }
536
+
537
+ if (!result) {
538
+ const decision = await this.policy.evaluate(preparedCall, {
539
+ projectRoot,
540
+ });
541
+ const grants = decision.grants;
542
+
543
+ if (decision.action === "confirm") {
544
+ await this.emit("approval.required", {
545
+ id: preparedCall.id,
546
+ name: preparedCall.name,
547
+ args: preparedCall.args,
548
+ reasons: decision.reasons,
549
+ });
550
+ const approved = await this.approval({
551
+ toolCall: preparedCall,
552
+ reasons: decision.reasons,
553
+ });
554
+ if (!approved) {
555
+ result = deniedResult(preparedCall, decision.reasons);
556
+ }
557
+ } else if (decision.action === "deny") {
558
+ throw new PolicyDeniedError(decision.reasons.join("; "));
559
+ }
560
+
561
+ if (sideEffect) {
562
+ const claimEvent = await this.session.claimSideEffectTool(sideEffect);
563
+ await this.onEvent?.(claimEvent);
564
+ }
565
+
566
+ if (!result) {
567
+ await this.emit("tool.started", {
568
+ id: preparedCall.id,
569
+ name: preparedCall.name,
570
+ });
571
+ result = await this.registry.execute(preparedCall, {
572
+ projectRoot,
573
+ allowOutside: grants?.allowOutside ?? false,
574
+ toolTimeoutMs: this.limits.toolTimeoutMs,
575
+ onToolOutput: async (output) => {
576
+ await this.session.appendToolOutput({
577
+ id: preparedCall.id,
578
+ name: preparedCall.name,
579
+ ...output,
580
+ });
581
+ await this.onEvent?.({
582
+ type: "tool.output",
583
+ sessionId: this.session.sessionId,
584
+ timestamp: new Date().toISOString(),
585
+ payload: {
586
+ id: preparedCall.id,
587
+ name: preparedCall.name,
588
+ ...output,
589
+ },
590
+ });
591
+ },
592
+ });
593
+ }
594
+
595
+ if (sideEffect) {
596
+ result.operationSignature = identity.requestSignature;
597
+ }
598
+
599
+ if (sideEffect && result.meta?.completionUnknown) {
600
+ const unknownEvent = await this.session.markSideEffectToolUnknown(
601
+ sideEffect,
602
+ result,
603
+ );
604
+ await this.onEvent?.(unknownEvent);
605
+ } else {
606
+ const completionEvent = await this.session.recordToolResult(
607
+ fingerprint,
608
+ result,
609
+ { identity: sideEffect },
610
+ );
611
+ await this.onEvent?.(completionEvent);
612
+ }
613
+ }
614
+
615
+ await this.session.appendTranscriptItem(functionCallOutput({
616
+ callId: result.callId,
617
+ output: toolResultOutput(result),
618
+ }));
619
+ await this.sendMessage(serializeToolResult(result));
620
+ awaitingPendingAcknowledgement = true;
621
+ await this.emit("tool.result_sent", {
622
+ id: result.callId,
623
+ name: result.name,
624
+ ok: result.ok,
625
+ });
626
+ }
627
+
628
+ const message = `Maximum turn limit reached (${this.limits.maxTurns}).`;
629
+ await this.session.update({
630
+ phase: "interrupted",
631
+ lastError: message,
632
+ });
633
+ await this.emit("run.interrupted", { message });
634
+ throw new Error(message);
635
+ }
636
+ }