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
@@ -0,0 +1,211 @@
1
+ import fs from "node:fs/promises";
2
+ import { BrowserImageDriver } from "../browser-image-driver.js";
3
+
4
+ const DOWNLOAD_NAME_PATTERN = /download(?: image)?|下载(?:图片)?|下載(?:圖片)?/i;
5
+ const MIN_IMAGE_EDGE = 256;
6
+
7
+ export function preferredImageUrl(image) {
8
+ const candidates = [image?.source, image?.href]
9
+ .filter((value) => typeof value === "string" && value.length > 0);
10
+ if (candidates.length === 0) {
11
+ return null;
12
+ }
13
+ const durableHttps = candidates.find((value) => {
14
+ try {
15
+ const parsed = new URL(value);
16
+ return parsed.protocol === "https:"
17
+ && !parsed.hostname.endsWith("chatgpt.com");
18
+ } catch {
19
+ return false;
20
+ }
21
+ });
22
+ return durableHttps
23
+ ?? candidates.find((value) => value.startsWith("https://"))
24
+ ?? candidates[0];
25
+ }
26
+
27
+ export class ChatGPTImageDriver extends BrowserImageDriver {
28
+ capabilities() {
29
+ return Object.freeze({
30
+ generate: true,
31
+ referenceImages: true,
32
+ aspectRatios: ["auto", "1:1", "3:2", "2:3", "16:9", "9:16"],
33
+ imageCount: 1,
34
+ });
35
+ }
36
+
37
+ async generate(request, execution = {}) {
38
+ await this.ensureChatPage();
39
+ const baseline = new Set(
40
+ (await this.chatImages().catch(() => [])).map((image) => preferredImageUrl(image)),
41
+ );
42
+ return await this.performTurn(request, execution, async () => {
43
+ const image = await this.waitForChatImage(request.timeoutMs, baseline);
44
+ if (image.type === "text") return image;
45
+ const downloaded = await this.downloadChatImage(
46
+ image,
47
+ Math.min(request.timeoutMs, 60_000),
48
+ );
49
+ return {
50
+ sourcePath: downloaded.temporaryPath,
51
+ cleanup: async () => fs.rm(downloaded.temporaryDir, { recursive: true, force: true }),
52
+ provenance: {
53
+ provider: this.provider,
54
+ providerFileId: this.remoteFileId(image.source)
55
+ ?? this.remoteFileId(downloaded.downloadUrl),
56
+ suggestedFilename: downloaded.suggestedFilename,
57
+ },
58
+ };
59
+ });
60
+ }
61
+
62
+ async imagesFromLocator(locator) {
63
+ if (await locator.count().catch(() => 0) === 0) return [];
64
+ return await locator.evaluate((root, minEdge) => {
65
+ const toAbsolute = (value) => {
66
+ if (!value) return "";
67
+ try {
68
+ return new URL(value, location.href).href;
69
+ } catch {
70
+ return value;
71
+ }
72
+ };
73
+ const fromSrcset = (srcset) => {
74
+ let best = "";
75
+ let bestWidth = 0;
76
+ for (const part of String(srcset ?? "").split(",")) {
77
+ const [url, descriptor] = part.trim().split(/\s+/, 2);
78
+ const width = descriptor?.endsWith("w")
79
+ ? Number.parseInt(descriptor, 10)
80
+ : 0;
81
+ if (url && width >= bestWidth) {
82
+ best = url;
83
+ bestWidth = width;
84
+ }
85
+ }
86
+ return best;
87
+ };
88
+ const images = [...root.querySelectorAll("img")].map((image) => {
89
+ const source = toAbsolute(
90
+ image.currentSrc
91
+ || fromSrcset(image.srcset)
92
+ || image.src
93
+ || image.getAttribute("data-src")
94
+ || "",
95
+ );
96
+ const href = toAbsolute(image.closest("a")?.href || "");
97
+ return {
98
+ source,
99
+ href,
100
+ width: image.naturalWidth || 0,
101
+ height: image.naturalHeight || 0,
102
+ };
103
+ }).filter((image) => (
104
+ (image.source || image.href)
105
+ && image.width >= minEdge
106
+ && image.height >= minEdge
107
+ ));
108
+
109
+ for (const link of root.querySelectorAll("a[href]")) {
110
+ const href = toAbsolute(link.href);
111
+ if (
112
+ !href
113
+ || images.some((image) => image.href === href || image.source === href)
114
+ ) {
115
+ continue;
116
+ }
117
+ if (
118
+ /oaiusercontent/i.test(href)
119
+ || /\.(png|jpe?g|webp)(?:\?|$)/i.test(href)
120
+ ) {
121
+ images.push({
122
+ source: href,
123
+ href,
124
+ width: minEdge,
125
+ height: minEdge,
126
+ });
127
+ }
128
+ }
129
+ return images;
130
+ }, MIN_IMAGE_EDGE).catch(() => []);
131
+ }
132
+
133
+ async currentAssistantImages() {
134
+ return await this.imagesFromLocator(this.adapter.assistantMessages().last());
135
+ }
136
+
137
+ async chatImages() {
138
+ return await this.imagesFromLocator(
139
+ this.adapter.page.locator("main").first(),
140
+ );
141
+ }
142
+
143
+ async waitForChatImage(timeoutMs, baseline = new Set()) {
144
+ const deadline = Date.now() + timeoutMs;
145
+ let candidate = null;
146
+ let stableSince = 0;
147
+ const textState = {};
148
+ while (Date.now() < deadline) {
149
+ const images = (await this.currentAssistantImages().catch(() => []))
150
+ .filter((image) => !baseline.has(preferredImageUrl(image)));
151
+ if (images.length > 1) {
152
+ throw new Error(
153
+ "ChatGPT produced multiple uncorrelated chat images; refusing to guess which artifact belongs to this request.",
154
+ );
155
+ }
156
+ if (images.length === 1) {
157
+ const next = preferredImageUrl(images[0]);
158
+ if (candidate !== next) {
159
+ candidate = next;
160
+ stableSince = Date.now();
161
+ } else if (Date.now() - stableSince >= 1_500) {
162
+ return { ...images[0], source: next };
163
+ }
164
+ } else {
165
+ candidate = null;
166
+ stableSince = 0;
167
+ }
168
+ if (images.length === 0) {
169
+ const text = await this.completedTextResponse(textState);
170
+ if (text) return text;
171
+ } else {
172
+ textState.candidate = null;
173
+ }
174
+ await this.adapter.page.waitForTimeout(1_000);
175
+ }
176
+ throw new Error(`ChatGPT did not publish a new image within ${Math.round(timeoutMs / 1000)} seconds.`);
177
+ }
178
+
179
+ async downloadChatImage(image, timeoutMs) {
180
+ await this.adapter.page.bringToFront().catch(() => null);
181
+ const url = preferredImageUrl(image);
182
+ if (url) {
183
+ try {
184
+ return await this.saveFromUrl(this.adapter.page, url, timeoutMs);
185
+ } catch {
186
+ // The in-chat URL can be a preview or expire; try a download control next.
187
+ }
188
+ }
189
+
190
+ const response = this.adapter.assistantMessages().last();
191
+ const buttonCandidates = [
192
+ response.getByRole("button", { name: DOWNLOAD_NAME_PATTERN }),
193
+ response.locator('button[aria-label*="download" i]'),
194
+ ];
195
+ for (const locator of buttonCandidates) {
196
+ if (await locator.first().isVisible().catch(() => false)) {
197
+ try {
198
+ return await this.saveDownload(
199
+ this.adapter.page,
200
+ locator.first(),
201
+ Math.min(timeoutMs, 30_000),
202
+ );
203
+ } catch {
204
+ break;
205
+ }
206
+ }
207
+ }
208
+ throw new Error("ChatGPT generated an image but no download control or image URL was found.");
209
+ }
210
+
211
+ }
@@ -0,0 +1,100 @@
1
+ import fs from "node:fs/promises";
2
+ import { BrowserImageDriver } from "../browser-image-driver.js";
3
+
4
+ export class GeminiImageDriver extends BrowserImageDriver {
5
+ capabilities() {
6
+ return Object.freeze({
7
+ generate: true,
8
+ referenceImages: true,
9
+ aspectRatios: ["auto", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"],
10
+ imageCount: 1,
11
+ });
12
+ }
13
+
14
+ async generatedImages() {
15
+ const response = this.adapter.assistantMessages().last();
16
+ if (await response.count().catch(() => 0) === 0) return [];
17
+ return await response.locator("img").evaluateAll((images) => images.map((image) => ({
18
+ source: image.currentSrc || image.src || "",
19
+ alt: image.alt || "",
20
+ width: image.naturalWidth || 0,
21
+ height: image.naturalHeight || 0,
22
+ })).filter((image) => image.source && image.width >= 256 && image.height >= 256));
23
+ }
24
+
25
+ async waitForGeneratedImage(timeoutMs, baseline = new Set()) {
26
+ const deadline = Date.now() + timeoutMs;
27
+ let stableSource = null;
28
+ let stableSince = 0;
29
+ const textState = {};
30
+ while (Date.now() < deadline) {
31
+ const images = (await this.generatedImages())
32
+ .filter((image) => !baseline.has(image.source));
33
+ if (images.length === 1) {
34
+ if (stableSource !== images[0].source) {
35
+ stableSource = images[0].source;
36
+ stableSince = Date.now();
37
+ } else if (Date.now() - stableSince >= 2_000) {
38
+ return images[0];
39
+ }
40
+ } else if (images.length > 1) {
41
+ throw new Error(
42
+ "Gemini returned multiple images for a single-output request; refusing to choose one implicitly.",
43
+ );
44
+ }
45
+ if (images.length === 0) {
46
+ const text = await this.completedTextResponse(textState);
47
+ if (text) return text;
48
+ } else {
49
+ textState.candidate = null;
50
+ }
51
+ await this.adapter.page.waitForTimeout(1_000);
52
+ }
53
+ throw new Error(`Gemini did not finish an image within ${Math.round(timeoutMs / 1000)} seconds.`);
54
+ }
55
+
56
+ async downloadGeneratedImage(timeoutMs, generatedImage = null) {
57
+ const response = this.adapter.assistantMessages().last();
58
+ const scope = response;
59
+ const buttonCandidates = [
60
+ scope.getByRole("button", { name: /download(?: image)?|下载(?:图片)?|下載(?:圖片)?/i }),
61
+ scope.locator('button[aria-label*="download" i]'),
62
+ scope.locator('button[data-test-id*="download" i]'),
63
+ ];
64
+ for (const locator of buttonCandidates) {
65
+ if (await locator.first().isVisible().catch(() => false)) {
66
+ try {
67
+ return await this.saveDownload(this.adapter.page, locator.first(), timeoutMs);
68
+ } catch {
69
+ break;
70
+ }
71
+ }
72
+ }
73
+ if (generatedImage?.source) {
74
+ return await this.saveFromUrl(this.adapter.page, generatedImage.source, timeoutMs);
75
+ }
76
+ throw new Error("Gemini generated an image but no image download control was found.");
77
+ }
78
+
79
+ async generate(request, execution = {}) {
80
+ await this.ensureChatPage();
81
+ const before = new Set((await this.generatedImages()).map((item) => item.source));
82
+ return await this.performTurn(request, execution, async () => {
83
+ const generatedImage = await this.waitForGeneratedImage(request.timeoutMs, before);
84
+ if (generatedImage.type === "text") return generatedImage;
85
+ const downloaded = await this.downloadGeneratedImage(
86
+ Math.min(request.timeoutMs, 60_000),
87
+ generatedImage,
88
+ );
89
+ return {
90
+ sourcePath: downloaded.temporaryPath,
91
+ cleanup: async () => fs.rm(downloaded.temporaryDir, { recursive: true, force: true }),
92
+ provenance: {
93
+ provider: this.provider,
94
+ providerFileId: this.remoteFileId(downloaded.downloadUrl),
95
+ suggestedFilename: downloaded.suggestedFilename,
96
+ },
97
+ };
98
+ });
99
+ }
100
+ }
@@ -0,0 +1,193 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { BrowserImageDriver } from "../browser-image-driver.js";
5
+
6
+ const GROK_ASSET_HOST = "assets.grok.com";
7
+ const GROK_GENERATED_IMAGE_PATH = /^\/users\/[^/]+\/generated\/([0-9a-f-]{16,})\/image\.(?:jpe?g|png|webp)$/i;
8
+ const IMAGE_CONTENT_TYPE = /^image\/(?:jpeg|jpg|png|webp)(?:;|$)/i;
9
+
10
+ export function parseGrokGeneratedImageUrl(value) {
11
+ try {
12
+ const url = new URL(value);
13
+ if (url.protocol !== "https:" || url.hostname !== GROK_ASSET_HOST) return null;
14
+ const match = url.pathname.match(GROK_GENERATED_IMAGE_PATH);
15
+ if (!match) return null;
16
+ return {
17
+ id: match[1],
18
+ key: `${url.origin}${url.pathname}`,
19
+ url: url.href,
20
+ };
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ function responseUrl(response) {
27
+ return typeof response?.url === "function" ? response.url() : response?.url;
28
+ }
29
+
30
+ function responseHeaders(response) {
31
+ return typeof response?.headers === "function" ? response.headers() : response?.headers;
32
+ }
33
+
34
+ export class GrokImageDriver extends BrowserImageDriver {
35
+ constructor(options) {
36
+ super(options);
37
+ this.responseStabilityMs = options.responseStabilityMs ?? 2_000;
38
+ }
39
+
40
+ capabilities() {
41
+ return Object.freeze({
42
+ generate: true,
43
+ referenceImages: false,
44
+ aspectRatios: ["auto", "1:1", "3:2", "2:3", "16:9", "9:16"],
45
+ imageCount: 1,
46
+ });
47
+ }
48
+
49
+ trackGeneratedImageResponses() {
50
+ const page = this.adapter.page;
51
+ const images = [];
52
+ const keys = new Set();
53
+ let armed = false;
54
+ let lastAddedAt = 0;
55
+ const listener = (response) => {
56
+ if (!armed) return;
57
+ const parsed = parseGrokGeneratedImageUrl(responseUrl(response));
58
+ if (!parsed || keys.has(parsed.key)) return;
59
+ const ok = typeof response?.ok === "function" ? response.ok() : response?.ok;
60
+ const contentType = String(responseHeaders(response)?.["content-type"] ?? "");
61
+ if (!ok || !IMAGE_CONTENT_TYPE.test(contentType)) return;
62
+ keys.add(parsed.key);
63
+ images.push(parsed);
64
+ lastAddedAt = Date.now();
65
+ };
66
+ page.on("response", listener);
67
+ return {
68
+ arm: () => { armed = true; },
69
+ images: () => images.slice(),
70
+ lastAddedAt: () => lastAddedAt,
71
+ stop: () => page.off("response", listener),
72
+ };
73
+ }
74
+
75
+ async currentAssistantImages() {
76
+ const response = this.adapter.assistantMessages().last();
77
+ if (await response.count().catch(() => 0) === 0) return [];
78
+ return await response.locator("img").evaluateAll((images) => images.map((image) => ({
79
+ source: image.currentSrc || image.src || "",
80
+ alt: image.alt || "",
81
+ width: image.naturalWidth || 0,
82
+ height: image.naturalHeight || 0,
83
+ })).filter((image) => image.source && image.width >= 256 && image.height >= 256));
84
+ }
85
+
86
+ async waitForGeneratedImage(timeoutMs, tracker, baseline = new Set()) {
87
+ const deadline = Date.now() + timeoutMs;
88
+ let candidateKey = null;
89
+ let candidateSince = 0;
90
+ const textState = {};
91
+ while (Date.now() < deadline) {
92
+ const responses = tracker.images();
93
+ const responseByKey = new Map(responses.map((image) => [image.key, image]));
94
+ const rendered = (await this.currentAssistantImages().catch(() => []))
95
+ .map((image) => ({ ...image, parsed: parseGrokGeneratedImageUrl(image.source) }))
96
+ .filter((image) => (
97
+ image.parsed
98
+ && !baseline.has(image.parsed.key)
99
+ && responseByKey.has(image.parsed.key)
100
+ ));
101
+ const keys = [...new Set(rendered.map((image) => image.parsed.key))];
102
+ if (
103
+ keys.length > 0
104
+ && Date.now() - tracker.lastAddedAt() >= this.responseStabilityMs
105
+ ) {
106
+ const signature = keys.join("|");
107
+ if (candidateKey !== signature) {
108
+ candidateKey = signature;
109
+ candidateSince = Date.now();
110
+ } else if (Date.now() - candidateSince >= this.responseStabilityMs) {
111
+ const parsed = responseByKey.get(keys[0]);
112
+ return {
113
+ source: parsed.url,
114
+ providerFileId: parsed.id,
115
+ generatedImageCount: keys.length,
116
+ selectedImageIndex: 1,
117
+ };
118
+ }
119
+ }
120
+ if (keys.length === 0) {
121
+ const text = await this.completedTextResponse(textState);
122
+ if (text) return text;
123
+ } else {
124
+ textState.candidate = null;
125
+ }
126
+ await this.adapter.page.waitForTimeout(500);
127
+ }
128
+ throw new Error(`Grok did not finish an image within ${Math.round(timeoutMs / 1000)} seconds.`);
129
+ }
130
+
131
+ async downloadGeneratedImage(image) {
132
+ const temporaryDir = await fs.mkdtemp(path.join(os.tmpdir(), "wtagent-image-"));
133
+ const sourceUrl = new URL(image.source);
134
+ const extension = path.extname(sourceUrl.pathname).toLowerCase() || ".jpg";
135
+ const temporaryPath = path.join(temporaryDir, `generated-image${extension}`);
136
+ try {
137
+ const response = await this.adapter.page.request.get(image.source, {
138
+ timeout: 60_000,
139
+ });
140
+ if (!response.ok()) {
141
+ throw new Error(`Grok image download failed with HTTP ${response.status()}.`);
142
+ }
143
+ await fs.writeFile(temporaryPath, await response.body());
144
+ return {
145
+ temporaryDir,
146
+ temporaryPath,
147
+ suggestedFilename: path.basename(sourceUrl.pathname) || `generated-image${extension}`,
148
+ downloadUrl: image.source,
149
+ };
150
+ } catch (error) {
151
+ await fs.rm(temporaryDir, { recursive: true, force: true }).catch(() => null);
152
+ throw error;
153
+ }
154
+ }
155
+
156
+ async generate(request, execution = {}) {
157
+ if (request.referenceImages.length > 0) {
158
+ throw new Error("The current Grok image driver does not support reference images.");
159
+ }
160
+ await this.ensureChatPage();
161
+ const baseline = new Set(
162
+ (await this.currentAssistantImages().catch(() => []))
163
+ .map((image) => parseGrokGeneratedImageUrl(image.source)?.key)
164
+ .filter(Boolean),
165
+ );
166
+ const tracker = this.trackGeneratedImageResponses();
167
+ try {
168
+ tracker.arm();
169
+ return await this.performTurn(request, execution, async () => {
170
+ const image = await this.waitForGeneratedImage(
171
+ request.timeoutMs,
172
+ tracker,
173
+ baseline,
174
+ );
175
+ if (image.type === "text") return image;
176
+ const downloaded = await this.downloadGeneratedImage(image);
177
+ return {
178
+ sourcePath: downloaded.temporaryPath,
179
+ cleanup: async () => fs.rm(downloaded.temporaryDir, { recursive: true, force: true }),
180
+ provenance: {
181
+ provider: this.provider,
182
+ providerFileId: image.providerFileId,
183
+ suggestedFilename: downloaded.suggestedFilename,
184
+ generatedImageCount: image.generatedImageCount,
185
+ selectedImageIndex: image.selectedImageIndex,
186
+ },
187
+ };
188
+ });
189
+ } finally {
190
+ tracker.stop();
191
+ }
192
+ }
193
+ }
@@ -9,6 +9,7 @@ const PATH_FIELDS = Object.freeze({
9
9
  "fs.search": ["path"],
10
10
  "terminal.exec": ["cwd"],
11
11
  "process.start": ["cwd"],
12
+ "image.generate": ["output_path", "reference_images"],
12
13
  });
13
14
 
14
15
  const PRIVILEGED_PROGRAMS = new Set(["sudo", "su", "doas", "runas"]);
@@ -268,11 +269,15 @@ export class PolicyEngine {
268
269
  let allowOutside = false;
269
270
 
270
271
  for (const field of PATH_FIELDS[toolCall.name] ?? []) {
271
- const rawPath = toolCall.args[field] || ".";
272
- const resolved = await resolveToolPath(context.projectRoot, rawPath);
273
- if (!resolved.inside) {
274
- reasons.push(`${field} is outside the selected project: ${resolved.path}`);
275
- allowOutside = true;
272
+ const values = Array.isArray(toolCall.args[field])
273
+ ? toolCall.args[field]
274
+ : [toolCall.args[field] || "."];
275
+ for (const rawPath of values) {
276
+ const resolved = await resolveToolPath(context.projectRoot, rawPath);
277
+ if (!resolved.inside) {
278
+ reasons.push(`${field} is outside the selected project: ${resolved.path}`);
279
+ allowOutside = true;
280
+ }
276
281
  }
277
282
  }
278
283
 
@@ -0,0 +1,74 @@
1
+ // Locate transport tags without mistaking quoted attributes, comments, or
2
+ // CDATA payloads for markup. This is a boundary scanner, not an XML validator.
3
+ // Malformed markup is still passed to the strict protocol parser.
4
+ export function* xmlTags(value) {
5
+ const text = String(value ?? "");
6
+ let index = 0;
7
+ while ((index = text.indexOf("<", index)) >= 0) {
8
+ const start = index;
9
+ const special = text.startsWith("<![CDATA[", index)
10
+ ? [9, "]]>"]
11
+ : text.startsWith("<!--", index)
12
+ ? [4, "-->"]
13
+ : text.startsWith("<?", index) ? [2, "?>"] : null;
14
+ if (special) {
15
+ const end = text.indexOf(special[1], index + special[0]);
16
+ if (end < 0) return;
17
+ index = end + special[1].length;
18
+ continue;
19
+ }
20
+ const match = /^<(\/)?([A-Za-z_][\w:.-]*)(?=[\s/>])/.exec(text.slice(index));
21
+ if (!match) {
22
+ index += 1;
23
+ continue;
24
+ }
25
+ index += match[0].length;
26
+ let quote = null;
27
+ for (; index < text.length; index += 1) {
28
+ const character = text[index];
29
+ if (quote) {
30
+ if (character === quote) quote = null;
31
+ } else if (character === '"' || character === "'") {
32
+ quote = character;
33
+ } else if (character === "<" || character === ">") {
34
+ break;
35
+ }
36
+ }
37
+ // Keep an unfinished opening tag visible to the boundary finder; otherwise
38
+ // a tool inside a malformed outer root could be mistaken for a bare call.
39
+ if (index >= text.length || text[index] === "<") {
40
+ yield { name: match[2], closing: Boolean(match[1]), selfClosing: false, start, end: null };
41
+ if (index >= text.length) return;
42
+ continue;
43
+ }
44
+ index += 1;
45
+ yield {
46
+ name: match[2],
47
+ closing: Boolean(match[1]),
48
+ selfClosing: /\/\s*>$/.test(text.slice(start, index)),
49
+ start,
50
+ end: index,
51
+ };
52
+ }
53
+ }
54
+
55
+ export function findXmlElement(text, name = "agent_response") {
56
+ let start = null;
57
+ let depth = 0;
58
+ for (const tag of xmlTags(text)) {
59
+ if (tag.name !== name) continue;
60
+ if (start == null) {
61
+ if (tag.closing) continue;
62
+ start = tag.start;
63
+ }
64
+ if (tag.end == null) return { start, end: null };
65
+ if (tag.closing) depth -= 1;
66
+ else if (!tag.selfClosing) depth += 1;
67
+ if (depth === 0) return { start, end: tag.end };
68
+ }
69
+ return start == null ? null : { start, end: null };
70
+ }
71
+
72
+ export function hasCompleteAgentEnvelope(text) {
73
+ return findXmlElement(text)?.end != null;
74
+ }
@@ -21,6 +21,22 @@ export const DEFAULT_SYSTEM_REMINDER = [
21
21
  "Write large code or file payloads as CDATA (<![CDATA[...]]>) inside <content>, <new_text>, or <message> — never raw code directly inside the envelope — and never include the sequence ]]> inside CDATA content.",
22
22
  ].join(" ");
23
23
 
24
+ // A native image is an assistant response, never a local XML tool call.
25
+ export const NATIVE_IMAGE_CONTRACT = [
26
+ "When the task needs generated artwork, generate and output the image directly using your native image-generation capability.",
27
+ "Image responses are exempt from the XML format: output the image itself, without an XML wrapper or drawing code.",
28
+ "The Runtime automatically downloads images from your completed reply and returns verified local paths; then continue the user's task using those paths.",
29
+ "There is no local image-generation tool. This replaces any image-tool instructions from earlier in this conversation.",
30
+ "If generation returns only text, that text will be returned as the provider result so you can explain the limitation or ask the user; do not claim an image was saved.",
31
+ ].join(" ");
32
+
33
+ export const NATIVE_IMAGE_SYSTEM_REMINDER = [
34
+ NATIVE_IMAGE_CONTRACT,
35
+ "For text answers and local operations, use exactly one complete <agent_response> XML envelope in one xml code fence.",
36
+ "Use done=true for a deliverable answer or a question that requires user input; use done=false with one local tool request when needed.",
37
+ "Use CDATA for code or long payloads. Local tools execute only after the Runtime returns their results.",
38
+ ].join(" ");
39
+
24
40
  export function wrapSystemPrompt(text) {
25
41
  return `<${SYSTEM_PROMPT_TAG}>\n${String(text ?? "")}\n</${SYSTEM_PROMPT_TAG}>`;
26
42
  }