@gajae-code/agent-core 0.9.1 → 0.9.3
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/CHANGELOG.md +6 -0
- package/dist/types/agent.d.ts +7 -1
- package/dist/types/compaction/compaction.d.ts +17 -3
- package/dist/types/image-placeholder-guard.d.ts +4 -0
- package/dist/types/index.d.ts +1 -0
- package/package.json +4 -4
- package/src/agent.ts +43 -3
- package/src/compaction/compaction.ts +79 -10
- package/src/image-placeholder-guard.ts +20 -0
- package/src/index.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.9.2] - 2026-07-09
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Follow-up queues can now mark individual messages as one-at-a-time, so interactive composer queues can remain sequential without disabling the existing batch mode for other callers.
|
|
10
|
+
|
|
5
11
|
## [0.8.2] - 2026-07-06
|
|
6
12
|
### Added
|
|
7
13
|
|
package/dist/types/agent.d.ts
CHANGED
|
@@ -323,8 +323,14 @@ export declare class Agent {
|
|
|
323
323
|
/**
|
|
324
324
|
* Queue a follow-up message to be processed after the agent finishes.
|
|
325
325
|
* Delivered only when agent has no more tool calls or steering messages.
|
|
326
|
+
*
|
|
327
|
+
* `forceOneAtATime` lets UI composer queues preserve prompt-by-prompt
|
|
328
|
+
* delivery even when the session-wide follow-up mode is set to `all` for
|
|
329
|
+
* other integration paths.
|
|
326
330
|
*/
|
|
327
|
-
followUp(m: AgentMessage
|
|
331
|
+
followUp(m: AgentMessage, options?: {
|
|
332
|
+
forceOneAtATime?: boolean;
|
|
333
|
+
}): void;
|
|
328
334
|
clearSteeringQueue(): void;
|
|
329
335
|
clearFollowUpQueue(): void;
|
|
330
336
|
clearAllQueues(): void;
|
|
@@ -67,7 +67,7 @@ export declare function effectiveReserveTokens(contextWindow: number, settings:
|
|
|
67
67
|
*/
|
|
68
68
|
export declare function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings, maxOutputTokens?: number): boolean;
|
|
69
69
|
/** Reason a compaction was triggered. `token` is the normal user-configurable path; the rest are emergency floors. */
|
|
70
|
-
export type CompactionTriggerReason = "token" | "heap" | "providerBytes" | "messageCount" | "imageBytes";
|
|
70
|
+
export type CompactionTriggerReason = "token" | "heap" | "retainedMemory" | "providerBytes" | "messageCount" | "imageBytes";
|
|
71
71
|
/** A point-in-time resource sample. Supplied by an injectable sampler so tests never read real RSS. */
|
|
72
72
|
export interface EmergencyCompactionSample {
|
|
73
73
|
/** Resident heap bytes (e.g. process.memoryUsage().heapUsed). */
|
|
@@ -78,13 +78,27 @@ export interface EmergencyCompactionSample {
|
|
|
78
78
|
messageCount: number;
|
|
79
79
|
/** Approximate inline image bytes in the provider context. */
|
|
80
80
|
imageBytes: number;
|
|
81
|
+
/** Bytes retained by session resident image sentinels; separate from provider-visible bytes. */
|
|
82
|
+
sessionResidentImageBytes?: number;
|
|
83
|
+
/** Bytes retained by non-provider materialized/session-local caches. */
|
|
84
|
+
materializedResidentBytes?: number;
|
|
85
|
+
/** Number of live TUI chat-container children. */
|
|
86
|
+
tuiChatChildren?: number;
|
|
87
|
+
/** Bytes retained by TUI render caches. */
|
|
88
|
+
tuiCachedRenderBytes?: number;
|
|
81
89
|
}
|
|
82
90
|
export interface EmergencyCompactionLimits {
|
|
83
91
|
heapUsedBytes: number;
|
|
84
92
|
providerBytes: number;
|
|
85
93
|
messageCount: number;
|
|
86
94
|
imageBytes: number;
|
|
95
|
+
retainedMemoryBytes?: number;
|
|
96
|
+
retainedMemoryDiagnosticBytes?: number;
|
|
97
|
+
tuiChatChildren?: number;
|
|
98
|
+
tuiChatChildrenDiagnostic?: number;
|
|
87
99
|
}
|
|
100
|
+
export declare function resetEmergencyRetainedMemoryDiagnosticsForTests(): void;
|
|
101
|
+
export declare function resolveEmergencyCompactionLimits(totalMemoryBytes?: number): EmergencyCompactionLimits;
|
|
88
102
|
/**
|
|
89
103
|
* Non-disableable emergency floors. These sit well above normal usage and exist so a
|
|
90
104
|
* long session on weak hardware compacts before OOM even when token-based compaction is
|
|
@@ -92,8 +106,8 @@ export interface EmergencyCompactionLimits {
|
|
|
92
106
|
*/
|
|
93
107
|
export declare const DEFAULT_EMERGENCY_COMPACTION_LIMITS: EmergencyCompactionLimits;
|
|
94
108
|
/**
|
|
95
|
-
* Returns the first emergency limit exceeded (heap > providerBytes > imageBytes > messageCount),
|
|
96
|
-
* or null when none is. Pure
|
|
109
|
+
* Returns the first emergency limit exceeded (heap > retainedMemory > providerBytes > imageBytes > messageCount),
|
|
110
|
+
* or null when none is. Pure apart from retained-memory diagnostics; the caller routes the result through the
|
|
97
111
|
* normal pair-safe `compact()` cut logic so a tool_use/tool_result pair is never split.
|
|
98
112
|
*/
|
|
99
113
|
export declare function emergencyCompactionReason(sample: EmergencyCompactionSample, limits?: EmergencyCompactionLimits): CompactionTriggerReason | null;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ImageContent, TextContent } from "@gajae-code/ai";
|
|
2
|
+
export declare const IMAGE_PLACEHOLDER_ATTACHMENT_GUIDANCE = "Image placeholder text was submitted without an image payload. Paste the image with #paste-image, attach it with @path/to/image.png, or save the image and provide the saved file path.";
|
|
3
|
+
export declare function isImagePlaceholderOnlyText(text: string): boolean;
|
|
4
|
+
export declare function assertImagePlaceholdersHavePayload(text: string, content: readonly (TextContent | ImageContent)[] | undefined): void;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from "./agent-loop";
|
|
|
3
3
|
export * from "./append-only-context";
|
|
4
4
|
export * from "./compaction";
|
|
5
5
|
export * from "./harmony-leak";
|
|
6
|
+
export * from "./image-placeholder-guard";
|
|
6
7
|
export * from "./proxy";
|
|
7
8
|
export * from "./run-collector";
|
|
8
9
|
export * from "./telemetry";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/agent-core",
|
|
4
|
-
"version": "0.9.
|
|
4
|
+
"version": "0.9.3",
|
|
5
5
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
6
6
|
"homepage": "https://gajae-code.com",
|
|
7
7
|
"author": "Yeachan-Heo and Gajae Code Contributors",
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"fmt": "biome format --write ."
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@gajae-code/ai": "0.9.
|
|
36
|
-
"@gajae-code/natives": "0.9.
|
|
37
|
-
"@gajae-code/utils": "0.9.
|
|
35
|
+
"@gajae-code/ai": "0.9.3",
|
|
36
|
+
"@gajae-code/natives": "0.9.3",
|
|
37
|
+
"@gajae-code/utils": "0.9.3",
|
|
38
38
|
"@opentelemetry/api": "^1.9.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
package/src/agent.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { agentLoop, agentLoopContinue } from "./agent-loop";
|
|
24
24
|
import type { AppendOnlyContextManager } from "./append-only-context";
|
|
25
25
|
import type { HarmonyAuditEvent } from "./harmony-leak";
|
|
26
|
+
import { assertImagePlaceholdersHavePayload } from "./image-placeholder-guard";
|
|
26
27
|
import type {
|
|
27
28
|
AgentContext,
|
|
28
29
|
AgentEvent,
|
|
@@ -35,6 +36,23 @@ import type {
|
|
|
35
36
|
ToolCallContext,
|
|
36
37
|
} from "./types";
|
|
37
38
|
|
|
39
|
+
function assertUserImagePlaceholdersHavePayload(messages: readonly AgentMessage[]): void {
|
|
40
|
+
for (const message of messages) {
|
|
41
|
+
if (!("role" in message) || message.role !== "user") continue;
|
|
42
|
+
const content = message.content;
|
|
43
|
+
if (typeof content === "string") {
|
|
44
|
+
assertImagePlaceholdersHavePayload(content, undefined);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (!Array.isArray(content)) continue;
|
|
48
|
+
const text = content
|
|
49
|
+
.filter(part => part.type === "text")
|
|
50
|
+
.map(part => part.text)
|
|
51
|
+
.join("\n");
|
|
52
|
+
assertImagePlaceholdersHavePayload(text, content);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
38
56
|
/**
|
|
39
57
|
* Default convertToLlm: Keep only LLM-compatible messages, convert attachments.
|
|
40
58
|
*/
|
|
@@ -275,6 +293,7 @@ export class Agent {
|
|
|
275
293
|
#transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
|
|
276
294
|
#steeringQueue: AgentMessage[] = [];
|
|
277
295
|
#followUpQueue: AgentMessage[] = [];
|
|
296
|
+
#followUpForceOneAtATime = new WeakSet<AgentMessage>();
|
|
278
297
|
#steeringMode: "all" | "one-at-a-time";
|
|
279
298
|
#followUpMode: "all" | "one-at-a-time";
|
|
280
299
|
#interruptMode: "immediate" | "wait";
|
|
@@ -859,14 +878,23 @@ export class Agent {
|
|
|
859
878
|
* Delivered after current tool execution, skips remaining tools.
|
|
860
879
|
*/
|
|
861
880
|
steer(m: AgentMessage) {
|
|
881
|
+
assertUserImagePlaceholdersHavePayload([m]);
|
|
862
882
|
this.#steeringQueue.push(m);
|
|
863
883
|
}
|
|
864
884
|
|
|
865
885
|
/**
|
|
866
886
|
* Queue a follow-up message to be processed after the agent finishes.
|
|
867
887
|
* Delivered only when agent has no more tool calls or steering messages.
|
|
888
|
+
*
|
|
889
|
+
* `forceOneAtATime` lets UI composer queues preserve prompt-by-prompt
|
|
890
|
+
* delivery even when the session-wide follow-up mode is set to `all` for
|
|
891
|
+
* other integration paths.
|
|
868
892
|
*/
|
|
869
|
-
followUp(m: AgentMessage) {
|
|
893
|
+
followUp(m: AgentMessage, options?: { forceOneAtATime?: boolean }) {
|
|
894
|
+
assertUserImagePlaceholdersHavePayload([m]);
|
|
895
|
+
if (options?.forceOneAtATime) {
|
|
896
|
+
this.#followUpForceOneAtATime.add(m);
|
|
897
|
+
}
|
|
870
898
|
this.#followUpQueue.push(m);
|
|
871
899
|
}
|
|
872
900
|
|
|
@@ -942,8 +970,18 @@ export class Agent {
|
|
|
942
970
|
}
|
|
943
971
|
return [];
|
|
944
972
|
}
|
|
945
|
-
|
|
946
|
-
this.#followUpQueue
|
|
973
|
+
|
|
974
|
+
const first = this.#followUpQueue[0];
|
|
975
|
+
if (!first) return [];
|
|
976
|
+
if (this.#followUpForceOneAtATime.has(first)) {
|
|
977
|
+
this.#followUpQueue = this.#followUpQueue.slice(1);
|
|
978
|
+
return [first];
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const forcedIndex = this.#followUpQueue.findIndex(message => this.#followUpForceOneAtATime.has(message));
|
|
982
|
+
const takeCount = forcedIndex === -1 ? this.#followUpQueue.length : forcedIndex;
|
|
983
|
+
const followUp = this.#followUpQueue.slice(0, takeCount);
|
|
984
|
+
this.#followUpQueue = this.#followUpQueue.slice(takeCount);
|
|
947
985
|
return followUp;
|
|
948
986
|
}
|
|
949
987
|
|
|
@@ -1100,6 +1138,8 @@ export class Agent {
|
|
|
1100
1138
|
promptOptions = imagesOrOptions as AgentPromptOptions | undefined;
|
|
1101
1139
|
}
|
|
1102
1140
|
|
|
1141
|
+
assertUserImagePlaceholdersHavePayload(msgs);
|
|
1142
|
+
|
|
1103
1143
|
await this.#runLoop(msgs, promptOptions);
|
|
1104
1144
|
}
|
|
1105
1145
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* and after compaction the session is reloaded.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import * as os from "node:os";
|
|
8
9
|
import {
|
|
9
10
|
type AssistantMessage,
|
|
10
11
|
Effort,
|
|
@@ -237,7 +238,13 @@ export function shouldCompact(
|
|
|
237
238
|
}
|
|
238
239
|
|
|
239
240
|
/** Reason a compaction was triggered. `token` is the normal user-configurable path; the rest are emergency floors. */
|
|
240
|
-
export type CompactionTriggerReason =
|
|
241
|
+
export type CompactionTriggerReason =
|
|
242
|
+
| "token"
|
|
243
|
+
| "heap"
|
|
244
|
+
| "retainedMemory"
|
|
245
|
+
| "providerBytes"
|
|
246
|
+
| "messageCount"
|
|
247
|
+
| "imageBytes";
|
|
241
248
|
|
|
242
249
|
/** A point-in-time resource sample. Supplied by an injectable sampler so tests never read real RSS. */
|
|
243
250
|
export interface EmergencyCompactionSample {
|
|
@@ -249,6 +256,14 @@ export interface EmergencyCompactionSample {
|
|
|
249
256
|
messageCount: number;
|
|
250
257
|
/** Approximate inline image bytes in the provider context. */
|
|
251
258
|
imageBytes: number;
|
|
259
|
+
/** Bytes retained by session resident image sentinels; separate from provider-visible bytes. */
|
|
260
|
+
sessionResidentImageBytes?: number;
|
|
261
|
+
/** Bytes retained by non-provider materialized/session-local caches. */
|
|
262
|
+
materializedResidentBytes?: number;
|
|
263
|
+
/** Number of live TUI chat-container children. */
|
|
264
|
+
tuiChatChildren?: number;
|
|
265
|
+
/** Bytes retained by TUI render caches. */
|
|
266
|
+
tuiCachedRenderBytes?: number;
|
|
252
267
|
}
|
|
253
268
|
|
|
254
269
|
export interface EmergencyCompactionLimits {
|
|
@@ -256,6 +271,40 @@ export interface EmergencyCompactionLimits {
|
|
|
256
271
|
providerBytes: number;
|
|
257
272
|
messageCount: number;
|
|
258
273
|
imageBytes: number;
|
|
274
|
+
retainedMemoryBytes?: number;
|
|
275
|
+
retainedMemoryDiagnosticBytes?: number;
|
|
276
|
+
tuiChatChildren?: number;
|
|
277
|
+
tuiChatChildrenDiagnostic?: number;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const MAX_EMERGENCY_HEAP_FLOOR_BYTES = 1_536 * 1024 * 1024; // 1.5 GiB resident heap
|
|
281
|
+
const EMERGENCY_RETAINED_MEMORY_BYTES = 128 * 1024 * 1024;
|
|
282
|
+
const DIAGNOSTIC_RETAINED_MEMORY_BYTES = 64 * 1024 * 1024;
|
|
283
|
+
const EMERGENCY_TUI_CHAT_CHILDREN = 1000;
|
|
284
|
+
const DIAGNOSTIC_TUI_CHAT_CHILDREN = 700;
|
|
285
|
+
let retainedMemoryDiagnosticActive = false;
|
|
286
|
+
let tuiChatChildrenDiagnosticActive = false;
|
|
287
|
+
|
|
288
|
+
export function resetEmergencyRetainedMemoryDiagnosticsForTests(): void {
|
|
289
|
+
retainedMemoryDiagnosticActive = false;
|
|
290
|
+
tuiChatChildrenDiagnosticActive = false;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function resolveEmergencyCompactionLimits(totalMemoryBytes: number = os.totalmem()): EmergencyCompactionLimits {
|
|
294
|
+
// Invalid or non-positive total memory (bad injection, exotic platform)
|
|
295
|
+
// must never disable the heap floor — fall back to the fixed 1.5 GiB cap.
|
|
296
|
+
const safeTotal =
|
|
297
|
+
Number.isFinite(totalMemoryBytes) && totalMemoryBytes > 0 ? totalMemoryBytes : Number.POSITIVE_INFINITY;
|
|
298
|
+
return {
|
|
299
|
+
heapUsedBytes: Math.min(MAX_EMERGENCY_HEAP_FLOOR_BYTES, Math.floor(0.5 * safeTotal)),
|
|
300
|
+
providerBytes: 24 * 1024 * 1024, // 24 MiB serialized provider context
|
|
301
|
+
messageCount: 4000,
|
|
302
|
+
imageBytes: 64 * 1024 * 1024, // 64 MiB inline image bytes
|
|
303
|
+
retainedMemoryBytes: EMERGENCY_RETAINED_MEMORY_BYTES,
|
|
304
|
+
retainedMemoryDiagnosticBytes: DIAGNOSTIC_RETAINED_MEMORY_BYTES,
|
|
305
|
+
tuiChatChildren: EMERGENCY_TUI_CHAT_CHILDREN,
|
|
306
|
+
tuiChatChildrenDiagnostic: DIAGNOSTIC_TUI_CHAT_CHILDREN,
|
|
307
|
+
};
|
|
259
308
|
}
|
|
260
309
|
|
|
261
310
|
/**
|
|
@@ -263,23 +312,43 @@ export interface EmergencyCompactionLimits {
|
|
|
263
312
|
* long session on weak hardware compacts before OOM even when token-based compaction is
|
|
264
313
|
* disabled or its threshold is set too high. They are NOT user-tunable down to zero.
|
|
265
314
|
*/
|
|
266
|
-
export const DEFAULT_EMERGENCY_COMPACTION_LIMITS: EmergencyCompactionLimits =
|
|
267
|
-
heapUsedBytes: 1_536 * 1024 * 1024, // 1.5 GiB resident heap
|
|
268
|
-
providerBytes: 24 * 1024 * 1024, // 24 MiB serialized provider context
|
|
269
|
-
messageCount: 4000,
|
|
270
|
-
imageBytes: 64 * 1024 * 1024, // 64 MiB inline image bytes
|
|
271
|
-
};
|
|
315
|
+
export const DEFAULT_EMERGENCY_COMPACTION_LIMITS: EmergencyCompactionLimits = resolveEmergencyCompactionLimits();
|
|
272
316
|
|
|
273
317
|
/**
|
|
274
|
-
* Returns the first emergency limit exceeded (heap > providerBytes > imageBytes > messageCount),
|
|
275
|
-
* or null when none is. Pure
|
|
318
|
+
* Returns the first emergency limit exceeded (heap > retainedMemory > providerBytes > imageBytes > messageCount),
|
|
319
|
+
* or null when none is. Pure apart from retained-memory diagnostics; the caller routes the result through the
|
|
276
320
|
* normal pair-safe `compact()` cut logic so a tool_use/tool_result pair is never split.
|
|
277
321
|
*/
|
|
278
322
|
export function emergencyCompactionReason(
|
|
279
323
|
sample: EmergencyCompactionSample,
|
|
280
|
-
limits: EmergencyCompactionLimits =
|
|
324
|
+
limits: EmergencyCompactionLimits = resolveEmergencyCompactionLimits(),
|
|
281
325
|
): CompactionTriggerReason | null {
|
|
326
|
+
const retainedMemoryBytes = (sample.materializedResidentBytes ?? 0) + (sample.tuiCachedRenderBytes ?? 0);
|
|
327
|
+
const tuiChatChildren = sample.tuiChatChildren ?? 0;
|
|
328
|
+
const retainedDiagnostic =
|
|
329
|
+
retainedMemoryBytes >= (limits.retainedMemoryDiagnosticBytes ?? DIAGNOSTIC_RETAINED_MEMORY_BYTES);
|
|
330
|
+
const childDiagnostic = tuiChatChildren >= (limits.tuiChatChildrenDiagnostic ?? DIAGNOSTIC_TUI_CHAT_CHILDREN);
|
|
331
|
+
if (retainedDiagnostic && !retainedMemoryDiagnosticActive) {
|
|
332
|
+
logger.warn("Emergency compaction retained-memory diagnostic threshold crossed", {
|
|
333
|
+
retainedMemoryBytes,
|
|
334
|
+
limitBytes: limits.retainedMemoryDiagnosticBytes ?? DIAGNOSTIC_RETAINED_MEMORY_BYTES,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
if (childDiagnostic && !tuiChatChildrenDiagnosticActive) {
|
|
338
|
+
logger.warn("Emergency compaction TUI chat-child diagnostic threshold crossed", {
|
|
339
|
+
tuiChatChildren,
|
|
340
|
+
limit: limits.tuiChatChildrenDiagnostic ?? DIAGNOSTIC_TUI_CHAT_CHILDREN,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
retainedMemoryDiagnosticActive = retainedDiagnostic;
|
|
344
|
+
tuiChatChildrenDiagnosticActive = childDiagnostic;
|
|
345
|
+
|
|
282
346
|
if (sample.heapUsedBytes > limits.heapUsedBytes) return "heap";
|
|
347
|
+
if (
|
|
348
|
+
retainedMemoryBytes >= (limits.retainedMemoryBytes ?? EMERGENCY_RETAINED_MEMORY_BYTES) ||
|
|
349
|
+
tuiChatChildren >= (limits.tuiChatChildren ?? EMERGENCY_TUI_CHAT_CHILDREN)
|
|
350
|
+
)
|
|
351
|
+
return "retainedMemory";
|
|
283
352
|
if (sample.providerBytes > limits.providerBytes) return "providerBytes";
|
|
284
353
|
if (sample.imageBytes > limits.imageBytes) return "imageBytes";
|
|
285
354
|
if (sample.messageCount > limits.messageCount) return "messageCount";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ImageContent, TextContent } from "@gajae-code/ai";
|
|
2
|
+
|
|
3
|
+
export const IMAGE_PLACEHOLDER_ATTACHMENT_GUIDANCE =
|
|
4
|
+
"Image placeholder text was submitted without an image payload. Paste the image with #paste-image, attach it with @path/to/image.png, or save the image and provide the saved file path.";
|
|
5
|
+
|
|
6
|
+
const IMAGE_PLACEHOLDER_ONLY_PATTERN = /^\s*(?:\[image\s+\d+\]\s*)+$/i;
|
|
7
|
+
|
|
8
|
+
export function isImagePlaceholderOnlyText(text: string): boolean {
|
|
9
|
+
return IMAGE_PLACEHOLDER_ONLY_PATTERN.test(text);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function assertImagePlaceholdersHavePayload(
|
|
13
|
+
text: string,
|
|
14
|
+
content: readonly (TextContent | ImageContent)[] | undefined,
|
|
15
|
+
): void {
|
|
16
|
+
if (!isImagePlaceholderOnlyText(text)) return;
|
|
17
|
+
const hasImagePayload = content?.some(part => part.type === "image") ?? false;
|
|
18
|
+
if (hasImagePayload) return;
|
|
19
|
+
throw new Error(IMAGE_PLACEHOLDER_ATTACHMENT_GUIDANCE);
|
|
20
|
+
}
|
package/src/index.ts
CHANGED