@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
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { notResumedNote } from '@adecore/agent-contracts';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { GeneratedImageInputSchema, notResumedNote } from '@adecore/agent-contracts';
|
|
3
|
+
import { resolveUiChoice, UiFailure } from '@adecore/intelligent-ui';
|
|
4
|
+
import { UiStream } from '@adecore/intelligent-ui/stream';
|
|
3
5
|
import { commandLabel, isBackgroundWork, reportsOnBackgroundWork, runningInBackground } from './background-work.js';
|
|
4
6
|
import { ChatError } from './errors.js';
|
|
5
7
|
import { limitedTurn, limitResumeAt, limitResumeWake } from './limit-resume.js';
|
|
@@ -70,12 +72,30 @@ export class ChatSession {
|
|
|
70
72
|
pendingPreambles;
|
|
71
73
|
submission = null;
|
|
72
74
|
steering = new Set();
|
|
75
|
+
uiStreams = new Map();
|
|
76
|
+
uiCompiled = new Map();
|
|
73
77
|
constructor(options) {
|
|
74
78
|
this.options = options;
|
|
75
79
|
this.pendingPreambles = options.preambles ?? [];
|
|
76
80
|
this.thread = new ChatThread(options.info, options.items);
|
|
77
81
|
this.projector = new ThreadProjector(this.thread, { providerName: options.provider.name });
|
|
78
82
|
}
|
|
83
|
+
get uiPreviews() {
|
|
84
|
+
const events = [];
|
|
85
|
+
for (const [itemId, preview] of this.uiCompiled) {
|
|
86
|
+
const item = this.thread.get(itemId);
|
|
87
|
+
if (item?.kind === 'assistant' && item.streaming && item.text.length === preview.textLength) {
|
|
88
|
+
events.push({ type: 'delta', itemId, text: '', ui: preview.blocks, textLength: preview.textLength });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return events;
|
|
92
|
+
}
|
|
93
|
+
previewItems(items) {
|
|
94
|
+
return items.map((item) => {
|
|
95
|
+
const preview = this.uiCompiled.get(item.id);
|
|
96
|
+
return item.kind === 'assistant' && preview?.textLength === item.text.length ? { ...item, ui: preview.blocks } : item;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
79
99
|
get id() {
|
|
80
100
|
return this.thread.info.chatId;
|
|
81
101
|
}
|
|
@@ -148,6 +168,7 @@ export class ChatSession {
|
|
|
148
168
|
turnId,
|
|
149
169
|
text,
|
|
150
170
|
createdAt: Date.now(),
|
|
171
|
+
...(extras.uiChoice ? { uiChoice: extras.uiChoice } : {}),
|
|
151
172
|
...(extras.mentions?.length ? { mentions: extras.mentions } : {}),
|
|
152
173
|
...(extras.skills?.length ? { skills: extras.skills } : {}),
|
|
153
174
|
...(extras.chats?.length ? { chats: extras.chats } : {}),
|
|
@@ -161,6 +182,76 @@ export class ChatSession {
|
|
|
161
182
|
this.dispatch(text, extras, turnId);
|
|
162
183
|
return { queued: false, turnId };
|
|
163
184
|
}
|
|
185
|
+
updateUiQueries(itemId, uiQueries) {
|
|
186
|
+
const item = this.thread.get(itemId);
|
|
187
|
+
if (this.frozen || item?.kind !== 'assistant' || item.parentToolUseId) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
this.thread.upsert({ ...item, uiQueries });
|
|
191
|
+
this.options.emit({ type: 'delta', itemId, text: '', uiQueries });
|
|
192
|
+
this.options.persistSoon();
|
|
193
|
+
}
|
|
194
|
+
choose(payload, queries = {}) {
|
|
195
|
+
const item = this.thread.get(payload.itemId);
|
|
196
|
+
const block = item?.kind === 'assistant' && !item.streaming && !item.parentToolUseId ? item.ui?.find((block) => block.id === payload.blockId) : undefined;
|
|
197
|
+
if (item?.kind !== 'assistant' || !block?.revision || block.revision !== payload.revision) {
|
|
198
|
+
throw new ChatError('stale-ui-block', 'This completed UI block is no longer available at the requested revision');
|
|
199
|
+
}
|
|
200
|
+
const sameBlock = (origin) => origin?.itemId === item.id && origin.blockId === block.id && origin.revision === block.revision;
|
|
201
|
+
let previous = item.uiAnswers?.[block.id];
|
|
202
|
+
if (previous?.revision !== block.revision) {
|
|
203
|
+
previous = undefined;
|
|
204
|
+
}
|
|
205
|
+
// The message is logged first: a crash before its answer marker must not send it a second time.
|
|
206
|
+
if (!previous) {
|
|
207
|
+
const sent = this.thread.list().find((entry) => entry.kind === 'user' && sameBlock(entry.uiChoice));
|
|
208
|
+
const queued = this.queue.find((entry) => sameBlock(entry.uiChoice));
|
|
209
|
+
const origin = sent?.kind === 'user' ? sent.uiChoice : queued?.uiChoice;
|
|
210
|
+
const turnId = sent?.turnId ?? queued?.turnId;
|
|
211
|
+
if (origin && turnId) {
|
|
212
|
+
previous = { ...origin, turnId, queued: !sent };
|
|
213
|
+
this.recordUiAnswer(previous);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (previous) {
|
|
217
|
+
if (previous.choiceId !== payload.choiceId) {
|
|
218
|
+
throw new ChatError('ui-already-answered', 'This UI block was already answered');
|
|
219
|
+
}
|
|
220
|
+
return { queued: previous.queued, turnId: previous.turnId };
|
|
221
|
+
}
|
|
222
|
+
let selected;
|
|
223
|
+
try {
|
|
224
|
+
selected = resolveUiChoice(block, payload.choiceId, payload.values, queries);
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
throw new ChatError(error instanceof UiFailure ? error.code : 'refused-choice', error instanceof Error ? error.message : 'This choice could not be read');
|
|
228
|
+
}
|
|
229
|
+
const origin = {
|
|
230
|
+
itemId: item.id,
|
|
231
|
+
blockId: block.id,
|
|
232
|
+
revision: block.revision,
|
|
233
|
+
choiceId: payload.choiceId,
|
|
234
|
+
label: selected.label,
|
|
235
|
+
sourceAt: item.createdAt,
|
|
236
|
+
older: this.thread.list().findLast((entry) => entry.kind === 'assistant' && !entry.parentToolUseId)?.id !== item.id,
|
|
237
|
+
values: selected.values,
|
|
238
|
+
at: Date.now()
|
|
239
|
+
};
|
|
240
|
+
const result = this.send(selected.context, { uiChoice: origin });
|
|
241
|
+
this.recordUiAnswer({ ...origin, ...result });
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
recordUiAnswer(answer) {
|
|
245
|
+
const item = this.thread.get(answer.itemId);
|
|
246
|
+
if (item?.kind !== 'assistant') {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (JSON.stringify(item.uiAnswers?.[answer.blockId]) === JSON.stringify(answer)) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
this.options.emit(this.thread.upsert({ ...item, uiAnswers: { ...item.uiAnswers, [answer.blockId]: answer } }));
|
|
253
|
+
this.options.persist();
|
|
254
|
+
}
|
|
164
255
|
sendInput(text, extras = {}) {
|
|
165
256
|
return this.submitInput(text, extras);
|
|
166
257
|
}
|
|
@@ -289,7 +380,7 @@ export class ChatSession {
|
|
|
289
380
|
this.sendAfterReplacement = false;
|
|
290
381
|
this.pauseQueue(false);
|
|
291
382
|
this.setQueue(rest);
|
|
292
|
-
this.dispatch(next.text, { mentions: next.mentions, skills: next.skills, chats: next.chats, attachments: next.attachments }, next.turnId);
|
|
383
|
+
this.dispatch(next.text, { mentions: next.mentions, skills: next.skills, chats: next.chats, attachments: next.attachments, uiChoice: next.uiChoice }, next.turnId);
|
|
293
384
|
}
|
|
294
385
|
dispatch(text, extras, requestedTurnId) {
|
|
295
386
|
this.pauseQueue(false);
|
|
@@ -298,7 +389,10 @@ export class ChatSession {
|
|
|
298
389
|
// A slash command must stay the first thing the CLI reads, as `contextNote` keeps it.
|
|
299
390
|
const references = text.startsWith('/') || !this.options.references ? { ids: [], note: null } : this.options.references(extras.chats);
|
|
300
391
|
const turnId = this.openTurn(text, note, { ...extras, chats: references.ids }, requestedTurnId);
|
|
301
|
-
const
|
|
392
|
+
const choiceNote = extras.uiChoice
|
|
393
|
+
? `The person chose ${JSON.stringify(extras.uiChoice.label)} from your UI reply. Their message is the visible context of that choice.`
|
|
394
|
+
: null;
|
|
395
|
+
const said = [preamble, references.note, choiceNote].filter((part) => part !== null);
|
|
302
396
|
const input = {
|
|
303
397
|
text,
|
|
304
398
|
preamble: said.length === 0 ? null : said.join('\n\n'),
|
|
@@ -410,14 +504,14 @@ export class ChatSession {
|
|
|
410
504
|
}
|
|
411
505
|
/*
|
|
412
506
|
* Starts the chat over: the CLI goes, and the next send starts one without a session to resume.
|
|
413
|
-
*
|
|
414
|
-
*
|
|
507
|
+
* The thread resets at once; the returned promise lets the host wait for pending attachment
|
|
508
|
+
* writes before removing their files. Writing the empty thread is the caller's.
|
|
415
509
|
*/
|
|
416
510
|
clear(force) {
|
|
417
511
|
if (this.busy && !force) {
|
|
418
512
|
throw new ChatError('chat-busy', `Chat ${this.id} is still working on the previous message`);
|
|
419
513
|
}
|
|
420
|
-
|
|
514
|
+
const ended = this.dispose();
|
|
421
515
|
this.generation = nextGeneration();
|
|
422
516
|
this.projector.reset();
|
|
423
517
|
// The fresh CLI hears what the host tells it at launch, as it would on a first turn.
|
|
@@ -456,6 +550,7 @@ export class ChatSession {
|
|
|
456
550
|
}
|
|
457
551
|
})
|
|
458
552
|
]);
|
|
553
|
+
return ended;
|
|
459
554
|
}
|
|
460
555
|
/*
|
|
461
556
|
* A thread read from disk cannot still be streaming or waiting, so whatever was open is closed,
|
|
@@ -836,6 +931,7 @@ export class ChatSession {
|
|
|
836
931
|
}
|
|
837
932
|
/* Ends the process and stops listening to it, as the host goes down; the thread stays as it is. */
|
|
838
933
|
freeze() {
|
|
934
|
+
this.clearUi();
|
|
839
935
|
this.clearStartWarning();
|
|
840
936
|
this.clearWorkflowWarnings();
|
|
841
937
|
this.frozen = true;
|
|
@@ -843,6 +939,7 @@ export class ChatSession {
|
|
|
843
939
|
}
|
|
844
940
|
/* Ends the CLI and everything it started; settles once it exited or was sent the SIGKILL. */
|
|
845
941
|
dispose() {
|
|
942
|
+
this.clearUi();
|
|
846
943
|
this.clearStartWarning();
|
|
847
944
|
this.clearWorkflowWarnings();
|
|
848
945
|
if (this.titleTimer !== null) {
|
|
@@ -988,10 +1085,24 @@ export class ChatSession {
|
|
|
988
1085
|
const skills = extras.skills?.length ? extras.skills : undefined;
|
|
989
1086
|
const chats = extras.chats?.length ? extras.chats : undefined;
|
|
990
1087
|
const attachments = extras.attachments?.length ? extras.attachments : undefined;
|
|
991
|
-
events.push(this.thread.upsert({
|
|
1088
|
+
events.push(this.thread.upsert({
|
|
1089
|
+
id: newId('user'),
|
|
1090
|
+
kind: 'user',
|
|
1091
|
+
createdAt: now,
|
|
1092
|
+
turnId,
|
|
1093
|
+
text,
|
|
1094
|
+
mentions,
|
|
1095
|
+
skills,
|
|
1096
|
+
chats,
|
|
1097
|
+
attachments,
|
|
1098
|
+
...(extras.uiChoice ? { uiChoice: extras.uiChoice } : {})
|
|
1099
|
+
}));
|
|
992
1100
|
}
|
|
993
1101
|
events.push(this.thread.patchInfo({ status: this.thread.statusFor(turnId), activeTurnId: turnId }));
|
|
994
1102
|
this.emit(events);
|
|
1103
|
+
if (extras.uiChoice) {
|
|
1104
|
+
this.recordUiAnswer({ ...extras.uiChoice, turnId, queued: false });
|
|
1105
|
+
}
|
|
995
1106
|
// On disk before the CLI answers, so a host that goes down mid-turn still shows the question.
|
|
996
1107
|
this.options.persist();
|
|
997
1108
|
return turnId;
|
|
@@ -1206,6 +1317,7 @@ export class ChatSession {
|
|
|
1206
1317
|
runtimeMode: info.runtimeMode,
|
|
1207
1318
|
resume: info.agentSessionId,
|
|
1208
1319
|
generation,
|
|
1320
|
+
saveGeneratedImage: this.options.saveGeneratedImage,
|
|
1209
1321
|
instructions: this.options.instructions?.() ?? null,
|
|
1210
1322
|
resumeNote: this.options.resumeNote?.() ?? null,
|
|
1211
1323
|
...(folders.length > 0 ? { folders } : {}),
|
|
@@ -1536,9 +1648,77 @@ export class ChatSession {
|
|
|
1536
1648
|
}
|
|
1537
1649
|
emit(events) {
|
|
1538
1650
|
const delegating = this.delegatingChange(events);
|
|
1539
|
-
for (
|
|
1540
|
-
|
|
1651
|
+
for (let event of delegating === null ? events : [...events, delegating]) {
|
|
1652
|
+
if (event.type === 'reset') {
|
|
1653
|
+
this.clearUi();
|
|
1654
|
+
}
|
|
1655
|
+
const item = event.type === 'item' ? event.item : event.type === 'delta' ? this.thread.get(event.itemId) : null;
|
|
1656
|
+
if (item?.kind !== 'assistant' || item.parentToolUseId) {
|
|
1657
|
+
this.options.emit(event);
|
|
1658
|
+
continue;
|
|
1659
|
+
}
|
|
1660
|
+
let stream = this.uiStreams.get(item.id);
|
|
1661
|
+
if (!stream && !item.text.includes('ruimte-ui')) {
|
|
1662
|
+
this.options.emit(event);
|
|
1663
|
+
continue;
|
|
1664
|
+
}
|
|
1665
|
+
if (!stream) {
|
|
1666
|
+
stream = new UiStream({
|
|
1667
|
+
querySchemas: this.options.ui?.schemas,
|
|
1668
|
+
id: item.id,
|
|
1669
|
+
clock: this.options.clock ?? systemClock,
|
|
1670
|
+
emit: (preview) => {
|
|
1671
|
+
this.uiCompiled.set(item.id, preview);
|
|
1672
|
+
this.options.emit({ type: 'delta', itemId: item.id, text: '', ui: preview.blocks, textLength: preview.textLength });
|
|
1673
|
+
}
|
|
1674
|
+
});
|
|
1675
|
+
this.uiStreams.set(item.id, stream);
|
|
1676
|
+
}
|
|
1677
|
+
this.options.ui?.observe(item, false);
|
|
1678
|
+
const latestAttachment = this.latestUiAttachment(item.turnId);
|
|
1679
|
+
if (event.type === 'item' && !item.streaming) {
|
|
1680
|
+
const ui = stream
|
|
1681
|
+
.finish(item.text, latestAttachment)
|
|
1682
|
+
.map((block) => ({ ...block, revision: createHash('sha256').update(JSON.stringify(block)).digest('hex') }));
|
|
1683
|
+
this.uiStreams.delete(item.id);
|
|
1684
|
+
this.uiCompiled.delete(item.id);
|
|
1685
|
+
const diagnostics = [...new Set(ui.flatMap((block) => block.diagnostics.map((diagnostic) => diagnostic.message)))].slice(0, 6);
|
|
1686
|
+
if (diagnostics.length > 0) {
|
|
1687
|
+
const repair = `Your last ruimte-ui block had these diagnostics: ${diagnostics.join(' ')} Use the catalog's supported props and components.`.slice(0, 1200);
|
|
1688
|
+
if (!this.pendingPreambles.includes(repair)) {
|
|
1689
|
+
this.pendingPreambles = [...this.pendingPreambles, repair];
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
const current = this.thread.get(item.id);
|
|
1693
|
+
const completed = { ...item, ui, ...(current?.kind === 'assistant' && current.uiQueries ? { uiQueries: current.uiQueries } : {}) };
|
|
1694
|
+
event = this.thread.upsert(completed);
|
|
1695
|
+
this.options.emit(event);
|
|
1696
|
+
this.options.ui?.observe(completed, true);
|
|
1697
|
+
}
|
|
1698
|
+
else {
|
|
1699
|
+
this.options.emit(event);
|
|
1700
|
+
stream.update(item.text, latestAttachment);
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
latestUiAttachment(turnId) {
|
|
1705
|
+
for (const item of this.thread.list().toReversed()) {
|
|
1706
|
+
if (item.kind !== 'tool' || item.turnId !== turnId || item.name !== 'ImageGeneration' || item.state !== 'done') {
|
|
1707
|
+
continue;
|
|
1708
|
+
}
|
|
1709
|
+
const input = GeneratedImageInputSchema.safeParse(item.input);
|
|
1710
|
+
if (input.success && input.data.attachment?.mime.startsWith('image/')) {
|
|
1711
|
+
return input.data.attachment.id;
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
return undefined;
|
|
1715
|
+
}
|
|
1716
|
+
clearUi() {
|
|
1717
|
+
for (const stream of this.uiStreams.values()) {
|
|
1718
|
+
stream.dispose();
|
|
1541
1719
|
}
|
|
1720
|
+
this.uiStreams.clear();
|
|
1721
|
+
this.uiCompiled.clear();
|
|
1542
1722
|
}
|
|
1543
1723
|
/* Said on the info as well as by the rows, since a client with the thread closed sees only the info. */
|
|
1544
1724
|
delegatingChange(events) {
|
|
@@ -9,6 +9,7 @@ export declare class CodexBackend implements ChatBackend {
|
|
|
9
9
|
readonly acknowledgesTurns = true;
|
|
10
10
|
private promptId;
|
|
11
11
|
private readonly launch;
|
|
12
|
+
private imageWrites;
|
|
12
13
|
private readonly host;
|
|
13
14
|
private readonly protocol;
|
|
14
15
|
private readonly client;
|
|
@@ -50,4 +51,5 @@ export declare class CodexBackend implements ChatBackend {
|
|
|
50
51
|
private handleFrame;
|
|
51
52
|
private handleExit;
|
|
52
53
|
private emit;
|
|
54
|
+
private storeImage;
|
|
53
55
|
}
|
|
@@ -39,6 +39,7 @@ export class CodexBackend {
|
|
|
39
39
|
acknowledgesTurns = true;
|
|
40
40
|
promptId = null;
|
|
41
41
|
launch;
|
|
42
|
+
imageWrites = null;
|
|
42
43
|
host;
|
|
43
44
|
protocol;
|
|
44
45
|
client;
|
|
@@ -321,7 +322,7 @@ export class CodexBackend {
|
|
|
321
322
|
dispose() {
|
|
322
323
|
const transport = this.transport;
|
|
323
324
|
this.transport = null;
|
|
324
|
-
return transport?.dispose()
|
|
325
|
+
return Promise.all([transport?.dispose(), this.imageWrites]).then(() => undefined);
|
|
325
326
|
}
|
|
326
327
|
/* A request whose failure the person has to hear about, since it is the turn that cannot go on. */
|
|
327
328
|
request(method, params, accepted) {
|
|
@@ -381,6 +382,55 @@ export class CodexBackend {
|
|
|
381
382
|
this.interruptOwed = false;
|
|
382
383
|
this.promptId = null;
|
|
383
384
|
}
|
|
385
|
+
if (event.type === 'image.generated' || this.imageWrites !== null) {
|
|
386
|
+
const pending = (this.imageWrites ?? Promise.resolve()).then(async () => {
|
|
387
|
+
if (event.type === 'image.generated') {
|
|
388
|
+
await this.storeImage(event);
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
this.host.onEvent(event);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
this.imageWrites = pending;
|
|
395
|
+
void pending
|
|
396
|
+
.finally(() => {
|
|
397
|
+
if (this.imageWrites === pending) {
|
|
398
|
+
this.imageWrites = null;
|
|
399
|
+
}
|
|
400
|
+
})
|
|
401
|
+
.catch(() => undefined);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
384
404
|
this.host.onEvent(event);
|
|
385
405
|
}
|
|
406
|
+
async storeImage(event) {
|
|
407
|
+
const input = { revisedPrompt: event.revisedPrompt, transparentBackground: event.transparentBackground };
|
|
408
|
+
let attachment;
|
|
409
|
+
try {
|
|
410
|
+
if (!this.launch.saveGeneratedImage) {
|
|
411
|
+
throw new Error('This machine cannot save generated images');
|
|
412
|
+
}
|
|
413
|
+
attachment = await this.launch.saveGeneratedImage(event.ref, event.upload);
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
this.host.onEvent({ type: 'tool.started', ref: event.ref, name: 'ImageGeneration', input, parentRef: null });
|
|
417
|
+
this.host.onEvent({ type: 'tool.done', ref: event.ref, output: errorText(error), state: 'error' });
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
this.host.onEvent({
|
|
421
|
+
type: 'tool.started',
|
|
422
|
+
ref: event.ref,
|
|
423
|
+
name: 'ImageGeneration',
|
|
424
|
+
input: {
|
|
425
|
+
...input,
|
|
426
|
+
attachment: {
|
|
427
|
+
...attachment,
|
|
428
|
+
...(event.width === undefined ? {} : { width: event.width }),
|
|
429
|
+
...(event.height === undefined ? {} : { height: event.height })
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
parentRef: null
|
|
433
|
+
});
|
|
434
|
+
this.host.onEvent({ type: 'tool.done', ref: event.ref, output: null, state: 'done' });
|
|
435
|
+
}
|
|
386
436
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { generatedImage } from './generated-image.js';
|
|
1
2
|
import { readCodexLimits } from '../usage/limits/normalize.js';
|
|
2
3
|
import { codexRuntimeMode } from '../providers/codex.js';
|
|
3
4
|
import { cleanTitle } from '../title-file.js';
|
|
@@ -567,7 +568,7 @@ export class CodexProtocol {
|
|
|
567
568
|
events.push({ type: 'question.requested', requestId: ref, questions, async: true });
|
|
568
569
|
return;
|
|
569
570
|
}
|
|
570
|
-
this.text(ref, str(item.text) ?? '', completed, events);
|
|
571
|
+
this.text(ref, str(item.text) ?? '', completed, events, str(item.phase) ?? undefined);
|
|
571
572
|
return;
|
|
572
573
|
}
|
|
573
574
|
case 'plan':
|
|
@@ -616,6 +617,32 @@ export class CodexProtocol {
|
|
|
616
617
|
case 'dynamicToolCall':
|
|
617
618
|
this.tool(ref, str(item.tool) ?? 'tool', item.arguments ?? {}, completed, textOf(item.contentItems), item.success !== false, events);
|
|
618
619
|
return;
|
|
620
|
+
case 'imageGeneration': {
|
|
621
|
+
const revisedPrompt = str(item.revisedPrompt) ?? undefined;
|
|
622
|
+
const transparentBackground = typeof item.transparentBackground === 'boolean' ? item.transparentBackground : undefined;
|
|
623
|
+
const input = {
|
|
624
|
+
...(revisedPrompt === undefined ? {} : { revisedPrompt }),
|
|
625
|
+
...(transparentBackground === undefined ? {} : { transparentBackground })
|
|
626
|
+
};
|
|
627
|
+
if (!completed) {
|
|
628
|
+
this.tool(ref, 'ImageGeneration', input, false, '', true, events);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
try {
|
|
632
|
+
if (item.failure || item.status !== 'completed') {
|
|
633
|
+
throw new Error(isRecord(item.failure) ? (str(item.failure.message) ?? 'Image generation failed') : (str(item.failure) ?? 'Image generation failed'));
|
|
634
|
+
}
|
|
635
|
+
const image = generatedImage(str(item.result) ?? '', revisedPrompt);
|
|
636
|
+
events.push({ type: 'image.generated', ref, ...image, ...input });
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
this.tool(ref, 'ImageGeneration', input, true, error instanceof Error ? error.message : 'Image generation failed', false, events);
|
|
640
|
+
}
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
case 'imageView':
|
|
644
|
+
this.tool(ref, 'ImageView', { path: str(item.path) ?? '' }, completed, '', true, events);
|
|
645
|
+
return;
|
|
619
646
|
case 'webSearch': {
|
|
620
647
|
const action = isRecord(item.action) ? item.action : {};
|
|
621
648
|
this.tool(ref, 'WebSearch', { query: str(item.query) ?? str(action.query) ?? '' }, completed, '', true, events);
|
|
@@ -683,8 +710,8 @@ export class CodexProtocol {
|
|
|
683
710
|
}
|
|
684
711
|
return null;
|
|
685
712
|
}
|
|
686
|
-
text(ref, text, completed, events) {
|
|
687
|
-
events.push(completed ?
|
|
713
|
+
text(ref, text, completed, events, phase) {
|
|
714
|
+
events.push({ type: completed ? 'text.done' : 'text.delta', ref, text, ...(phase === undefined ? {} : { phase }) });
|
|
688
715
|
}
|
|
689
716
|
/*
|
|
690
717
|
* An agent Codex spawned, as a spawnAgent call reports it. It works in a thread of its own, so this row
|
package/dist/chat/errors.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { CodedError } from '../coded-error.ts';
|
|
2
|
-
export type ChatErrorCode = 'history-expired' | 'chat-not-found' | 'chat-unreadable' | 'chat-busy' | 'request-not-found' | 'chat-unsupported' | 'invalid-attachments' | 'subagent-not-found' | 'task-not-found' | 'transcript-missing' | 'transcript-format' | 'fork-failed' | 'turn-not-found' | 'item-not-found' | 'bookmark-not-found' | 'too-many-bookmarks' | 'visual-invalid' | 'visual-too-large';
|
|
2
|
+
export type ChatErrorCode = 'refused-query' | 'stale-ui-query' | 'history-expired' | 'chat-not-found' | 'chat-unreadable' | 'chat-busy' | 'request-not-found' | 'chat-unsupported' | 'invalid-attachments' | 'subagent-not-found' | 'task-not-found' | 'transcript-missing' | 'transcript-format' | 'fork-failed' | 'turn-not-found' | 'item-not-found' | 'bookmark-not-found' | 'too-many-bookmarks' | 'visual-invalid' | 'visual-too-large';
|
|
3
3
|
export declare class ChatError<TCode extends string = ChatErrorCode> extends CodedError<TCode> {
|
|
4
4
|
}
|
package/dist/chat/fake-codex.js
CHANGED
|
@@ -169,6 +169,39 @@ export function fakeCodexWith(options) {
|
|
|
169
169
|
turnCompleted('failed', "You've hit your usage limit.", 'usageLimitExceeded');
|
|
170
170
|
return;
|
|
171
171
|
}
|
|
172
|
+
if (text.startsWith('image:')) {
|
|
173
|
+
const scenario = text.slice(6);
|
|
174
|
+
const entry = item('imageGeneration', {
|
|
175
|
+
status: 'inProgress',
|
|
176
|
+
result: '',
|
|
177
|
+
savedPath: '/never-read-generated-image.png',
|
|
178
|
+
revisedPrompt: 'A rabbit',
|
|
179
|
+
transparentBackground: true
|
|
180
|
+
});
|
|
181
|
+
started(entry);
|
|
182
|
+
const result = scenario === 'empty'
|
|
183
|
+
? ''
|
|
184
|
+
: scenario === 'invalid'
|
|
185
|
+
? 'not base64'
|
|
186
|
+
: scenario === 'not-image'
|
|
187
|
+
? Buffer.from('text').toString('base64')
|
|
188
|
+
: scenario === 'too-large'
|
|
189
|
+
? Buffer.alloc(10 * 1024 * 1024 + 1).toString('base64')
|
|
190
|
+
: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=';
|
|
191
|
+
const done = {
|
|
192
|
+
...entry,
|
|
193
|
+
status: scenario === 'failed' ? 'failed' : 'completed',
|
|
194
|
+
result,
|
|
195
|
+
...(scenario === 'failed' ? { failure: { message: 'Image generation failed' } } : {})
|
|
196
|
+
};
|
|
197
|
+
completed(done);
|
|
198
|
+
if (scenario === 'replay') {
|
|
199
|
+
completed(done);
|
|
200
|
+
}
|
|
201
|
+
agentMessage('Here is the generated image.');
|
|
202
|
+
turnCompleted('completed');
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
172
205
|
if (text === 'slow') {
|
|
173
206
|
slow = true;
|
|
174
207
|
started(item('agentMessage', { text: '', phase: 'final_answer', memoryCitation: null, delivery: null, questions: null }));
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { ChatAttachmentUploadSchema } from '@adecore/agent-contracts';
|
|
2
|
+
function imageInfo(bytes) {
|
|
3
|
+
if (bytes.length >= 33 &&
|
|
4
|
+
bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) &&
|
|
5
|
+
bytes.readUInt32BE(8) === 13 &&
|
|
6
|
+
bytes.toString('ascii', 12, 16) === 'IHDR') {
|
|
7
|
+
return { mime: 'image/png', extension: 'png', width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
|
|
8
|
+
}
|
|
9
|
+
if (bytes.length >= 13 && /GIF8[79]a/.test(bytes.toString('ascii', 0, 6))) {
|
|
10
|
+
return { mime: 'image/gif', extension: 'gif', width: bytes.readUInt16LE(6), height: bytes.readUInt16LE(8) };
|
|
11
|
+
}
|
|
12
|
+
if (bytes.length >= 20 &&
|
|
13
|
+
bytes.toString('ascii', 0, 4) === 'RIFF' &&
|
|
14
|
+
bytes.toString('ascii', 8, 12) === 'WEBP' &&
|
|
15
|
+
bytes.readUInt32LE(4) + 8 === bytes.length) {
|
|
16
|
+
const kind = bytes.toString('ascii', 12, 16);
|
|
17
|
+
if (kind === 'VP8X' && bytes.length >= 30) {
|
|
18
|
+
return { mime: 'image/webp', extension: 'webp', width: bytes.readUIntLE(24, 3) + 1, height: bytes.readUIntLE(27, 3) + 1 };
|
|
19
|
+
}
|
|
20
|
+
if (kind === 'VP8 ' && bytes.length >= 30 && bytes.subarray(23, 26).equals(Buffer.from([157, 1, 42]))) {
|
|
21
|
+
return { mime: 'image/webp', extension: 'webp', width: bytes.readUInt16LE(26) & 0x3fff, height: bytes.readUInt16LE(28) & 0x3fff };
|
|
22
|
+
}
|
|
23
|
+
if (kind === 'VP8L' && bytes.length >= 25 && bytes[20] === 0x2f) {
|
|
24
|
+
const size = bytes.readUInt32LE(21);
|
|
25
|
+
return { mime: 'image/webp', extension: 'webp', width: (size & 0x3fff) + 1, height: ((size >> 14) & 0x3fff) + 1 };
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
if (bytes.length >= 4 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) {
|
|
30
|
+
let offset = 2;
|
|
31
|
+
while (offset + 4 <= bytes.length && bytes[offset] === 255) {
|
|
32
|
+
const marker = bytes[offset + 1];
|
|
33
|
+
if (marker === 218 || marker === 217) {
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
if (marker === 255) {
|
|
37
|
+
offset++;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const size = bytes.readUInt16BE(offset + 2);
|
|
41
|
+
if (size < 2 || offset + 2 + size > bytes.length) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if ([192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207].includes(marker) && size >= 8) {
|
|
45
|
+
return { mime: 'image/jpeg', extension: 'jpg', width: bytes.readUInt16BE(offset + 7), height: bytes.readUInt16BE(offset + 5) };
|
|
46
|
+
}
|
|
47
|
+
offset += size + 2;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
export function generatedImage(data, prompt) {
|
|
53
|
+
const parsed = ChatAttachmentUploadSchema.safeParse({ name: 'image', mime: 'application/octet-stream', data });
|
|
54
|
+
if (!parsed.success) {
|
|
55
|
+
throw new Error('The generated image is empty, invalid base64 or exceeds 10 MiB');
|
|
56
|
+
}
|
|
57
|
+
const bytes = Buffer.from(data, 'base64');
|
|
58
|
+
// Buffer's decoder accepts malformed padding and trailing bits; the protocol requires canonical base64.
|
|
59
|
+
if (bytes.toString('base64') !== data) {
|
|
60
|
+
throw new Error('The generated image contains invalid base64');
|
|
61
|
+
}
|
|
62
|
+
const info = imageInfo(bytes);
|
|
63
|
+
if (info === null || (info.width !== undefined && info.width === 0) || (info.height !== undefined && info.height === 0)) {
|
|
64
|
+
throw new Error('The generated bytes are not a supported image');
|
|
65
|
+
}
|
|
66
|
+
const stem = (prompt ?? '')
|
|
67
|
+
.normalize('NFKD')
|
|
68
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
69
|
+
.replace(/^-|-$/g, '')
|
|
70
|
+
.slice(0, 80)
|
|
71
|
+
.toLowerCase() || 'generated-image';
|
|
72
|
+
const upload = { name: `${stem}.${info.extension}`, mime: info.mime, data };
|
|
73
|
+
return { upload, ...(info.width === undefined ? {} : { width: info.width }), ...(info.height === undefined ? {} : { height: info.height }) };
|
|
74
|
+
}
|
package/dist/chat/projector.js
CHANGED
|
@@ -150,7 +150,7 @@ export class ThreadProjector {
|
|
|
150
150
|
break;
|
|
151
151
|
case 'text.delta':
|
|
152
152
|
this.closeThinking(events);
|
|
153
|
-
this.appendText(this.itemId(generation, event.ref), event.text, events);
|
|
153
|
+
this.appendText(this.itemId(generation, event.ref), event.text, events, event.phase);
|
|
154
154
|
break;
|
|
155
155
|
case 'text.done': {
|
|
156
156
|
if (event.parentRef) {
|
|
@@ -168,6 +168,12 @@ export class ThreadProjector {
|
|
|
168
168
|
createdAt: existing?.createdAt ?? this.now(),
|
|
169
169
|
turnId: existing?.turnId ?? info.activeTurnId,
|
|
170
170
|
text: event.text,
|
|
171
|
+
...(existing?.kind === 'assistant' ? { uiAnswers: existing.uiAnswers, uiQueries: existing.uiQueries } : {}),
|
|
172
|
+
...(event.phase === undefined
|
|
173
|
+
? existing?.kind === 'assistant' && existing.phase !== undefined
|
|
174
|
+
? { phase: existing.phase }
|
|
175
|
+
: {}
|
|
176
|
+
: { phase: event.phase }),
|
|
171
177
|
streaming: false
|
|
172
178
|
}));
|
|
173
179
|
break;
|
|
@@ -452,7 +458,7 @@ export class ThreadProjector {
|
|
|
452
458
|
events.push(this.thread.upsert({ ...item, streaming: false, endedAt: this.now() }));
|
|
453
459
|
}
|
|
454
460
|
}
|
|
455
|
-
appendText(id, text, events) {
|
|
461
|
+
appendText(id, text, events, phase) {
|
|
456
462
|
const existing = this.thread.get(id);
|
|
457
463
|
if (!existing) {
|
|
458
464
|
events.push(this.thread.upsert({
|
|
@@ -461,6 +467,7 @@ export class ThreadProjector {
|
|
|
461
467
|
createdAt: this.now(),
|
|
462
468
|
turnId: this.thread.info.activeTurnId,
|
|
463
469
|
text,
|
|
470
|
+
...(phase === undefined ? {} : { phase }),
|
|
464
471
|
streaming: true
|
|
465
472
|
}));
|
|
466
473
|
return;
|
package/dist/chat/thread.js
CHANGED
|
@@ -82,6 +82,12 @@ export class ChatThread {
|
|
|
82
82
|
break;
|
|
83
83
|
case 'delta':
|
|
84
84
|
this.appendText(event.itemId, event.text);
|
|
85
|
+
if (event.uiQueries) {
|
|
86
|
+
const item = this.items.get(event.itemId);
|
|
87
|
+
if (item?.kind === 'assistant') {
|
|
88
|
+
this.items.set(item.id, { ...item, uiQueries: event.uiQueries });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
85
91
|
break;
|
|
86
92
|
case 'info':
|
|
87
93
|
this.info = event.info;
|