@adecore/agents 0.20.1 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chat/attachment-store.d.ts +2 -0
- package/dist/chat/attachment-store.js +38 -2
- package/dist/chat/backend.d.ts +12 -1
- package/dist/chat/chat-core.d.ts +53 -1
- package/dist/chat/chat-core.js +52 -9
- package/dist/chat/chat-session.d.ts +22 -2
- package/dist/chat/chat-session.js +190 -10
- package/dist/chat/codex-backend.d.ts +2 -0
- package/dist/chat/codex-backend.js +51 -1
- package/dist/chat/codex-protocol.js +30 -3
- package/dist/chat/delta-coalescer.js +1 -1
- package/dist/chat/errors.d.ts +1 -1
- package/dist/chat/fake-codex.js +33 -0
- package/dist/chat/generated-image.d.ts +6 -0
- package/dist/chat/generated-image.js +74 -0
- package/dist/chat/projector.js +9 -2
- package/dist/chat/thread.js +6 -0
- package/dist/chat/ui-queries.d.ts +38 -0
- package/dist/chat/ui-queries.js +311 -0
- package/dist/host/handlers.js +3 -0
- package/package.json +5 -4
- package/src/chat/attachment-store.ts +38 -2
- package/src/chat/backend.ts +14 -2
- package/src/chat/chat-core.ts +59 -8
- package/src/chat/chat-session.ts +223 -11
- package/src/chat/codex-backend.ts +50 -1
- package/src/chat/codex-protocol.ts +31 -3
- package/src/chat/delta-coalescer.ts +1 -1
- package/src/chat/errors.ts +2 -0
- package/src/chat/fake-codex.ts +34 -0
- package/src/chat/generated-image.ts +88 -0
- package/src/chat/projector.ts +9 -2
- package/src/chat/thread.ts +6 -0
- package/src/chat/ui-queries.ts +360 -0
- package/src/host/handlers.ts +3 -0
package/src/chat/chat-session.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import type {
|
|
3
3
|
ChatAttachment,
|
|
4
|
+
ChatAssistantItem,
|
|
4
5
|
ChatCheckpointDiff,
|
|
5
6
|
ChatEvent,
|
|
6
7
|
ChatInfo,
|
|
@@ -9,10 +10,17 @@ import type {
|
|
|
9
10
|
ChatSkill,
|
|
10
11
|
ChatSubagentItem,
|
|
11
12
|
ChatTurnItem,
|
|
13
|
+
ChatUiAnswer,
|
|
14
|
+
ChatUiChoiceOrigin,
|
|
15
|
+
ChatUiChoicePayload,
|
|
16
|
+
ChatUiQueryState,
|
|
12
17
|
ModelSelection,
|
|
13
18
|
RuntimeMode
|
|
14
19
|
} from '@adecore/agent-contracts';
|
|
15
|
-
import { notResumedNote } from '@adecore/agent-contracts';
|
|
20
|
+
import { GeneratedImageInputSchema, notResumedNote } from '@adecore/agent-contracts';
|
|
21
|
+
import { resolveUiChoice, UiFailure } from '@adecore/intelligent-ui';
|
|
22
|
+
import type { UiCompileOptions } from '@adecore/intelligent-ui/compiler';
|
|
23
|
+
import { UiStream, type UiStreamPreview } from '@adecore/intelligent-ui/stream';
|
|
16
24
|
import type { ChatProvider } from '../providers/provider.ts';
|
|
17
25
|
import type { LimitsUpdate } from '../usage/limits/normalize.ts';
|
|
18
26
|
import type { BackendEvent, BackendLaunch, ChatBackend, TurnInput } from './backend.ts';
|
|
@@ -58,6 +66,7 @@ export interface ChatSessionOptions {
|
|
|
58
66
|
/* The environment of the CLI under the chat's account, asked at every start; throws for an account that cannot start. */
|
|
59
67
|
env(account: string | undefined): Record<string, string>;
|
|
60
68
|
spawn?: SpawnChatProcess;
|
|
69
|
+
saveGeneratedImage?: BackendLaunch['saveGeneratedImage'];
|
|
61
70
|
// What the agent is told at the start of every process, and what of it a resumed thread hears again; see `BackendLaunch`.
|
|
62
71
|
instructions?(): string | null;
|
|
63
72
|
resumeNote?(): string | null;
|
|
@@ -67,6 +76,10 @@ export interface ChatSessionOptions {
|
|
|
67
76
|
references?: ChatReferences;
|
|
68
77
|
// Trees per turn, so a settled turn can show what the working tree holds against its start.
|
|
69
78
|
checkpoints?: TurnCheckpoints;
|
|
79
|
+
ui?: {
|
|
80
|
+
schemas: UiCompileOptions['querySchemas'];
|
|
81
|
+
observe(item: ChatAssistantItem, final: boolean): void;
|
|
82
|
+
};
|
|
70
83
|
emit(event: ChatEvent): void;
|
|
71
84
|
/* What a turn said about the plan it runs on. It belongs to the machine, so it leaves the chat. */
|
|
72
85
|
onLimits?(update: LimitsUpdate): void;
|
|
@@ -98,6 +111,7 @@ export interface LimitResumeHooks {
|
|
|
98
111
|
const TITLE_RECHECK_MS = 10_000;
|
|
99
112
|
|
|
100
113
|
export interface ChatSendExtras {
|
|
114
|
+
uiChoice?: ChatUiChoiceOrigin;
|
|
101
115
|
delivery?: 'steer' | 'queue';
|
|
102
116
|
mentions?: string[];
|
|
103
117
|
skills?: string[];
|
|
@@ -177,6 +191,8 @@ export class ChatSession {
|
|
|
177
191
|
private pendingPreambles: readonly string[];
|
|
178
192
|
private submission: { turnId: string; input: TurnInput } | null = null;
|
|
179
193
|
private readonly steering = new Set<string>();
|
|
194
|
+
private readonly uiStreams = new Map<string, UiStream>();
|
|
195
|
+
private readonly uiCompiled = new Map<string, UiStreamPreview>();
|
|
180
196
|
|
|
181
197
|
constructor(options: ChatSessionOptions) {
|
|
182
198
|
this.options = options;
|
|
@@ -185,6 +201,24 @@ export class ChatSession {
|
|
|
185
201
|
this.projector = new ThreadProjector(this.thread, { providerName: options.provider.name });
|
|
186
202
|
}
|
|
187
203
|
|
|
204
|
+
get uiPreviews(): ChatEvent[] {
|
|
205
|
+
const events: ChatEvent[] = [];
|
|
206
|
+
for (const [itemId, preview] of this.uiCompiled) {
|
|
207
|
+
const item = this.thread.get(itemId);
|
|
208
|
+
if (item?.kind === 'assistant' && item.streaming && item.text.length === preview.textLength) {
|
|
209
|
+
events.push({ type: 'delta', itemId, text: '', ui: preview.blocks, textLength: preview.textLength });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return events;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
previewItems(items: ChatItem[]): ChatItem[] {
|
|
216
|
+
return items.map((item) => {
|
|
217
|
+
const preview = this.uiCompiled.get(item.id);
|
|
218
|
+
return item.kind === 'assistant' && preview?.textLength === item.text.length ? { ...item, ui: preview.blocks } : item;
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
188
222
|
get id(): string {
|
|
189
223
|
return this.thread.info.chatId;
|
|
190
224
|
}
|
|
@@ -265,6 +299,7 @@ export class ChatSession {
|
|
|
265
299
|
turnId,
|
|
266
300
|
text,
|
|
267
301
|
createdAt: Date.now(),
|
|
302
|
+
...(extras.uiChoice ? { uiChoice: extras.uiChoice } : {}),
|
|
268
303
|
...(extras.mentions?.length ? { mentions: extras.mentions } : {}),
|
|
269
304
|
...(extras.skills?.length ? { skills: extras.skills } : {}),
|
|
270
305
|
...(extras.chats?.length ? { chats: extras.chats } : {}),
|
|
@@ -279,6 +314,83 @@ export class ChatSession {
|
|
|
279
314
|
return { queued: false, turnId };
|
|
280
315
|
}
|
|
281
316
|
|
|
317
|
+
updateUiQueries(itemId: string, uiQueries: ChatUiQueryState): void {
|
|
318
|
+
const item = this.thread.get(itemId);
|
|
319
|
+
if (this.frozen || item?.kind !== 'assistant' || item.parentToolUseId) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
this.thread.upsert({ ...item, uiQueries });
|
|
323
|
+
this.options.emit({ type: 'delta', itemId, text: '', uiQueries });
|
|
324
|
+
this.options.persistSoon();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
choose(payload: ChatUiChoicePayload, queries: Readonly<Record<string, unknown>> = {}): { queued: boolean; turnId: string } {
|
|
328
|
+
const item = this.thread.get(payload.itemId);
|
|
329
|
+
const block =
|
|
330
|
+
item?.kind === 'assistant' && !item.streaming && !item.parentToolUseId ? item.ui?.find((block) => block.id === payload.blockId) : undefined;
|
|
331
|
+
if (item?.kind !== 'assistant' || !block?.revision || block.revision !== payload.revision) {
|
|
332
|
+
throw new ChatError('stale-ui-block', 'This completed UI block is no longer available at the requested revision');
|
|
333
|
+
}
|
|
334
|
+
const sameBlock = (origin: ChatUiChoiceOrigin | undefined) =>
|
|
335
|
+
origin?.itemId === item.id && origin.blockId === block.id && origin.revision === block.revision;
|
|
336
|
+
let previous = item.uiAnswers?.[block.id];
|
|
337
|
+
if (previous?.revision !== block.revision) {
|
|
338
|
+
previous = undefined;
|
|
339
|
+
}
|
|
340
|
+
// The message is logged first: a crash before its answer marker must not send it a second time.
|
|
341
|
+
if (!previous) {
|
|
342
|
+
const sent = this.thread.list().find((entry) => entry.kind === 'user' && sameBlock(entry.uiChoice));
|
|
343
|
+
const queued = this.queue.find((entry) => sameBlock(entry.uiChoice));
|
|
344
|
+
const origin = sent?.kind === 'user' ? sent.uiChoice : queued?.uiChoice;
|
|
345
|
+
const turnId = sent?.turnId ?? queued?.turnId;
|
|
346
|
+
if (origin && turnId) {
|
|
347
|
+
previous = { ...origin, turnId, queued: !sent };
|
|
348
|
+
this.recordUiAnswer(previous);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (previous) {
|
|
352
|
+
if (previous.choiceId !== payload.choiceId) {
|
|
353
|
+
throw new ChatError('ui-already-answered', 'This UI block was already answered');
|
|
354
|
+
}
|
|
355
|
+
return { queued: previous.queued, turnId: previous.turnId };
|
|
356
|
+
}
|
|
357
|
+
let selected;
|
|
358
|
+
try {
|
|
359
|
+
selected = resolveUiChoice(block, payload.choiceId, payload.values, queries);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
throw new ChatError(
|
|
362
|
+
error instanceof UiFailure ? error.code : 'refused-choice',
|
|
363
|
+
error instanceof Error ? error.message : 'This choice could not be read'
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
const origin: ChatUiChoiceOrigin = {
|
|
367
|
+
itemId: item.id,
|
|
368
|
+
blockId: block.id,
|
|
369
|
+
revision: block.revision,
|
|
370
|
+
choiceId: payload.choiceId,
|
|
371
|
+
label: selected.label,
|
|
372
|
+
sourceAt: item.createdAt,
|
|
373
|
+
older: this.thread.list().findLast((entry) => entry.kind === 'assistant' && !entry.parentToolUseId)?.id !== item.id,
|
|
374
|
+
values: selected.values,
|
|
375
|
+
at: Date.now()
|
|
376
|
+
};
|
|
377
|
+
const result = this.send(selected.context, { uiChoice: origin });
|
|
378
|
+
this.recordUiAnswer({ ...origin, ...result });
|
|
379
|
+
return result;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private recordUiAnswer(answer: ChatUiAnswer): void {
|
|
383
|
+
const item = this.thread.get(answer.itemId);
|
|
384
|
+
if (item?.kind !== 'assistant') {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (JSON.stringify(item.uiAnswers?.[answer.blockId]) === JSON.stringify(answer)) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
this.options.emit(this.thread.upsert({ ...item, uiAnswers: { ...item.uiAnswers, [answer.blockId]: answer } }));
|
|
391
|
+
this.options.persist();
|
|
392
|
+
}
|
|
393
|
+
|
|
282
394
|
sendInput(text: string, extras: ChatSendExtras = {}): Promise<{ queued: boolean; turnId: string }> {
|
|
283
395
|
return this.submitInput(text, extras);
|
|
284
396
|
}
|
|
@@ -415,7 +527,11 @@ export class ChatSession {
|
|
|
415
527
|
this.sendAfterReplacement = false;
|
|
416
528
|
this.pauseQueue(false);
|
|
417
529
|
this.setQueue(rest);
|
|
418
|
-
this.dispatch(
|
|
530
|
+
this.dispatch(
|
|
531
|
+
next.text,
|
|
532
|
+
{ mentions: next.mentions, skills: next.skills, chats: next.chats, attachments: next.attachments, uiChoice: next.uiChoice },
|
|
533
|
+
next.turnId
|
|
534
|
+
);
|
|
419
535
|
}
|
|
420
536
|
|
|
421
537
|
private dispatch(text: string, extras: ChatSendExtras, requestedTurnId?: string): void {
|
|
@@ -425,7 +541,10 @@ export class ChatSession {
|
|
|
425
541
|
// A slash command must stay the first thing the CLI reads, as `contextNote` keeps it.
|
|
426
542
|
const references = text.startsWith('/') || !this.options.references ? { ids: [], note: null } : this.options.references(extras.chats);
|
|
427
543
|
const turnId = this.openTurn(text, note, { ...extras, chats: references.ids }, requestedTurnId);
|
|
428
|
-
const
|
|
544
|
+
const choiceNote = extras.uiChoice
|
|
545
|
+
? `The person chose ${JSON.stringify(extras.uiChoice.label)} from your UI reply. Their message is the visible context of that choice.`
|
|
546
|
+
: null;
|
|
547
|
+
const said = [preamble, references.note, choiceNote].filter((part): part is string => part !== null);
|
|
429
548
|
const input = {
|
|
430
549
|
text,
|
|
431
550
|
preamble: said.length === 0 ? null : said.join('\n\n'),
|
|
@@ -544,14 +663,14 @@ export class ChatSession {
|
|
|
544
663
|
|
|
545
664
|
/*
|
|
546
665
|
* Starts the chat over: the CLI goes, and the next send starts one without a session to resume.
|
|
547
|
-
*
|
|
548
|
-
*
|
|
666
|
+
* The thread resets at once; the returned promise lets the host wait for pending attachment
|
|
667
|
+
* writes before removing their files. Writing the empty thread is the caller's.
|
|
549
668
|
*/
|
|
550
|
-
clear(force: boolean): void {
|
|
669
|
+
clear(force: boolean): Promise<void> {
|
|
551
670
|
if (this.busy && !force) {
|
|
552
671
|
throw new ChatError('chat-busy', `Chat ${this.id} is still working on the previous message`);
|
|
553
672
|
}
|
|
554
|
-
|
|
673
|
+
const ended = this.dispose();
|
|
555
674
|
this.generation = nextGeneration();
|
|
556
675
|
this.projector.reset();
|
|
557
676
|
// The fresh CLI hears what the host tells it at launch, as it would on a first turn.
|
|
@@ -590,6 +709,7 @@ export class ChatSession {
|
|
|
590
709
|
}
|
|
591
710
|
})
|
|
592
711
|
]);
|
|
712
|
+
return ended;
|
|
593
713
|
}
|
|
594
714
|
|
|
595
715
|
/*
|
|
@@ -994,6 +1114,7 @@ export class ChatSession {
|
|
|
994
1114
|
|
|
995
1115
|
/* Ends the process and stops listening to it, as the host goes down; the thread stays as it is. */
|
|
996
1116
|
freeze(): void {
|
|
1117
|
+
this.clearUi();
|
|
997
1118
|
this.clearStartWarning();
|
|
998
1119
|
this.clearWorkflowWarnings();
|
|
999
1120
|
this.frozen = true;
|
|
@@ -1002,6 +1123,7 @@ export class ChatSession {
|
|
|
1002
1123
|
|
|
1003
1124
|
/* Ends the CLI and everything it started; settles once it exited or was sent the SIGKILL. */
|
|
1004
1125
|
dispose(): Promise<void> {
|
|
1126
|
+
this.clearUi();
|
|
1005
1127
|
this.clearStartWarning();
|
|
1006
1128
|
this.clearWorkflowWarnings();
|
|
1007
1129
|
if (this.titleTimer !== null) {
|
|
@@ -1159,10 +1281,26 @@ export class ChatSession {
|
|
|
1159
1281
|
const skills = extras.skills?.length ? extras.skills : undefined;
|
|
1160
1282
|
const chats = extras.chats?.length ? extras.chats : undefined;
|
|
1161
1283
|
const attachments = extras.attachments?.length ? extras.attachments : undefined;
|
|
1162
|
-
events.push(
|
|
1284
|
+
events.push(
|
|
1285
|
+
this.thread.upsert({
|
|
1286
|
+
id: newId('user'),
|
|
1287
|
+
kind: 'user',
|
|
1288
|
+
createdAt: now,
|
|
1289
|
+
turnId,
|
|
1290
|
+
text,
|
|
1291
|
+
mentions,
|
|
1292
|
+
skills,
|
|
1293
|
+
chats,
|
|
1294
|
+
attachments,
|
|
1295
|
+
...(extras.uiChoice ? { uiChoice: extras.uiChoice } : {})
|
|
1296
|
+
})
|
|
1297
|
+
);
|
|
1163
1298
|
}
|
|
1164
1299
|
events.push(this.thread.patchInfo({ status: this.thread.statusFor(turnId), activeTurnId: turnId }));
|
|
1165
1300
|
this.emit(events);
|
|
1301
|
+
if (extras.uiChoice) {
|
|
1302
|
+
this.recordUiAnswer({ ...extras.uiChoice, turnId, queued: false });
|
|
1303
|
+
}
|
|
1166
1304
|
// On disk before the CLI answers, so a host that goes down mid-turn still shows the question.
|
|
1167
1305
|
this.options.persist();
|
|
1168
1306
|
return turnId;
|
|
@@ -1392,6 +1530,7 @@ export class ChatSession {
|
|
|
1392
1530
|
runtimeMode: info.runtimeMode,
|
|
1393
1531
|
resume: info.agentSessionId,
|
|
1394
1532
|
generation,
|
|
1533
|
+
saveGeneratedImage: this.options.saveGeneratedImage,
|
|
1395
1534
|
instructions: this.options.instructions?.() ?? null,
|
|
1396
1535
|
resumeNote: this.options.resumeNote?.() ?? null,
|
|
1397
1536
|
...(folders.length > 0 ? { folders } : {}),
|
|
@@ -1743,9 +1882,82 @@ export class ChatSession {
|
|
|
1743
1882
|
|
|
1744
1883
|
private emit(events: ChatEvent[]): void {
|
|
1745
1884
|
const delegating = this.delegatingChange(events);
|
|
1746
|
-
for (
|
|
1747
|
-
|
|
1885
|
+
for (let event of delegating === null ? events : [...events, delegating]) {
|
|
1886
|
+
if (event.type === 'reset') {
|
|
1887
|
+
this.clearUi();
|
|
1888
|
+
}
|
|
1889
|
+
const item = event.type === 'item' ? event.item : event.type === 'delta' ? this.thread.get(event.itemId) : null;
|
|
1890
|
+
if (item?.kind !== 'assistant' || item.parentToolUseId) {
|
|
1891
|
+
this.options.emit(event);
|
|
1892
|
+
continue;
|
|
1893
|
+
}
|
|
1894
|
+
let stream = this.uiStreams.get(item.id);
|
|
1895
|
+
if (!stream && !item.text.includes('ruimte-ui')) {
|
|
1896
|
+
this.options.emit(event);
|
|
1897
|
+
continue;
|
|
1898
|
+
}
|
|
1899
|
+
if (!stream) {
|
|
1900
|
+
stream = new UiStream({
|
|
1901
|
+
querySchemas: this.options.ui?.schemas,
|
|
1902
|
+
id: item.id,
|
|
1903
|
+
clock: this.options.clock ?? systemClock,
|
|
1904
|
+
emit: (preview) => {
|
|
1905
|
+
this.uiCompiled.set(item.id, preview);
|
|
1906
|
+
this.options.emit({ type: 'delta', itemId: item.id, text: '', ui: preview.blocks, textLength: preview.textLength });
|
|
1907
|
+
}
|
|
1908
|
+
});
|
|
1909
|
+
this.uiStreams.set(item.id, stream);
|
|
1910
|
+
}
|
|
1911
|
+
this.options.ui?.observe(item, false);
|
|
1912
|
+
const latestAttachment = this.latestUiAttachment(item.turnId);
|
|
1913
|
+
if (event.type === 'item' && !item.streaming) {
|
|
1914
|
+
const ui = stream
|
|
1915
|
+
.finish(item.text, latestAttachment)
|
|
1916
|
+
.map((block) => ({ ...block, revision: createHash('sha256').update(JSON.stringify(block)).digest('hex') }));
|
|
1917
|
+
this.uiStreams.delete(item.id);
|
|
1918
|
+
this.uiCompiled.delete(item.id);
|
|
1919
|
+
const diagnostics = [...new Set(ui.flatMap((block) => block.diagnostics.map((diagnostic) => diagnostic.message)))].slice(0, 6);
|
|
1920
|
+
if (diagnostics.length > 0) {
|
|
1921
|
+
const repair =
|
|
1922
|
+
`Your last ruimte-ui block had these diagnostics: ${diagnostics.join(' ')} Use the catalog's supported props and components.`.slice(
|
|
1923
|
+
0,
|
|
1924
|
+
1200
|
|
1925
|
+
);
|
|
1926
|
+
if (!this.pendingPreambles.includes(repair)) {
|
|
1927
|
+
this.pendingPreambles = [...this.pendingPreambles, repair];
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
const current = this.thread.get(item.id);
|
|
1931
|
+
const completed = { ...item, ui, ...(current?.kind === 'assistant' && current.uiQueries ? { uiQueries: current.uiQueries } : {}) };
|
|
1932
|
+
event = this.thread.upsert(completed);
|
|
1933
|
+
this.options.emit(event);
|
|
1934
|
+
this.options.ui?.observe(completed, true);
|
|
1935
|
+
} else {
|
|
1936
|
+
this.options.emit(event);
|
|
1937
|
+
stream.update(item.text, latestAttachment);
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
private latestUiAttachment(turnId: string | null): string | undefined {
|
|
1943
|
+
for (const item of this.thread.list().toReversed()) {
|
|
1944
|
+
if (item.kind !== 'tool' || item.turnId !== turnId || item.name !== 'ImageGeneration' || item.state !== 'done') {
|
|
1945
|
+
continue;
|
|
1946
|
+
}
|
|
1947
|
+
const input = GeneratedImageInputSchema.safeParse(item.input);
|
|
1948
|
+
if (input.success && input.data.attachment?.mime.startsWith('image/')) {
|
|
1949
|
+
return input.data.attachment.id;
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
return undefined;
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
private clearUi(): void {
|
|
1956
|
+
for (const stream of this.uiStreams.values()) {
|
|
1957
|
+
stream.dispose();
|
|
1748
1958
|
}
|
|
1959
|
+
this.uiStreams.clear();
|
|
1960
|
+
this.uiCompiled.clear();
|
|
1749
1961
|
}
|
|
1750
1962
|
|
|
1751
1963
|
/* Said on the info as well as by the rows, since a client with the thread closed sees only the info. */
|
|
@@ -48,6 +48,7 @@ export class CodexBackend implements ChatBackend {
|
|
|
48
48
|
readonly acknowledgesTurns = true;
|
|
49
49
|
private promptId: string | null = null;
|
|
50
50
|
private readonly launch: BackendLaunch;
|
|
51
|
+
private imageWrites: Promise<void> | null = null;
|
|
51
52
|
private readonly host: BackendHost;
|
|
52
53
|
private readonly protocol: CodexProtocol;
|
|
53
54
|
private readonly client: CodexClientInfo;
|
|
@@ -351,7 +352,7 @@ export class CodexBackend implements ChatBackend {
|
|
|
351
352
|
dispose(): Promise<void> {
|
|
352
353
|
const transport = this.transport;
|
|
353
354
|
this.transport = null;
|
|
354
|
-
return transport?.dispose()
|
|
355
|
+
return Promise.all([transport?.dispose(), this.imageWrites]).then(() => undefined);
|
|
355
356
|
}
|
|
356
357
|
|
|
357
358
|
/* A request whose failure the person has to hear about, since it is the turn that cannot go on. */
|
|
@@ -415,6 +416,54 @@ export class CodexBackend implements ChatBackend {
|
|
|
415
416
|
this.interruptOwed = false;
|
|
416
417
|
this.promptId = null;
|
|
417
418
|
}
|
|
419
|
+
if (event.type === 'image.generated' || this.imageWrites !== null) {
|
|
420
|
+
const pending = (this.imageWrites ?? Promise.resolve()).then(async () => {
|
|
421
|
+
if (event.type === 'image.generated') {
|
|
422
|
+
await this.storeImage(event);
|
|
423
|
+
} else {
|
|
424
|
+
this.host.onEvent(event);
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
this.imageWrites = pending;
|
|
428
|
+
void pending
|
|
429
|
+
.finally(() => {
|
|
430
|
+
if (this.imageWrites === pending) {
|
|
431
|
+
this.imageWrites = null;
|
|
432
|
+
}
|
|
433
|
+
})
|
|
434
|
+
.catch(() => undefined);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
418
437
|
this.host.onEvent(event);
|
|
419
438
|
}
|
|
439
|
+
|
|
440
|
+
private async storeImage(event: Extract<BackendEvent, { type: 'image.generated' }>): Promise<void> {
|
|
441
|
+
const input = { revisedPrompt: event.revisedPrompt, transparentBackground: event.transparentBackground };
|
|
442
|
+
let attachment;
|
|
443
|
+
try {
|
|
444
|
+
if (!this.launch.saveGeneratedImage) {
|
|
445
|
+
throw new Error('This machine cannot save generated images');
|
|
446
|
+
}
|
|
447
|
+
attachment = await this.launch.saveGeneratedImage(event.ref, event.upload);
|
|
448
|
+
} catch (error) {
|
|
449
|
+
this.host.onEvent({ type: 'tool.started', ref: event.ref, name: 'ImageGeneration', input, parentRef: null });
|
|
450
|
+
this.host.onEvent({ type: 'tool.done', ref: event.ref, output: errorText(error), state: 'error' });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
this.host.onEvent({
|
|
454
|
+
type: 'tool.started',
|
|
455
|
+
ref: event.ref,
|
|
456
|
+
name: 'ImageGeneration',
|
|
457
|
+
input: {
|
|
458
|
+
...input,
|
|
459
|
+
attachment: {
|
|
460
|
+
...attachment,
|
|
461
|
+
...(event.width === undefined ? {} : { width: event.width }),
|
|
462
|
+
...(event.height === undefined ? {} : { height: event.height })
|
|
463
|
+
}
|
|
464
|
+
},
|
|
465
|
+
parentRef: null
|
|
466
|
+
});
|
|
467
|
+
this.host.onEvent({ type: 'tool.done', ref: event.ref, output: null, state: 'done' });
|
|
468
|
+
}
|
|
420
469
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { generatedImage } from './generated-image.ts';
|
|
1
2
|
import type { ChatFileChange, ChatQuestion, ChatTurnLimit, UsageWindow } from '@adecore/agent-contracts';
|
|
2
3
|
import { readCodexLimits } from '../usage/limits/normalize.ts';
|
|
3
4
|
import { codexRuntimeMode } from '../providers/codex.ts';
|
|
@@ -632,7 +633,7 @@ export class CodexProtocol {
|
|
|
632
633
|
events.push({ type: 'question.requested', requestId: ref, questions, async: true });
|
|
633
634
|
return;
|
|
634
635
|
}
|
|
635
|
-
this.text(ref, str(item.text) ?? '', completed, events);
|
|
636
|
+
this.text(ref, str(item.text) ?? '', completed, events, str(item.phase) ?? undefined);
|
|
636
637
|
return;
|
|
637
638
|
}
|
|
638
639
|
case 'plan':
|
|
@@ -705,6 +706,33 @@ export class CodexProtocol {
|
|
|
705
706
|
case 'dynamicToolCall':
|
|
706
707
|
this.tool(ref, str(item.tool) ?? 'tool', item.arguments ?? {}, completed, textOf(item.contentItems), item.success !== false, events);
|
|
707
708
|
return;
|
|
709
|
+
case 'imageGeneration': {
|
|
710
|
+
const revisedPrompt = str(item.revisedPrompt) ?? undefined;
|
|
711
|
+
const transparentBackground = typeof item.transparentBackground === 'boolean' ? item.transparentBackground : undefined;
|
|
712
|
+
const input = {
|
|
713
|
+
...(revisedPrompt === undefined ? {} : { revisedPrompt }),
|
|
714
|
+
...(transparentBackground === undefined ? {} : { transparentBackground })
|
|
715
|
+
};
|
|
716
|
+
if (!completed) {
|
|
717
|
+
this.tool(ref, 'ImageGeneration', input, false, '', true, events);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
try {
|
|
721
|
+
if (item.failure || item.status !== 'completed') {
|
|
722
|
+
throw new Error(
|
|
723
|
+
isRecord(item.failure) ? (str(item.failure.message) ?? 'Image generation failed') : (str(item.failure) ?? 'Image generation failed')
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
const image = generatedImage(str(item.result) ?? '', revisedPrompt);
|
|
727
|
+
events.push({ type: 'image.generated', ref, ...image, ...input });
|
|
728
|
+
} catch (error) {
|
|
729
|
+
this.tool(ref, 'ImageGeneration', input, true, error instanceof Error ? error.message : 'Image generation failed', false, events);
|
|
730
|
+
}
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
case 'imageView':
|
|
734
|
+
this.tool(ref, 'ImageView', { path: str(item.path) ?? '' }, completed, '', true, events);
|
|
735
|
+
return;
|
|
708
736
|
case 'webSearch': {
|
|
709
737
|
const action = isRecord(item.action) ? item.action : {};
|
|
710
738
|
this.tool(ref, 'WebSearch', { query: str(item.query) ?? str(action.query) ?? '' }, completed, '', true, events);
|
|
@@ -775,8 +803,8 @@ export class CodexProtocol {
|
|
|
775
803
|
return null;
|
|
776
804
|
}
|
|
777
805
|
|
|
778
|
-
private text(ref: string, text: string, completed: boolean, events: BackendEvent[]): void {
|
|
779
|
-
events.push(completed ?
|
|
806
|
+
private text(ref: string, text: string, completed: boolean, events: BackendEvent[], phase?: string): void {
|
|
807
|
+
events.push({ type: completed ? 'text.done' : 'text.delta', ref, text, ...(phase === undefined ? {} : { phase }) });
|
|
780
808
|
}
|
|
781
809
|
|
|
782
810
|
/*
|
package/src/chat/errors.ts
CHANGED
package/src/chat/fake-codex.ts
CHANGED
|
@@ -197,6 +197,40 @@ export function fakeCodexWith(options: FakeCodexOptions): FakeCli {
|
|
|
197
197
|
turnCompleted('failed', "You've hit your usage limit.", 'usageLimitExceeded');
|
|
198
198
|
return;
|
|
199
199
|
}
|
|
200
|
+
if (text.startsWith('image:')) {
|
|
201
|
+
const scenario = text.slice(6);
|
|
202
|
+
const entry = item('imageGeneration', {
|
|
203
|
+
status: 'inProgress',
|
|
204
|
+
result: '',
|
|
205
|
+
savedPath: '/never-read-generated-image.png',
|
|
206
|
+
revisedPrompt: 'A rabbit',
|
|
207
|
+
transparentBackground: true
|
|
208
|
+
});
|
|
209
|
+
started(entry);
|
|
210
|
+
const result =
|
|
211
|
+
scenario === 'empty'
|
|
212
|
+
? ''
|
|
213
|
+
: scenario === 'invalid'
|
|
214
|
+
? 'not base64'
|
|
215
|
+
: scenario === 'not-image'
|
|
216
|
+
? Buffer.from('text').toString('base64')
|
|
217
|
+
: scenario === 'too-large'
|
|
218
|
+
? Buffer.alloc(10 * 1024 * 1024 + 1).toString('base64')
|
|
219
|
+
: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=';
|
|
220
|
+
const done = {
|
|
221
|
+
...entry,
|
|
222
|
+
status: scenario === 'failed' ? 'failed' : 'completed',
|
|
223
|
+
result,
|
|
224
|
+
...(scenario === 'failed' ? { failure: { message: 'Image generation failed' } } : {})
|
|
225
|
+
};
|
|
226
|
+
completed(done);
|
|
227
|
+
if (scenario === 'replay') {
|
|
228
|
+
completed(done);
|
|
229
|
+
}
|
|
230
|
+
agentMessage('Here is the generated image.');
|
|
231
|
+
turnCompleted('completed');
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
200
234
|
if (text === 'slow') {
|
|
201
235
|
slow = true;
|
|
202
236
|
started(item('agentMessage', { text: '', phase: 'final_answer', memoryCitation: null, delivery: null, questions: null }));
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { ChatAttachmentUploadSchema, type ChatAttachmentUpload } from '@adecore/agent-contracts';
|
|
2
|
+
|
|
3
|
+
interface ImageInfo {
|
|
4
|
+
mime: string;
|
|
5
|
+
extension: string;
|
|
6
|
+
width?: number;
|
|
7
|
+
height?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function imageInfo(bytes: Buffer): ImageInfo | null {
|
|
11
|
+
if (
|
|
12
|
+
bytes.length >= 33 &&
|
|
13
|
+
bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) &&
|
|
14
|
+
bytes.readUInt32BE(8) === 13 &&
|
|
15
|
+
bytes.toString('ascii', 12, 16) === 'IHDR'
|
|
16
|
+
) {
|
|
17
|
+
return { mime: 'image/png', extension: 'png', width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
|
|
18
|
+
}
|
|
19
|
+
if (bytes.length >= 13 && /GIF8[79]a/.test(bytes.toString('ascii', 0, 6))) {
|
|
20
|
+
return { mime: 'image/gif', extension: 'gif', width: bytes.readUInt16LE(6), height: bytes.readUInt16LE(8) };
|
|
21
|
+
}
|
|
22
|
+
if (
|
|
23
|
+
bytes.length >= 20 &&
|
|
24
|
+
bytes.toString('ascii', 0, 4) === 'RIFF' &&
|
|
25
|
+
bytes.toString('ascii', 8, 12) === 'WEBP' &&
|
|
26
|
+
bytes.readUInt32LE(4) + 8 === bytes.length
|
|
27
|
+
) {
|
|
28
|
+
const kind = bytes.toString('ascii', 12, 16);
|
|
29
|
+
if (kind === 'VP8X' && bytes.length >= 30) {
|
|
30
|
+
return { mime: 'image/webp', extension: 'webp', width: bytes.readUIntLE(24, 3) + 1, height: bytes.readUIntLE(27, 3) + 1 };
|
|
31
|
+
}
|
|
32
|
+
if (kind === 'VP8 ' && bytes.length >= 30 && bytes.subarray(23, 26).equals(Buffer.from([157, 1, 42]))) {
|
|
33
|
+
return { mime: 'image/webp', extension: 'webp', width: bytes.readUInt16LE(26) & 0x3fff, height: bytes.readUInt16LE(28) & 0x3fff };
|
|
34
|
+
}
|
|
35
|
+
if (kind === 'VP8L' && bytes.length >= 25 && bytes[20] === 0x2f) {
|
|
36
|
+
const size = bytes.readUInt32LE(21);
|
|
37
|
+
return { mime: 'image/webp', extension: 'webp', width: (size & 0x3fff) + 1, height: ((size >> 14) & 0x3fff) + 1 };
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
if (bytes.length >= 4 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) {
|
|
42
|
+
let offset = 2;
|
|
43
|
+
while (offset + 4 <= bytes.length && bytes[offset] === 255) {
|
|
44
|
+
const marker = bytes[offset + 1]!;
|
|
45
|
+
if (marker === 218 || marker === 217) {
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
if (marker === 255) {
|
|
49
|
+
offset++;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const size = bytes.readUInt16BE(offset + 2);
|
|
53
|
+
if (size < 2 || offset + 2 + size > bytes.length) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
if ([192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207].includes(marker) && size >= 8) {
|
|
57
|
+
return { mime: 'image/jpeg', extension: 'jpg', width: bytes.readUInt16BE(offset + 7), height: bytes.readUInt16BE(offset + 5) };
|
|
58
|
+
}
|
|
59
|
+
offset += size + 2;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function generatedImage(data: string, prompt?: string): { upload: ChatAttachmentUpload; width?: number; height?: number } {
|
|
66
|
+
const parsed = ChatAttachmentUploadSchema.safeParse({ name: 'image', mime: 'application/octet-stream', data });
|
|
67
|
+
if (!parsed.success) {
|
|
68
|
+
throw new Error('The generated image is empty, invalid base64 or exceeds 10 MiB');
|
|
69
|
+
}
|
|
70
|
+
const bytes = Buffer.from(data, 'base64');
|
|
71
|
+
// Buffer's decoder accepts malformed padding and trailing bits; the protocol requires canonical base64.
|
|
72
|
+
if (bytes.toString('base64') !== data) {
|
|
73
|
+
throw new Error('The generated image contains invalid base64');
|
|
74
|
+
}
|
|
75
|
+
const info = imageInfo(bytes);
|
|
76
|
+
if (info === null || (info.width !== undefined && info.width === 0) || (info.height !== undefined && info.height === 0)) {
|
|
77
|
+
throw new Error('The generated bytes are not a supported image');
|
|
78
|
+
}
|
|
79
|
+
const stem =
|
|
80
|
+
(prompt ?? '')
|
|
81
|
+
.normalize('NFKD')
|
|
82
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
83
|
+
.replace(/^-|-$/g, '')
|
|
84
|
+
.slice(0, 80)
|
|
85
|
+
.toLowerCase() || 'generated-image';
|
|
86
|
+
const upload = { name: `${stem}.${info.extension}`, mime: info.mime, data };
|
|
87
|
+
return { upload, ...(info.width === undefined ? {} : { width: info.width }), ...(info.height === undefined ? {} : { height: info.height }) };
|
|
88
|
+
}
|