@agentshouse/kit 0.2.1-cd9cbd8b6428c → 0.2.1-cf669c6d1ecbb

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.
@@ -1,18 +1,21 @@
1
1
  import { client, } from '@agentclientprotocol/sdk';
2
- import { randomUUID } from 'node:crypto';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
3
  import { mkdir } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { unlessAborted } from './abort.js';
6
- import { killMarked, startAdapter, TURN_ENDED, TURN_STARTED } from './acp.js';
7
- import { blocksOf, firstChange } from './blocks.js';
6
+ import { startAdapter } from './acp.js';
8
7
  import { openBridge } from './bridge.js';
9
8
  import { CLIS } from './clis.js';
10
- import { placeFiles } from './files.js';
9
+ import { readerFor } from './events.js';
10
+ import { placeFiles, uploadBytes } from './files.js';
11
11
  import { agentBase } from './home.js';
12
12
  import { instructions } from './instructions.js';
13
+ import { Parts } from './parts.js';
14
+ import { killMarked, Scope } from './scope.js';
13
15
  import { holdSecretInput } from './secret-input.js';
14
16
  import { createHowWeWork } from './skills.js';
15
17
  import { transcribe } from './transcription.js';
18
+ import { workFor } from './work.js';
16
19
  const KILLED = 'the conversation was killed';
17
20
  function causeOf(error) {
18
21
  return error instanceof Error ? error.message : String(error);
@@ -23,33 +26,35 @@ function answerMessage(request, response) {
23
26
  function logged(error) {
24
27
  process.stderr.write(`kit: ${causeOf(error)}\n`);
25
28
  }
26
- function stop(bridge) {
27
- killMarked(`HOUSE_BRIDGE=${bridge.env.HOUSE_BRIDGE}`);
29
+ function marker(bridge) {
30
+ return `HOUSE_BRIDGE=${bridge.env.HOUSE_BRIDGE}`;
28
31
  }
29
32
  class Turn {
30
33
  id = randomUUID();
31
- text = '';
32
- segment = null;
34
+ parts;
33
35
  admitted = false;
34
- notes = [];
35
- sent = [];
36
- unsentFrom = null;
37
- draftSequence = 0;
38
- planSequence = 0;
39
- entries = null;
40
36
  questions = 0;
41
37
  secrets = 0;
42
- prompt = null;
38
+ prompted = false;
43
39
  ended = false;
40
+ images = 0;
41
+ uploads = [];
42
+ constructor(emit) {
43
+ this.parts = new Parts(emit);
44
+ }
44
45
  }
45
46
  class Conversation {
46
47
  id;
47
48
  running = null;
49
+ session = null;
48
50
  turn = null;
49
51
  commands = null;
50
- jobs = new Set();
51
- queued = [];
52
- late = [];
52
+ context = null;
53
+ leftovers = new Set();
54
+ waiting = [];
55
+ sending = null;
56
+ subagents = new Map();
57
+ held = [];
53
58
  opening = null;
54
59
  kills = 0;
55
60
  killed = new AbortController();
@@ -77,8 +82,11 @@ export class Conversations {
77
82
  questions = new Map();
78
83
  carrying = 0;
79
84
  reporting = 0;
85
+ watched = false;
80
86
  constructor(kit) {
81
87
  this.kit = kit;
88
+ // A process or a durable schedule ends without a report, so the Kit looks at both again every two seconds.
89
+ setInterval(() => this.look(), 2_000);
82
90
  }
83
91
  input(input) {
84
92
  this.carrying++;
@@ -88,14 +96,17 @@ export class Conversations {
88
96
  if (input.kind === 'kill') {
89
97
  conversation.kills++;
90
98
  conversation.killed.abort(new Error(KILLED));
99
+ for (const waiting of conversation.waiting.splice(0))
100
+ waiting.settle({ refused: KILLED });
91
101
  this.halt(conversation);
92
102
  }
93
103
  if (input.kind === 'interrupt') {
94
104
  conversation.interrupts++;
95
105
  conversation.interrupted.abort();
96
106
  }
97
- const work = conversation.queue.then(() => this.carry(conversation, input));
98
- conversation.queue = work.catch(() => undefined);
107
+ const handled = conversation.queue.then(() => this.carry(conversation, input));
108
+ conversation.queue = handled.catch(() => undefined);
109
+ const work = handled.then(({ ack }) => ack);
99
110
  this.acks.set(input.input_id, work);
100
111
  return work;
101
112
  })();
@@ -113,15 +124,39 @@ export class Conversations {
113
124
  for (const conversation of this.conversations.values()) {
114
125
  const turn = conversation.turn;
115
126
  const asking = turn !== null && turn.questions > 0 && turn.secrets === 0;
116
- if (conversation.jobs.size > 0 || conversation.open > (asking ? 1 : 0))
127
+ if (conversation.open > (asking ? 1 : 0))
117
128
  return false;
118
129
  }
119
- return true;
130
+ const conversations = [...this.conversations.values()];
131
+ if (conversations.some((conversation) => conversation.running?.work.busy()))
132
+ return false;
133
+ return !conversations.some((conversation) => conversation.running?.scope.working() || [...conversation.leftovers].some((scope) => scope.working()));
134
+ }
135
+ quiet() {
136
+ return this.idle() && [...this.conversations.values()].every((conversation) => conversation.open === 0);
137
+ }
138
+ look() {
139
+ for (const conversation of this.conversations.values()) {
140
+ for (const scope of conversation.leftovers) {
141
+ if (!scope.empty())
142
+ continue;
143
+ scope.close();
144
+ conversation.leftovers.delete(scope);
145
+ }
146
+ }
147
+ this.kit.changed();
120
148
  }
121
149
  opened() {
150
+ this.watched = false;
151
+ }
152
+ watching(watched) {
153
+ const resend = watched && !this.watched;
154
+ this.watched = watched;
155
+ if (!resend)
156
+ return;
122
157
  for (const conversation of this.conversations.values()) {
123
158
  if (conversation.turn !== null)
124
- conversation.turn.unsentFrom = 0;
159
+ this.replay(conversation, conversation.turn);
125
160
  }
126
161
  }
127
162
  conversation(id) {
@@ -134,7 +169,7 @@ export class Conversations {
134
169
  }
135
170
  async carry(conversation, input) {
136
171
  if (input.kind === 'open')
137
- return this.open(conversation, input);
172
+ return { ack: Promise.resolve(await this.open(conversation, input)) };
138
173
  if (input.kind === 'message')
139
174
  return this.message(conversation, input);
140
175
  if (input.kind === 'interrupt') {
@@ -144,6 +179,7 @@ export class Conversations {
144
179
  }
145
180
  if (input.kind === 'kill') {
146
181
  await this.kill(conversation);
182
+ await conversation.sending;
147
183
  if (--conversation.kills === 0)
148
184
  conversation.killed = new AbortController();
149
185
  }
@@ -152,11 +188,12 @@ export class Conversations {
152
188
  if (input.kind === 'answer') {
153
189
  const question = this.questions.get(String(input.interaction_id));
154
190
  if (question === undefined) {
155
- return this.message(conversation, { ...input, text: answerMessage(input.request, input.response), files: [], first: false });
191
+ const text = answerMessage(input.request, input.response);
192
+ return this.message(conversation, { ...input, text, files: [], first: false });
156
193
  }
157
194
  question.answer(input.response);
158
195
  }
159
- return {};
196
+ return { ack: Promise.resolve({}) };
160
197
  }
161
198
  async open(conversation, input) {
162
199
  if (conversation.running !== null)
@@ -169,37 +206,69 @@ export class Conversations {
169
206
  }
170
207
  }
171
208
  async message(conversation, input) {
172
- let ack = {};
173
- const prompt = [];
209
+ const content = [{ type: 'text', text: String(input.text) }];
210
+ let route;
174
211
  try {
175
- const route = await this.route(conversation, input.agent_id);
212
+ route = await this.route(conversation, input.agent_id);
176
213
  const files = input.files;
177
214
  const paths = await placeFiles(this.kit.house, route.working_directory, files, conversation.killed.signal);
178
215
  const lines = await this.transcribed(conversation, files, paths);
216
+ if (lines.length > 0)
217
+ content.push({ type: 'text', text: lines.join('\n') });
218
+ if (conversation.kills > 0)
219
+ throw new Error(KILLED);
220
+ }
221
+ catch (error) {
222
+ return { ack: Promise.resolve({ refused: conversation.kills > 0 ? KILLED : causeOf(error) }) };
223
+ }
224
+ return {
225
+ ack: new Promise((settle) => {
226
+ conversation.waiting.push({ input, route, content, settle });
227
+ this.next(conversation);
228
+ }),
229
+ };
230
+ }
231
+ next(conversation) {
232
+ if (conversation.sending !== null || conversation.open > 0)
233
+ return;
234
+ const waiting = conversation.waiting.shift();
235
+ if (waiting === undefined)
236
+ return;
237
+ conversation.sending = this.send(conversation, waiting).finally(() => {
238
+ conversation.sending = null;
239
+ this.next(conversation);
240
+ });
241
+ }
242
+ async send(conversation, waiting) {
243
+ const { input } = waiting;
244
+ let ack = {};
245
+ const prompt = [];
246
+ try {
179
247
  if (conversation.running === null) {
180
- const opened = await this.start(conversation, route, input.provider_session_id);
181
- if (input.provider_session_id === null)
248
+ const resumed = input.provider_session_id ?? conversation.session;
249
+ const opened = await this.start(conversation, waiting.route, resumed);
250
+ if (resumed === null)
182
251
  ack = { provider_session_id: opened };
183
252
  }
184
253
  if (input.first === true) {
185
254
  prompt.push({
186
255
  type: 'text',
187
- text: await instructions(conversation.running.bridge, route.base_instructions, await this.kit.copies.mapping()),
256
+ text: await instructions(conversation.running.bridge, waiting.route.base_instructions, await this.kit.copies.mapping()),
188
257
  });
189
258
  }
190
- prompt.push({ type: 'text', text: String(input.text) });
191
- if (lines.length > 0)
192
- prompt.push({ type: 'text', text: lines.join('\n') });
193
259
  if (conversation.kills > 0)
194
260
  throw new Error(KILLED);
195
261
  }
196
262
  catch (error) {
197
- if ('provider_session_id' in ack)
263
+ if ('provider_session_id' in ack) {
264
+ conversation.session = null;
198
265
  await this.kill(conversation);
199
- return { refused: conversation.kills > 0 ? KILLED : causeOf(error) };
266
+ }
267
+ waiting.settle({ refused: conversation.kills > 0 ? KILLED : causeOf(error) });
268
+ return;
200
269
  }
201
- this.prompt(conversation, prompt);
202
- return ack;
270
+ this.prompt(conversation, [...prompt, ...waiting.content]);
271
+ waiting.settle(ack);
203
272
  }
204
273
  async transcribed(conversation, files, paths) {
205
274
  const lines = [];
@@ -209,7 +278,7 @@ export class Conversations {
209
278
  continue;
210
279
  try {
211
280
  const text = await transcribe(path, AbortSignal.any([conversation.killed.signal, conversation.interrupted.signal]));
212
- if (text !== '')
281
+ if (text !== null && text !== '')
213
282
  lines.push(`Transcript: ${text}`);
214
283
  }
215
284
  catch (error) {
@@ -229,11 +298,13 @@ export class Conversations {
229
298
  }
230
299
  halt(conversation) {
231
300
  if (conversation.opening !== null)
232
- stop(conversation.opening);
301
+ killMarked(marker(conversation.opening));
302
+ for (const scope of conversation.leftovers)
303
+ scope.kill();
233
304
  if (conversation.running === null)
234
305
  return;
235
306
  conversation.running.killed = true;
236
- stop(conversation.running.bridge);
307
+ conversation.running.scope.kill();
237
308
  }
238
309
  async kill(conversation) {
239
310
  const running = conversation.running;
@@ -245,6 +316,7 @@ export class Conversations {
245
316
  if (running === null)
246
317
  return;
247
318
  try {
319
+ running.reader.requested(option, value);
248
320
  const answered = await running.adapter.connection.agent.request('session/set_config_option', {
249
321
  sessionId: running.sessionId,
250
322
  configId: option,
@@ -253,6 +325,7 @@ export class Conversations {
253
325
  this.options(conversation, answered.configOptions);
254
326
  }
255
327
  catch (error) {
328
+ running.reader.refused(option);
256
329
  logged(error);
257
330
  }
258
331
  }
@@ -270,10 +343,9 @@ export class Conversations {
270
343
  return route;
271
344
  }
272
345
  async start(conversation, route, sessionId) {
346
+ const reader = readerFor(route.kind);
347
+ reader.session = sessionId;
273
348
  const app = client({ name: '@agentshouse/kit' })
274
- .onNotification('session/update', ({ params }) => this.update(conversation, params))
275
- .onNotification(TURN_STARTED, (params) => params, () => void this.started(conversation))
276
- .onNotification(TURN_ENDED, (params) => params, () => this.ended(conversation))
277
349
  .onRequest('session/request_permission', ({ params, signal }) => this.ask(conversation, 'session/request_permission', params, false, signal))
278
350
  .onRequest('elicitation/create', ({ params, signal }) => this.ask(conversation, 'elicitation/create', params, asksSecret(params), signal));
279
351
  const cli = await unlessAborted(this.kit.agents.cli(route.kind), conversation.killed.signal);
@@ -283,25 +355,32 @@ export class Conversations {
283
355
  throw new Error(KILLED);
284
356
  }
285
357
  conversation.opening = bridge;
358
+ const work = workFor(route.kind, route.working_directory, () => this.kit.changed());
359
+ const scope = new Scope(marker(bridge), randomUUID(), cli);
286
360
  try {
287
361
  await unlessAborted(createHowWeWork(bridge), conversation.killed.signal);
288
- const adapter = await startAdapter(route.kind, cli, route.working_directory, app, (job) => this.job(conversation, job), bridge.env);
362
+ await CLIS[route.kind].allowHouse(bridge.env.PATH);
363
+ const adapter = await startAdapter(route.kind, cli, route.working_directory, app, (message) => {
364
+ work.read(message);
365
+ this.read(conversation, reader, message);
366
+ }, { ...bridge.env, ...(await work.open(cli)) }, undefined, scope);
289
367
  void adapter.exited.then(() => bridge.close());
290
368
  const agent = adapter.connection.agent;
291
369
  const cwd = route.working_directory;
370
+ const _meta = CLIS[route.kind].sessionMeta(bridge.env.HOUSE_BRIDGE, conversation.id);
292
371
  const opened = sessionId === null
293
- ? await agent.request('session/new', { cwd, mcpServers: [] })
294
- : { ...(await agent.request('session/resume', { sessionId, cwd, mcpServers: [] })), sessionId };
295
- const running = {
296
- adapter,
297
- bridge,
298
- sessionId: opened.sessionId,
299
- killed: false,
300
- queues: CLIS[route.kind].queues,
301
- phase: CLIS[route.kind].phase,
302
- };
372
+ ? await agent.request('session/new', { cwd, mcpServers: [], _meta })
373
+ : { ...(await agent.request('session/resume', { sessionId, cwd, mcpServers: [], _meta })), sessionId };
374
+ reader.session = opened.sessionId;
375
+ if (sessionId === null && CLIS[route.kind].renames) {
376
+ const prompt = [{ type: 'text', text: `/rename ${conversation.id}` }];
377
+ await agent.request('session/prompt', { sessionId: opened.sessionId, prompt });
378
+ }
379
+ const running = { adapter, bridge, sessionId: opened.sessionId, killed: false, reader, work, scope };
303
380
  const options = await this.launchSettings(running, route, opened.configOptions ?? []);
381
+ void work.settled().then(() => scope.record());
304
382
  conversation.running = running;
383
+ conversation.session = running.sessionId;
305
384
  this.kit.send({ type: 'process', conversation_id: conversation.id, running: true });
306
385
  if (conversation.commands !== null)
307
386
  this.commands(conversation, conversation.commands);
@@ -310,7 +389,9 @@ export class Conversations {
310
389
  return running.sessionId;
311
390
  }
312
391
  catch (error) {
313
- stop(bridge);
392
+ scope.kill();
393
+ work.close();
394
+ conversation.leftovers.add(scope);
314
395
  bridge.close();
315
396
  throw conversation.kills > 0 ? new Error(KILLED) : error;
316
397
  }
@@ -321,6 +402,7 @@ export class Conversations {
321
402
  async launchSettings(running, route, offered) {
322
403
  let options = offered;
323
404
  const settings = [
405
+ ['mode', route.mode ?? CLIS[route.kind].fullAccess],
324
406
  ['model', route.model],
325
407
  ['thought_level', route.effort],
326
408
  ];
@@ -328,6 +410,7 @@ export class Conversations {
328
410
  if (value === null)
329
411
  continue;
330
412
  const option = options.find((candidate) => candidate.category === category);
413
+ running.reader.requested(option?.id ?? category, value);
331
414
  const answered = await running.adapter.connection.agent.request('session/set_config_option', {
332
415
  sessionId: running.sessionId,
333
416
  configId: option?.id ?? category,
@@ -341,19 +424,23 @@ export class Conversations {
341
424
  const running = conversation.running;
342
425
  if (running?.adapter !== adapter)
343
426
  return;
427
+ this.detach(conversation, running);
428
+ if (conversation.turn !== null) {
429
+ this.end(conversation, conversation.turn, { failed: running.killed ? KILLED : cause });
430
+ }
431
+ this.next(conversation);
432
+ this.kit.changed();
433
+ }
434
+ detach(conversation, running) {
435
+ running.work.close();
436
+ conversation.leftovers.add(running.scope);
344
437
  conversation.running = null;
345
438
  conversation.commands = null;
346
- conversation.jobs.clear();
347
- conversation.queued.length = 0;
348
439
  for (const [id, question] of this.questions) {
349
440
  if (question.conversation === conversation)
350
441
  this.questions.delete(id);
351
442
  }
352
443
  this.kit.send({ type: 'process', conversation_id: conversation.id, running: false });
353
- if (conversation.turn !== null) {
354
- this.end(conversation, conversation.turn, { failed: running.killed ? KILLED : cause });
355
- }
356
- this.kit.changed();
357
444
  }
358
445
  commands(conversation, commands) {
359
446
  conversation.commands = commands;
@@ -368,7 +455,7 @@ export class Conversations {
368
455
  }
369
456
  deliver(conversation, turn, event, body) {
370
457
  this.reporting++;
371
- const delivered = conversation.reports.then(() => this.kit.house.deliver(`/kit/conversations/${conversation.id}/turns/${turn.id}/${event}`, body));
458
+ const delivered = conversation.reports.then(async () => this.kit.house.deliver(`/kit/conversations/${conversation.id}/turns/${turn.id}/${event}`, await body));
372
459
  conversation.reports = delivered
373
460
  .catch(() => undefined)
374
461
  .finally(() => {
@@ -377,119 +464,82 @@ export class Conversations {
377
464
  });
378
465
  return delivered;
379
466
  }
380
- report(conversation) {
381
- const turn = new Turn();
382
- conversation.open++;
383
- void this.deliver(conversation, turn, 'started', {})
384
- .then(() => this.admitted(conversation, turn))
385
- .catch(logged);
386
- return turn;
467
+ write(conversation, turn, operation) {
468
+ if (!turn.admitted || turn.ended || !this.watched)
469
+ return;
470
+ this.kit.send({ type: 'turn', conversation_id: conversation.id, turn_id: turn.id, ...operation });
387
471
  }
388
- admitted(conversation, turn) {
389
- turn.admitted = true;
390
- for (const note of turn.notes.splice(0))
391
- this.kit.send(note);
392
- if (turn.text !== '') {
393
- turn.unsentFrom = 0;
394
- this.draft(conversation, turn);
395
- }
396
- if (turn.entries !== null)
397
- this.plan(conversation, turn);
472
+ replay(conversation, turn) {
473
+ for (const operation of turn.parts.operations())
474
+ this.write(conversation, turn, operation);
398
475
  }
399
476
  begin(conversation) {
400
- const turn = this.report(conversation);
477
+ const turn = new Turn((operation) => this.write(conversation, turn, operation));
478
+ conversation.open++;
479
+ void this.deliver(conversation, turn, 'started', {})
480
+ .then(() => {
481
+ turn.admitted = true;
482
+ this.replay(conversation, turn);
483
+ })
484
+ .catch(logged);
401
485
  conversation.turn = turn;
486
+ if (conversation.context !== null)
487
+ turn.parts.measure(conversation.context);
488
+ for (const part of conversation.held.splice(0))
489
+ turn.parts.start(part);
402
490
  this.kit.changed();
403
491
  return turn;
404
492
  }
405
- finish(conversation, turn) {
406
- if (turn.ended)
407
- return;
408
- const segment = turn.segment;
409
- turn.segment = null;
410
- if (segment?.phase === 'note')
411
- this.note(conversation, turn, segment.text);
412
- else if (segment !== null)
413
- this.answer(conversation, turn, segment.text);
414
- this.end(conversation, turn, { text: turn.text });
415
- }
416
- end(conversation, turn, outcome) {
493
+ end(conversation, turn, failed) {
417
494
  if (turn.ended)
418
495
  return;
496
+ if (conversation.turn === turn) {
497
+ for (const event of conversation.running?.reader.closeTurn() ?? [])
498
+ this.apply(conversation, turn, event);
499
+ }
419
500
  turn.ended = true;
420
501
  conversation.open--;
421
502
  if (conversation.turn === turn)
422
503
  conversation.turn = null;
423
- void this.deliver(conversation, turn, 'ended', outcome).catch(logged);
424
- const late = conversation.open === 0 ? conversation.late.shift() : undefined;
425
- if (late !== undefined)
426
- this.failed(conversation, late);
504
+ const body = Promise.allSettled(turn.uploads).then(() => ({
505
+ parts: turn.parts.saved(),
506
+ context: turn.parts.context,
507
+ ...failed,
508
+ }));
509
+ void this.deliver(conversation, turn, 'ended', body).catch(logged);
510
+ this.next(conversation);
427
511
  this.kit.changed();
428
512
  }
429
- failed(conversation, cause) {
430
- if (conversation.open > 0)
431
- conversation.late.push(cause);
432
- else
433
- this.end(conversation, this.report(conversation), { failed: cause });
434
- }
435
513
  prompt(conversation, prompt) {
436
514
  const running = conversation.running;
437
- const sent = { turn: null };
438
- if (running.queues && (conversation.turn !== null || conversation.queued.length > 0)) {
439
- conversation.queued.push(sent);
440
- }
441
- else {
442
- this.attach(conversation.turn ?? this.begin(conversation), sent);
443
- }
444
- running.adapter.connection.agent.request('session/prompt', { sessionId: running.sessionId, prompt }).then(() => this.answered(conversation, sent, null), async (error) => {
445
- if (!running.adapter.connection.signal.aborted)
446
- this.answered(conversation, sent, causeOf(error));
447
- else if (sent.turn !== null) {
448
- this.end(conversation, sent.turn, { failed: running.killed ? KILLED : await running.adapter.exited });
515
+ const turn = this.begin(conversation);
516
+ turn.prompted = true;
517
+ running.adapter.connection.agent.request('session/prompt', { sessionId: running.sessionId, prompt }).then(() => this.end(conversation, turn, null), async (error) => {
518
+ if (running.adapter.connection.signal.aborted) {
519
+ this.end(conversation, turn, { failed: running.killed ? KILLED : await running.adapter.exited });
520
+ }
521
+ else {
522
+ if (conversation.open === 1 && conversation.running === running) {
523
+ running.scope.kill();
524
+ this.detach(conversation, running);
525
+ }
526
+ this.end(conversation, turn, { failed: causeOf(error) });
449
527
  }
450
528
  });
451
529
  }
452
- attach(turn, sent) {
453
- turn.prompt = sent;
454
- sent.turn = turn;
455
- }
456
- answered(conversation, sent, failed) {
457
- const turn = sent.turn;
458
- if (turn === null) {
459
- const queued = conversation.queued.indexOf(sent);
460
- if (queued < 0)
461
- return;
462
- conversation.queued.splice(queued, 1);
463
- if (failed !== null)
464
- this.failed(conversation, failed);
465
- }
466
- else if (failed !== null) {
467
- if (turn.ended)
468
- this.failed(conversation, failed);
469
- else
470
- this.end(conversation, turn, { failed });
471
- }
472
- else if (turn.prompt === sent) {
473
- this.finish(conversation, turn);
474
- }
475
- }
476
530
  started(conversation) {
477
- if (conversation.turn !== null)
478
- return conversation.turn;
479
- const turn = this.begin(conversation);
480
- const queued = conversation.queued.shift();
481
- if (queued !== undefined)
482
- this.attach(turn, queued);
483
- return turn;
531
+ return conversation.turn ?? this.begin(conversation);
484
532
  }
485
533
  ended(conversation) {
486
534
  const turn = conversation.turn;
487
535
  if (turn === null)
488
536
  return;
489
- if (turn.prompt === null) {
490
- this.finish(conversation, turn);
537
+ if (!turn.prompted) {
538
+ this.end(conversation, turn, null);
491
539
  }
492
540
  else {
541
+ for (const event of conversation.running?.reader.closeTurn() ?? [])
542
+ this.apply(conversation, turn, event);
493
543
  conversation.turn = null;
494
544
  this.kit.changed();
495
545
  }
@@ -538,101 +588,102 @@ export class Conversations {
538
588
  if (content !== null)
539
589
  this.questions.get(interactionId)?.answer({ action: 'accept', content });
540
590
  }
541
- update(conversation, notification) {
542
- const update = notification.update;
543
- if (update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text') {
544
- this.chunk(conversation, this.started(conversation), update, update.content.text);
545
- }
546
- else if (update.sessionUpdate === 'plan') {
547
- const turn = this.started(conversation);
548
- this.close(conversation, turn);
549
- turn.entries = update.entries;
550
- this.plan(conversation, turn);
551
- }
552
- else if (update.sessionUpdate === 'tool_call') {
553
- this.close(conversation, this.started(conversation));
554
- }
555
- else if (update.sessionUpdate === 'agent_thought_chunk') {
556
- const turn = this.started(conversation);
557
- if (turn.segment?.messageId !== (update.messageId ?? null))
558
- this.close(conversation, turn);
559
- }
560
- else if (update.sessionUpdate === 'available_commands_update') {
561
- this.commands(conversation, update.availableCommands);
562
- }
563
- else if (update.sessionUpdate === 'config_option_update') {
564
- this.options(conversation, update.configOptions);
565
- }
591
+ read(conversation, reader, message) {
592
+ for (const event of reader.read(message))
593
+ this.event(conversation, event);
566
594
  }
567
- job(conversation, job) {
568
- if (job.running)
569
- conversation.jobs.add(job.id);
570
- else
571
- conversation.jobs.delete(job.id);
572
- this.kit.changed();
595
+ event(conversation, event) {
596
+ switch (event.kind) {
597
+ case 'started':
598
+ this.started(conversation);
599
+ return;
600
+ case 'ended':
601
+ this.ended(conversation);
602
+ return;
603
+ case 'commands':
604
+ this.commands(conversation, event.commands);
605
+ return;
606
+ case 'options':
607
+ this.options(conversation, event.options);
608
+ return;
609
+ case 'context':
610
+ conversation.context = { used: event.used, window: event.window };
611
+ conversation.turn?.parts.measure(conversation.context);
612
+ return;
613
+ case 'subagent-done':
614
+ this.subagentDone(conversation, event.id, event.result);
615
+ return;
616
+ case 'marker':
617
+ case 'image':
618
+ if (conversation.turn !== null)
619
+ break;
620
+ if (event.kind === 'marker' && event.marker === 'job')
621
+ conversation.held.push({ type: 'marker', marker: 'job', text: event.text });
622
+ return;
623
+ }
624
+ this.apply(conversation, this.started(conversation), event);
573
625
  }
574
- chunk(conversation, turn, chunk, text) {
575
- const phase = conversation.running?.phase(chunk) ?? null;
576
- const messageId = chunk.messageId ?? null;
577
- if (turn.segment?.messageId !== messageId || turn.segment.phase !== phase)
578
- this.close(conversation, turn);
579
- if (phase === 'answer') {
580
- this.answer(conversation, turn, text);
581
- return;
626
+ apply(conversation, turn, event) {
627
+ switch (event.kind) {
628
+ case 'text':
629
+ case 'reasoning':
630
+ turn.parts.write(event.kind, event.id, event.text);
631
+ return;
632
+ case 'command':
633
+ turn.parts.command();
634
+ return;
635
+ case 'subagent': {
636
+ const index = turn.parts.start({ type: 'subagent', description: event.description, status: 'running', result: null });
637
+ conversation.subagents.set(event.id, { description: event.description, turn, index });
638
+ return;
639
+ }
640
+ case 'message':
641
+ turn.parts.start({ type: 'message', command: event.command, target: event.target });
642
+ return;
643
+ case 'marker':
644
+ turn.parts.start({ type: 'marker', marker: event.marker, text: event.text });
645
+ return;
646
+ case 'plan':
647
+ turn.parts.plan(event.entries);
648
+ return;
649
+ case 'image':
650
+ turn.uploads.push(this.image(conversation, turn, turn.parts.reserve(), event.data, event.mediaType));
651
+ this.kit.changed();
652
+ return;
582
653
  }
583
- turn.segment ??= { messageId, phase, text: '' };
584
- turn.segment.text += text;
585
654
  }
586
- close(conversation, turn) {
587
- const segment = turn.segment;
588
- if (segment === null)
655
+ subagentDone(conversation, id, result) {
656
+ const subagent = conversation.subagents.get(id);
657
+ if (subagent === undefined)
589
658
  return;
590
- turn.segment = null;
591
- this.note(conversation, turn, segment.text);
592
- }
593
- note(conversation, turn, text) {
594
- if (text.trim() === '')
659
+ conversation.subagents.delete(id);
660
+ if (subagent.turn === conversation.turn) {
661
+ subagent.turn.parts.done(subagent.index, result);
595
662
  return;
596
- const note = { type: 'working-note', conversation_id: conversation.id, note_id: randomUUID(), text };
597
- if (turn.admitted)
598
- this.kit.send(note);
663
+ }
664
+ const part = { type: 'subagent', description: subagent.description, status: 'done', result };
665
+ if (conversation.turn === null)
666
+ conversation.held.push(part);
599
667
  else
600
- turn.notes.push(note);
601
- }
602
- answer(conversation, turn, text) {
603
- turn.text += text;
604
- this.draft(conversation, turn);
605
- }
606
- draft(conversation, turn) {
607
- const blocks = blocksOf(turn.text);
608
- const from = Math.min(firstChange(turn.sent, blocks), turn.unsentFrom ?? Infinity);
609
- const sent = this.kit.send({
610
- type: 'draft',
611
- conversation_id: conversation.id,
612
- turn_id: turn.id,
613
- sequence: turn.draftSequence + 1,
614
- from,
615
- blocks: blocks.slice(from),
616
- });
617
- if (sent) {
618
- turn.draftSequence++;
619
- turn.sent = blocks;
620
- turn.unsentFrom = null;
668
+ conversation.turn.parts.start(part);
669
+ }
670
+ async image(conversation, turn, reserved, data, mediaType) {
671
+ const bytes = Buffer.from(data, 'base64');
672
+ const name = `image-${++turn.images}.${mediaType.split('/')[1]}`;
673
+ try {
674
+ const declared = (await this.deliver(conversation, turn, 'images', {
675
+ name,
676
+ media_type: mediaType,
677
+ bytes: bytes.length,
678
+ sha256: createHash('sha256').update(bytes).digest('hex'),
679
+ }));
680
+ const saved = declared.upload === undefined ? declared : await uploadBytes(declared.upload, bytes);
681
+ if (saved.save.status !== 'saved')
682
+ throw new Error(saved.save.failure ?? saved.save.status);
683
+ turn.parts.arrive({ type: 'image', file: saved.version, name, media_type: mediaType }, reserved);
621
684
  }
622
- else {
623
- turn.unsentFrom = from;
685
+ catch (error) {
686
+ turn.parts.arrive({ type: 'marker', marker: 'retry', text: `${name} was not saved: ${causeOf(error)}` }, reserved);
624
687
  }
625
688
  }
626
- plan(conversation, turn) {
627
- const sent = this.kit.send({
628
- type: 'plan',
629
- conversation_id: conversation.id,
630
- turn_id: turn.id,
631
- sequence: turn.planSequence + 1,
632
- from: 0,
633
- steps: turn.entries.map((entry) => ({ label: entry.content, priority: entry.priority, status: entry.status })),
634
- });
635
- if (sent)
636
- turn.planSequence++;
637
- }
638
689
  }