wtagent 0.4.0 → 1.0.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.
Files changed (34) hide show
  1. package/README.md +161 -7
  2. package/package.json +5 -4
  3. package/src/artifacts/artifact-store.js +154 -0
  4. package/src/audio/music-artifact.js +66 -0
  5. package/src/audio/native-music-receiver.js +91 -0
  6. package/src/browser/base-web-adapter.js +354 -50
  7. package/src/browser/chatgpt-dom.js +179 -0
  8. package/src/browser/chatgpt-web-adapter.js +89 -125
  9. package/src/browser/fake-web-model-adapter.js +16 -1
  10. package/src/browser/gemini-web-adapter.js +61 -20
  11. package/src/browser/grok-web-adapter.js +35 -3
  12. package/src/browser/rendered-text.js +11 -0
  13. package/src/cli/i18n.js +40 -10
  14. package/src/cli/main.js +32 -3
  15. package/src/cli/prompt-input.js +68 -0
  16. package/src/image/browser-image-driver.js +238 -0
  17. package/src/image/browser-image-session-pool.js +96 -0
  18. package/src/image/image-generation-service.js +101 -0
  19. package/src/image/image-provider-driver.js +22 -0
  20. package/src/image/native-image-receiver.js +66 -0
  21. package/src/image/provider-registry.js +23 -0
  22. package/src/image/providers/chatgpt-image-driver.js +211 -0
  23. package/src/image/providers/gemini-image-driver.js +100 -0
  24. package/src/image/providers/grok-image-driver.js +193 -0
  25. package/src/policy/policy-engine.js +10 -5
  26. package/src/protocol/envelope.js +74 -0
  27. package/src/protocol/markers.js +16 -0
  28. package/src/protocol/prompt-builder.js +12 -9
  29. package/src/protocol/xml-protocol.js +36 -18
  30. package/src/runtime/agent-runtime.js +345 -65
  31. package/src/session/agent-session.js +73 -2
  32. package/src/tools/image-tools.js +92 -0
  33. package/src/tools/registry.js +13 -1
  34. package/docs/technical-design.md +0 -873
@@ -5,11 +5,11 @@ import {
5
5
  parseAgentResponse,
6
6
  serializeProtocolError,
7
7
  serializeToolResult,
8
- stripUiNoiseLines,
9
8
  } from "../protocol/xml-protocol.js";
10
9
  import {
11
10
  appendSystemReminder,
12
11
  DEFAULT_SYSTEM_REMINDER,
12
+ NATIVE_IMAGE_SYSTEM_REMINDER,
13
13
  } from "../protocol/markers.js";
14
14
  import {
15
15
  buildBootstrapPrompt,
@@ -24,6 +24,7 @@ import {
24
24
  userMessage,
25
25
  } from "../session/canonical-transcript.js";
26
26
  import { DEFAULT_LIMITS } from "../shared/limits.js";
27
+ import { MUSIC_SYSTEM_REMINDER } from "../audio/native-music-receiver.js";
27
28
  import { utf8ByteLength } from "../shared/text-budget.js";
28
29
  import {
29
30
  BrowserAdapterError,
@@ -157,6 +158,8 @@ export class AgentRuntime {
157
158
  approval,
158
159
  onEvent,
159
160
  postAuthSetup = null,
161
+ nativeImageReceiver = null,
162
+ nativeMusicReceiver = null,
160
163
  limits = DEFAULT_LIMITS,
161
164
  }) {
162
165
  this.adapter = adapter;
@@ -166,6 +169,8 @@ export class AgentRuntime {
166
169
  this.approval = approval;
167
170
  this.onEvent = onEvent;
168
171
  this.postAuthSetup = postAuthSetup;
172
+ this.nativeImageReceiver = nativeImageReceiver;
173
+ this.nativeMusicReceiver = nativeMusicReceiver;
169
174
  this.limits = limits;
170
175
  this.conversationIdentityQueue = Promise.resolve();
171
176
  this.deferConversationIdentityPersistence = false;
@@ -180,20 +185,40 @@ export class AgentRuntime {
180
185
  return event;
181
186
  }
182
187
 
188
+ #assertProviderReadyForMessage() {
189
+ if (this.session.state.pendingAuxiliaryTurn != null) {
190
+ throw new BrowserAdapterError(
191
+ "The provider operation has no verified assistant boundary. No further message was sent.",
192
+ { code: "AUXILIARY_TURN_RECOVERY_REQUIRED", recoverable: false },
193
+ );
194
+ }
195
+ if (this.session.state.pendingOutbound != null) {
196
+ throw new BrowserAdapterError(
197
+ "A previous provider message has not been reconciled. No further message was sent.",
198
+ { code: "OUTBOUND_COMMIT_UNCERTAIN", recoverable: false },
199
+ );
200
+ }
201
+ }
202
+
183
203
  async sendMessage(text, {
184
204
  files = [],
185
205
  maxBytes = null,
186
206
  allowFreshReconnect = false,
187
207
  allowAssistantContinuation = false,
208
+ requireAttachments = false,
188
209
  outboundKind = "runtime_message",
189
210
  transcriptItems = [],
190
211
  runtimeTurn = null,
191
212
  pendingToolAcknowledgement = null,
192
213
  } = {}) {
214
+ this.#assertProviderReadyForMessage();
215
+ await this.nativeMusicReceiver?.prepare();
193
216
  const outboundId = randomUUID();
217
+ const reminder = this.nativeMusicReceiver ? MUSIC_SYSTEM_REMINDER
218
+ : this.nativeImageReceiver ? NATIVE_IMAGE_SYSTEM_REMINDER : DEFAULT_SYSTEM_REMINDER;
194
219
  const message = appendSystemReminder(
195
220
  text,
196
- `${DEFAULT_SYSTEM_REMINDER}${OUTBOUND_CORRELATION_PREFIX}${outboundId}.`,
221
+ `${reminder}${OUTBOUND_CORRELATION_PREFIX}${outboundId}.`,
197
222
  );
198
223
  const pendingOutbound = {
199
224
  kind: outboundKind,
@@ -213,6 +238,7 @@ export class AgentRuntime {
213
238
  maxBytes,
214
239
  allowFreshReconnect,
215
240
  allowAssistantContinuation,
241
+ requireAttachments,
216
242
  outboundId,
217
243
  });
218
244
  const handoff = await this.#queueSessionOperation(() => (
@@ -414,6 +440,7 @@ export class AgentRuntime {
414
440
  maxBytes,
415
441
  allowFreshReconnect,
416
442
  allowAssistantContinuation,
443
+ requireAttachments,
417
444
  outboundId,
418
445
  }) {
419
446
  try {
@@ -422,6 +449,7 @@ export class AgentRuntime {
422
449
  maxBytes,
423
450
  outboundId,
424
451
  allowAssistantContinuation,
452
+ requireAttachments,
425
453
  });
426
454
  } catch (error) {
427
455
  if (isConnectionLostError(error)) {
@@ -447,11 +475,13 @@ export class AgentRuntime {
447
475
  )
448
476
  ),
449
477
  });
478
+ await this.nativeMusicReceiver?.prepare();
450
479
  return await this.adapter.sendMessage(message, {
451
480
  files,
452
481
  maxBytes,
453
482
  outboundId,
454
483
  allowAssistantContinuation,
484
+ requireAttachments,
455
485
  });
456
486
  }
457
487
  throw error;
@@ -515,6 +545,9 @@ export class AgentRuntime {
515
545
  if (state.pendingAssistantTurn != null) {
516
546
  blockers.push("pending assistant handoff");
517
547
  }
548
+ if (state.pendingAuxiliaryTurn != null) {
549
+ blockers.push("pending auxiliary provider turn");
550
+ }
518
551
  if (state.pendingToolResult != null) {
519
552
  blockers.push("pending tool result");
520
553
  }
@@ -581,7 +614,8 @@ export class AgentRuntime {
581
614
 
582
615
  buildToolResultMessage(result, { suffix = "" } = {}) {
583
616
  const limit = this.limits.maxBrowserToolResultBytes;
584
- const nonResultBytes = utf8ByteLength(appendSystemReminder(suffix))
617
+ const nonResultBytes = utf8ByteLength(appendSystemReminder(suffix,
618
+ this.nativeImageReceiver ? NATIVE_IMAGE_SYSTEM_REMINDER : DEFAULT_SYSTEM_REMINDER))
585
619
  + OUTBOUND_CORRELATION_RESERVE_BYTES;
586
620
  const resultBudget = limit - nonResultBytes;
587
621
  if (resultBudget < 512) {
@@ -611,6 +645,144 @@ export class AgentRuntime {
611
645
  );
612
646
  }
613
647
 
648
+ // Runs a provider-native helper turn (currently used by image.generate) in
649
+ // the same web conversation without exposing that turn as another WTAgent
650
+ // protocol exchange. The send still goes through the adapter's full causal
651
+ // checks, and both provider message identities are checkpointed before the
652
+ // caller is allowed to send the tool result.
653
+ async runAuxiliaryTurn({
654
+ text,
655
+ files = [],
656
+ timeoutMs = this.limits.modelTurnTimeoutMs,
657
+ waitForCompletion,
658
+ }) {
659
+ if (typeof waitForCompletion !== "function") {
660
+ throw new TypeError("An auxiliary turn requires a completion callback.");
661
+ }
662
+ this.#assertProviderReadyForMessage();
663
+ const outboundId = randomUUID();
664
+ const message = appendSystemReminder(
665
+ text,
666
+ `This is an internal WTAgent provider operation. Perform only the requested operation; do not emit the WTAgent XML protocol.${OUTBOUND_CORRELATION_PREFIX}${outboundId}.`,
667
+ );
668
+ const pendingOutbound = {
669
+ kind: "auxiliary_tool_turn",
670
+ outboundId,
671
+ messageHash: createHash("sha256").update(message).digest("hex"),
672
+ preparedAt: new Date().toISOString(),
673
+ transcriptItems: [],
674
+ runtimeTurn: null,
675
+ pendingToolAcknowledgement: null,
676
+ conversationUrl: this.session.state.conversationUrl ?? null,
677
+ conversationTargetId: this.session.state.conversationTargetId ?? null,
678
+ };
679
+ await this.#queueSessionUpdate({ pendingOutbound });
680
+
681
+ let sendResult;
682
+ try {
683
+ sendResult = await this.#sendMessageWithReconnect(message, {
684
+ files,
685
+ maxBytes: null,
686
+ allowFreshReconnect: false,
687
+ allowAssistantContinuation: false,
688
+ requireAttachments: files.length > 0,
689
+ outboundId,
690
+ });
691
+ await this.#queueSessionOperation(() => (
692
+ this.session.commitPendingAuxiliaryTurn({
693
+ outboundId,
694
+ turn: {
695
+ conversationUrl: sendResult?.conversationUrl
696
+ ?? this.session.state.conversationUrl,
697
+ conversationTargetId: sendResult?.conversationTargetId
698
+ ?? this.session.state.conversationTargetId,
699
+ userMessageId: sendResult?.userMessageId ?? null,
700
+ userTurn: sendResult?.userTurn ?? null,
701
+ assistantBaseline: sendResult?.assistantBaseline ?? null,
702
+ },
703
+ })
704
+ ));
705
+ } catch (error) {
706
+ const sendStatus = this.adapter.getLastSendStatus?.() ?? "commit-unknown";
707
+ await this.#queueSessionOperation(async () => {
708
+ if (this.session.state.pendingOutbound?.outboundId === outboundId) {
709
+ await this.session.update({
710
+ pendingOutbound: sendStatus === "not-submitted"
711
+ ? null
712
+ : {
713
+ ...pendingOutbound,
714
+ status: "commit-unknown",
715
+ failedAt: new Date().toISOString(),
716
+ },
717
+ });
718
+ }
719
+ }).catch(() => {});
720
+ if (sendStatus !== "not-submitted") {
721
+ error.completionUnknown = true;
722
+ }
723
+ throw error;
724
+ } finally {
725
+ await this.#syncConversationIdentity({ suppressErrors: true });
726
+ }
727
+
728
+ let value;
729
+ try {
730
+ value = await waitForCompletion({
731
+ adapter: this.adapter,
732
+ page: this.adapter.page,
733
+ sendResult,
734
+ timeoutMs,
735
+ });
736
+ const completion = await this.adapter.captureAuxiliaryTurnCompletion?.({
737
+ timeoutMs: Math.min(timeoutMs, 30_000),
738
+ })
739
+ ?? null;
740
+ const assistantMessageId = completion?.assistantMessageId
741
+ ?? await this.adapter.getLastAssistantMessageId?.()
742
+ ?? null;
743
+ const assistantTurn = completion?.assistantTurn
744
+ ?? await this.adapter.getLastAssistantTurn?.()
745
+ ?? null;
746
+ await this.#queueSessionOperation(() => (
747
+ this.session.completePendingAuxiliaryTurn({
748
+ outboundId,
749
+ assistantMessageId,
750
+ assistantTurn,
751
+ })
752
+ ));
753
+ return value;
754
+ } catch (error) {
755
+ // A provider can finish the visible assistant turn yet fail while WTAgent
756
+ // downloads or validates its artifact. Close that browser-turn checkpoint
757
+ // when its final boundary is still provable; otherwise retain it so a
758
+ // later resume cannot blindly submit a duplicate image request.
759
+ try {
760
+ const completion = await this.adapter.captureAuxiliaryTurnCompletion?.({
761
+ timeoutMs: Math.min(timeoutMs, 5_000),
762
+ });
763
+ if (completion) {
764
+ await this.#queueSessionOperation(() => (
765
+ this.session.completePendingAuxiliaryTurn({
766
+ outboundId,
767
+ assistantMessageId: completion.assistantMessageId ?? null,
768
+ assistantTurn: completion.assistantTurn ?? null,
769
+ })
770
+ ));
771
+ }
772
+ } catch {
773
+ // Keep pendingAuxiliaryTurn intact when completion cannot be proven.
774
+ }
775
+ // If the completion callback produced a temporary artifact but the
776
+ // assistant-boundary checkpoint failed, ownership never reaches the
777
+ // ImageGenerationService, so clean it here.
778
+ await value?.cleanup?.().catch(() => null);
779
+ error.completionUnknown = true;
780
+ throw error;
781
+ } finally {
782
+ await this.#syncConversationIdentity({ suppressErrors: true });
783
+ }
784
+ }
785
+
614
786
  async #appendHandoffTranscript(activeHandoff, suffix, item) {
615
787
  if (!activeHandoff?.handoffId) {
616
788
  throw new Error("Cannot append transcript without an assistant handoff ID.");
@@ -789,7 +961,9 @@ export class AgentRuntime {
789
961
  ));
790
962
  }
791
963
 
792
- if (outcome.status === "complete") {
964
+ // Recovery reconciles text/identity. A not-yet-checkpointed native turn
965
+ // still needs the ordinary image-aware completion reader before sealing it.
966
+ if (outcome.status === "complete" && ((!this.nativeImageReceiver && !this.nativeMusicReceiver) || handoff.status === "complete")) {
793
967
  const outcomeHash = createHash("sha256")
794
968
  .update(outcome.rawResponse)
795
969
  .digest("hex");
@@ -805,6 +979,8 @@ export class AgentRuntime {
805
979
  assistantMessageId: outcome.assistantMessageId ?? null,
806
980
  assistantTurn: outcome.assistantTurn ?? null,
807
981
  rawResponse: outcome.rawResponse,
982
+ nativeImages: handoff.nativeImages ?? [],
983
+ nativeMusic: handoff.nativeMusic ?? [],
808
984
  })
809
985
  ));
810
986
  }
@@ -824,8 +1000,23 @@ export class AgentRuntime {
824
1000
  const previousConversationUrl = this.session.state.conversationUrl;
825
1001
  const pendingOutboundAtStart = this.session.state.pendingOutbound;
826
1002
  const pendingAssistantAtStart = this.session.state.pendingAssistantTurn;
1003
+ const pendingAuxiliaryAtStart = this.session.state.pendingAuxiliaryTurn;
1004
+ if (pendingAuxiliaryAtStart != null) {
1005
+ throw new BrowserAdapterError(
1006
+ "An image/provider auxiliary turn was interrupted after submission. "
1007
+ + "WTAgent will not replay it automatically because that could create a duplicate remote artifact.",
1008
+ { code: "AUXILIARY_TURN_RECOVERY_REQUIRED", recoverable: false },
1009
+ );
1010
+ }
827
1011
  const hasPendingRecovery = pendingOutboundAtStart != null
828
1012
  || pendingAssistantAtStart != null;
1013
+ if (this.nativeMusicReceiver && resume && !hasPendingRecovery
1014
+ && !instruction?.trim() && files.length === 0 && this.session.state.lastMusicResult) {
1015
+ // Bare resume of a completed generation must not spend quota again.
1016
+ const result = this.session.state.lastMusicResult;
1017
+ await this.emit("run.completed", result);
1018
+ return { sessionId: this.session.sessionId, ...result };
1019
+ }
829
1020
  const supportsStrictRecovery = Boolean(
830
1021
  this.adapter.supportsPendingOutboundRecovery?.(),
831
1022
  );
@@ -1091,7 +1282,13 @@ export class AgentRuntime {
1091
1282
  // records only confirmed user messages; WTAgent scaffolding stays transport-only.
1092
1283
  let initialTranscript = [];
1093
1284
  if (!legacyAssistantRecoveryAtStart) {
1094
- if (resumedExistingConversation && pendingToolResult && !inPlaceRecovery) {
1285
+ if (this.nativeMusicReceiver) {
1286
+ if (inPlaceRecovery) throw new Error("Music generation is not automatically repeated; resume the pending turn without a new instruction.");
1287
+ const request = effectiveInstruction?.trim() || task;
1288
+ initialMessage = `Create the following music using the native Music tool.\n\n${request}`;
1289
+ initialTranscript = resume && effectiveInstruction?.trim() ? [userMessage(effectiveInstruction.trim(), messageOptions)] : [];
1290
+ initialKind = resume ? "music_follow_up" : "music_generation";
1291
+ } else if (resumedExistingConversation && pendingToolResult && !inPlaceRecovery) {
1095
1292
  let suffix = "";
1096
1293
  if (instruction?.trim()) {
1097
1294
  suffix = `\n<resume_instruction>${cdata(instruction)}</resume_instruction>`;
@@ -1120,6 +1317,7 @@ export class AgentRuntime {
1120
1317
  : instruction,
1121
1318
  state: this.session.state,
1122
1319
  tools: this.registry.list(),
1320
+ nativeImages: Boolean(this.nativeImageReceiver),
1123
1321
  });
1124
1322
  initialMessage = prompt.web;
1125
1323
  // The original task is already the one safe canonical item required by the
@@ -1135,6 +1333,7 @@ export class AgentRuntime {
1135
1333
  task,
1136
1334
  projectRoot,
1137
1335
  tools: this.registry.list(),
1336
+ nativeImages: Boolean(this.nativeImageReceiver),
1138
1337
  });
1139
1338
  initialMessage = prompt.web;
1140
1339
  // The opening item was persisted before browser setup above.
@@ -1188,12 +1387,16 @@ export class AgentRuntime {
1188
1387
  : baseTurn + step;
1189
1388
  await this.session.update({ turn: turnNumber, phase: "waiting_model" });
1190
1389
  let raw;
1390
+ let nativeImages = [];
1391
+ let nativeMusic = [];
1191
1392
  let emptyAssistantRetries = 0;
1192
1393
  let connectionRetries = 0;
1193
1394
  for (;;) {
1194
1395
  try {
1195
1396
  if (activeHandoff.status === "complete") {
1196
1397
  raw = activeHandoff.rawResponse;
1398
+ nativeImages = activeHandoff.nativeImages ?? [];
1399
+ nativeMusic = activeHandoff.nativeMusic ?? [];
1197
1400
  } else {
1198
1401
  try {
1199
1402
  raw = await this.adapter.waitForTurnComplete({
@@ -1201,6 +1404,10 @@ export class AgentRuntime {
1201
1404
  stableWindowMs: this.limits.modelStableWindowMs,
1202
1405
  emptyResponseWindowMs: this.limits.emptyAssistantWindowMs,
1203
1406
  deadRequestGraceMs: this.limits.deadRequestGraceMs,
1407
+ readNativeImages: this.nativeImageReceiver
1408
+ ? (message) => this.nativeImageReceiver.read(message) : null,
1409
+ readNativeMusic: this.nativeMusicReceiver
1410
+ ? (message) => this.nativeMusicReceiver.read(message) : null,
1204
1411
  onDelta: async (delta) => {
1205
1412
  await this.onEvent?.({
1206
1413
  type: "model.streaming",
@@ -1210,6 +1417,13 @@ export class AgentRuntime {
1210
1417
  });
1211
1418
  },
1212
1419
  });
1420
+ if (raw && typeof raw === "object" && Array.isArray(raw.nativeImages)) {
1421
+ nativeImages = raw.nativeImages;
1422
+ raw = raw.text ?? "";
1423
+ } else if (raw && typeof raw === "object" && Array.isArray(raw.nativeMusic)) {
1424
+ nativeMusic = raw.nativeMusic;
1425
+ raw = raw.text ?? "";
1426
+ }
1213
1427
  } finally {
1214
1428
  // Navigation events normally persist canonicalization immediately;
1215
1429
  // this validated fallback runs on every outcome but never masks a
@@ -1259,6 +1473,7 @@ export class AgentRuntime {
1259
1473
 
1260
1474
  const deadRequest = error?.code === "DEAD_ASSISTANT_REQUEST";
1261
1475
  const generationFailed = error?.code === "GENERATION_FAILED";
1476
+ if (this.nativeMusicReceiver) throw error;
1262
1477
  if (
1263
1478
  error?.code !== "EMPTY_ASSISTANT_RESPONSE"
1264
1479
  && !deadRequest
@@ -1351,6 +1566,8 @@ export class AgentRuntime {
1351
1566
  assistantMessageId,
1352
1567
  assistantTurn,
1353
1568
  rawResponse: raw,
1569
+ nativeImages,
1570
+ nativeMusic,
1354
1571
  })
1355
1572
  ));
1356
1573
  } else {
@@ -1372,6 +1589,66 @@ export class AgentRuntime {
1372
1589
  assistantMessageId,
1373
1590
  });
1374
1591
 
1592
+ if (this.nativeMusicReceiver) {
1593
+ let artifacts = activeHandoff.nativeArtifacts;
1594
+ if (!artifacts) {
1595
+ artifacts = nativeMusic.length ? await this.nativeMusicReceiver.save(nativeMusic, {
1596
+ projectRoot, handoffId: activeHandoff.handoffId, assistantMessageId,
1597
+ }) : [];
1598
+ activeHandoff = await this.#queueSessionOperation(() => (
1599
+ this.session.refreshPendingAssistantTurn(activeHandoff.handoffId, { nativeArtifacts: artifacts })
1600
+ ));
1601
+ }
1602
+ const message = [
1603
+ raw,
1604
+ ...artifacts.map((artifact) => `Saved music: ${artifact.localPath} (${artifact.durationSeconds.toFixed(2)} s)`),
1605
+ ...artifacts.map((artifact) => artifact.audioExtractionNote).filter(Boolean),
1606
+ ...(artifacts.length ? [] : ["No music file was generated or saved."]),
1607
+ ].filter(Boolean).join("\n");
1608
+ await this.#appendHandoffTranscript(activeHandoff, "assistant", assistantMessage(message));
1609
+ const result = { message, artifacts };
1610
+ await this.#queueSessionOperation(() => this.session.clearPendingAssistantTurn(activeHandoff.handoffId, {
1611
+ phase: "idle", lastMessage: message, pendingToolResult: null, lastMusicResult: result,
1612
+ }));
1613
+ if (artifacts.length) await this.emit("model.native_music", { turn: turnNumber, artifacts });
1614
+ await this.emit("run.completed", result);
1615
+ return { sessionId: this.session.sessionId, ...result };
1616
+ }
1617
+
1618
+ if (nativeImages.length > 0) {
1619
+ if (!this.nativeImageReceiver) {
1620
+ throw new Error("This session has pending native images but no image receiver.");
1621
+ }
1622
+ let artifacts = activeHandoff.nativeArtifacts;
1623
+ if (!artifacts) {
1624
+ artifacts = await this.nativeImageReceiver.save(nativeImages, {
1625
+ projectRoot,
1626
+ handoffId: activeHandoff.handoffId,
1627
+ assistantMessageId,
1628
+ });
1629
+ activeHandoff = await this.#queueSessionOperation(() => (
1630
+ this.session.refreshPendingAssistantTurn(activeHandoff.handoffId, { nativeArtifacts: artifacts })
1631
+ ));
1632
+ }
1633
+ protocolErrors = 0;
1634
+ const message = [raw, ...artifacts.map((artifact) => `Saved image: ${artifact.localPath}`)]
1635
+ .filter(Boolean).join("\n");
1636
+ await this.#appendHandoffTranscript(activeHandoff, "assistant", assistantMessage(message));
1637
+ await this.emit("model.native_images", { turn: turnNumber, artifacts });
1638
+ await this.emit("model.progress", { turn: turnNumber, message });
1639
+ const receipt = artifacts.map(({ localPath, mimeType, width, height, size, sha256 }) => (
1640
+ { localPath, mimeType, width, height, size, sha256 }
1641
+ ));
1642
+ await this.sendMessage(
1643
+ `<native_image_result>${cdata(JSON.stringify(receipt))}</native_image_result>\n`
1644
+ + "The images from your preceding reply are saved at these verified local paths. "
1645
+ + "Continue the user's original task. Generate further images directly if needed; "
1646
+ + "use local tools for file operations, or finish with a done=true XML answer.",
1647
+ { outboundKind: "native_image_result", runtimeTurn: turnNumber + 1 },
1648
+ );
1649
+ continue;
1650
+ }
1651
+
1375
1652
  let parsed;
1376
1653
  let finalMessage;
1377
1654
  let usedTrailingProse = false;
@@ -1421,50 +1698,33 @@ export class AgentRuntime {
1421
1698
  );
1422
1699
  }
1423
1700
 
1424
- // The model answered in plain prose without any <agent_response> at
1425
- // all. That cannot be a broken tool request (tools only exist inside a
1426
- // parsed envelope), so it is safe to treat the prose as the final
1427
- // answer: end the run and show it, instead of burning retries on a
1428
- // model that deliberately finished the conversation. A reply that DOES
1429
- // contain <agent_response but fails to parse keeps the retry path —
1430
- // the model tried the protocol and we must not guess its intent.
1431
- // A bare <tool_calls>/<invoke> reply (no envelope) is likewise a tool
1432
- // REQUEST, never prose: it goes to the protocol-error retry below.
1433
- const looksLikeToolRequest = /<tool_calls[\s>]|<tool_call[\s>]|<invoke[\s>]/i
1434
- .test(raw);
1435
- const plainAnswer = !raw.includes("<agent_response")
1436
- && !looksLikeToolRequest
1437
- ? stripUiNoiseLines(raw)
1438
- : "";
1439
- if (plainAnswer) {
1440
- await this.emit("protocol.plain_answer", {
1441
- snippet: plainAnswer.slice(0, 200),
1442
- });
1443
- await this.#appendHandoffTranscript(
1444
- activeHandoff,
1445
- "assistant",
1446
- assistantMessage(plainAnswer),
1701
+ // A finished native refusal/clarification is data for the agent, not a
1702
+ // successful image or an empty-response retry. Keep a bounded budget;
1703
+ // malformed XML still follows strict protocol correction below.
1704
+ if (this.nativeImageReceiver && raw.trim() && !/<\/?(?:agent_response|tool_call|tool_calls|invoke|done|message)(?:\s|>)/i.test(raw)) {
1705
+ protocolErrors += 1;
1706
+ await this.#appendHandoffTranscript(activeHandoff, "assistant", assistantMessage(raw));
1707
+ await this.emit("model.progress", { turn: turnNumber, message: raw });
1708
+ if (protocolErrors >= this.limits.maxProtocolErrors) {
1709
+ throw new ProtocolError("The provider repeatedly returned text without a completed XML answer or an image.");
1710
+ }
1711
+ await this.sendMessage(
1712
+ `<native_image_result type="text">${cdata(raw)}</native_image_result>\n`
1713
+ + "Your preceding reply contained only this text; no image was received or saved. "
1714
+ + "Continue the original task based on this result. If generation is unavailable or declined, "
1715
+ + "explain it or ask the user a specific question in a done=true XML answer. Do not automatically retry the same image request.",
1716
+ { outboundKind: "native_image_text", runtimeTurn: turnNumber + 1 },
1447
1717
  );
1448
- await this.#queueSessionOperation(() => (
1449
- this.session.clearPendingAssistantTurn(
1450
- activeHandoff.handoffId,
1451
- {
1452
- phase: "idle",
1453
- lastMessage: plainAnswer,
1454
- pendingToolResult: null,
1455
- },
1456
- )
1457
- ));
1458
- await this.emit("run.completed", {
1459
- message: plainAnswer,
1460
- plainAnswer: true,
1461
- });
1462
- return {
1463
- sessionId: this.session.sessionId,
1464
- message: plainAnswer,
1465
- };
1718
+ continue;
1466
1719
  }
1467
1720
 
1721
+ // Absence of an envelope is a protocol failure, not proof of done.
1722
+ // This also covers UI-only text and partial native-artifact responses.
1723
+ await this.adapter.writeDiagnostics?.("protocol-invalid", {
1724
+ turn: turnNumber,
1725
+ assistantMessageId,
1726
+ error: error.message,
1727
+ }).catch(() => null);
1468
1728
  protocolErrors += 1;
1469
1729
  await this.emit("protocol.invalid", {
1470
1730
  message: error.message,
@@ -1628,6 +1888,7 @@ export class AgentRuntime {
1628
1888
  const sideEffect = isReadTool ? null : identity;
1629
1889
  const fingerprint = identity.fingerprint;
1630
1890
  let result;
1891
+ let restoredForTool = false;
1631
1892
 
1632
1893
  if (
1633
1894
  sideEffect
@@ -1746,32 +2007,46 @@ export class AgentRuntime {
1746
2007
  }
1747
2008
 
1748
2009
  if (!result) {
2010
+ if (preparedCall.definition.requiresVisibleBrowser) {
2011
+ await this.adapter.restoreWindow?.();
2012
+ restoredForTool = true;
2013
+ }
1749
2014
  await this.emit("tool.started", {
1750
2015
  id: preparedCall.id,
1751
2016
  name: preparedCall.name,
1752
2017
  });
1753
- result = await this.registry.execute(preparedCall, {
1754
- projectRoot,
1755
- allowOutside: grants?.allowOutside ?? false,
1756
- toolTimeoutMs: this.limits.toolTimeoutMs,
1757
- onToolOutput: async (output) => {
1758
- await this.session.appendToolOutput({
1759
- id: preparedCall.id,
1760
- name: preparedCall.name,
1761
- ...output,
1762
- });
1763
- await this.onEvent?.({
1764
- type: "tool.output",
1765
- sessionId: this.session.sessionId,
1766
- timestamp: new Date().toISOString(),
1767
- payload: {
2018
+ try {
2019
+ result = await this.registry.execute(preparedCall, {
2020
+ projectRoot,
2021
+ allowOutside: grants?.allowOutside ?? false,
2022
+ toolTimeoutMs: this.limits.toolTimeoutMs,
2023
+ sessionId: this.session.sessionId,
2024
+ operationKey: identity.operationKey,
2025
+ requestSignature: identity.requestSignature,
2026
+ runAuxiliaryTurn: (options) => this.runAuxiliaryTurn(options),
2027
+ onToolOutput: async (output) => {
2028
+ await this.session.appendToolOutput({
1768
2029
  id: preparedCall.id,
1769
2030
  name: preparedCall.name,
1770
2031
  ...output,
1771
- },
1772
- });
1773
- },
1774
- });
2032
+ });
2033
+ await this.onEvent?.({
2034
+ type: "tool.output",
2035
+ sessionId: this.session.sessionId,
2036
+ timestamp: new Date().toISOString(),
2037
+ payload: {
2038
+ id: preparedCall.id,
2039
+ name: preparedCall.name,
2040
+ ...output,
2041
+ },
2042
+ });
2043
+ },
2044
+ });
2045
+ } finally {
2046
+ if (restoredForTool) {
2047
+ await this.adapter.page?.bringToFront?.().catch(() => null);
2048
+ }
2049
+ }
1775
2050
  }
1776
2051
 
1777
2052
  if (sideEffect) {
@@ -1794,6 +2069,8 @@ export class AgentRuntime {
1794
2069
  }
1795
2070
  }
1796
2071
 
2072
+ this.#assertProviderReadyForMessage();
2073
+
1797
2074
  await this.#appendHandoffTranscript(
1798
2075
  activeHandoff,
1799
2076
  "function_output",
@@ -1809,6 +2086,9 @@ export class AgentRuntime {
1809
2086
  name: result.name,
1810
2087
  ok: result.ok,
1811
2088
  });
2089
+ if (restoredForTool) {
2090
+ await this.adapter.minimizeWindow?.();
2091
+ }
1812
2092
  }
1813
2093
 
1814
2094
  }