@agentshouse/kit 0.2.1-alpha.9 → 0.2.1-c14f8446ba284

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