@agentshouse/kit 0.2.1-c0b0ffa6c025b → 0.2.1-c0eea939ba749

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,22 +1,50 @@
1
- import { client, } from '@agentclientprotocol/sdk';
1
+ import { client, RequestError, } from '@agentclientprotocol/sdk';
2
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
6
  import { startAdapter } from './acp.js';
7
+ import { finalRefusal } from './api.js';
7
8
  import { openBridge } from './bridge.js';
8
9
  import { CLIS } from './clis.js';
9
- import { readerFor } from './events.js';
10
+ import { answeredFailure, readerFor } from './events.js';
10
11
  import { placeFiles, uploadBytes } from './files.js';
11
12
  import { agentBase } from './home.js';
12
13
  import { instructions } from './instructions.js';
13
14
  import { Parts } from './parts.js';
15
+ import { refusalOf } from './refusals.js';
14
16
  import { killMarked, Scope } from './scope.js';
15
17
  import { holdSecretInput } from './secret-input.js';
16
18
  import { createHowWeWork } from './skills.js';
17
19
  import { transcribe } from './transcription.js';
18
20
  import { workFor } from './work.js';
21
+ const SIGNED_OUT = RequestError.authRequired().code;
19
22
  const KILLED = 'the conversation was killed';
23
+ const REFUSED = 'the CLI refused the prompt';
24
+ const NO_CONNECTION = "the Agent's computer could not open this conversation; try again";
25
+ const GONE = 'House no longer holds this conversation; start a new one';
26
+ const RETIRED = "this conversation's Agent is retired, so it cannot start";
27
+ const ENDED = {
28
+ conversation_not_found: GONE,
29
+ conversation_historical: RETIRED,
30
+ };
31
+ const NO_START = "the Agent's CLI did not start";
32
+ const NO_SESSION = "the Agent's CLI did not open a session";
33
+ const NO_RESUME = "the Agent's CLI could not resume this conversation";
34
+ const NO_SETTINGS = "the Agent's CLI refused the Agent's model, mode or effort";
35
+ class NotStarted extends Error {
36
+ }
37
+ async function step(reason, work) {
38
+ try {
39
+ return await work;
40
+ }
41
+ catch (error) {
42
+ throw new NotStarted(typeof reason === 'string' ? reason : reason(error), { cause: error });
43
+ }
44
+ }
45
+ function unbridged(error) {
46
+ return (finalRefusal(error) ? ENDED[refusalOf(error.text)?.code ?? ''] : undefined) ?? NO_CONNECTION;
47
+ }
20
48
  const EXIT_PLAN_MODE = {
21
49
  parse(value) {
22
50
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
@@ -29,19 +57,32 @@ export const CONVERSATION_INPUTS = new Set(['open', 'message', 'interrupt', 'kil
29
57
  function causeOf(error) {
30
58
  return error instanceof Error ? error.message : String(error);
31
59
  }
60
+ function answeredWith(error) {
61
+ const signedOut = typeof error === 'object' && error !== null && error.code === SIGNED_OUT;
62
+ return { failure: signedOut ? 'signed_out' : 'error', cause: causeOf(error) };
63
+ }
64
+ function exitedWith(running, cause) {
65
+ return running.killed ? { failure: 'killed', cause: KILLED } : { failure: 'exited', cause };
66
+ }
32
67
  function answerMessage(request, response) {
33
68
  return `The User answered a question you asked earlier.\nQuestion: ${JSON.stringify(request)}\nAnswer: ${JSON.stringify(response)}`;
34
69
  }
35
70
  function logged(error) {
36
71
  process.stderr.write(`kit: ${causeOf(error)}\n`);
37
72
  }
73
+ function refusal(conversation, error) {
74
+ if (conversation.kills > 0)
75
+ return KILLED;
76
+ const cause = error instanceof NotStarted ? `${error.message}: ${causeOf(error.cause)}` : causeOf(error);
77
+ logged(new Error(`conversation ${conversation.id} did not start: ${cause}`));
78
+ return causeOf(error);
79
+ }
38
80
  function marker(bridge) {
39
81
  return `HOUSE_BRIDGE=${bridge.env.HOUSE_BRIDGE}`;
40
82
  }
41
83
  class Turn {
42
84
  id = randomUUID();
43
85
  parts;
44
- admitted = false;
45
86
  questions = 0;
46
87
  secrets = 0;
47
88
  prompted = false;
@@ -65,6 +106,7 @@ class Conversation {
65
106
  subagents = new Map();
66
107
  held = [];
67
108
  opening = null;
109
+ prompted = null;
68
110
  kills = 0;
69
111
  killed = new AbortController();
70
112
  interrupts = 0;
@@ -87,9 +129,7 @@ function asksSecret(request) {
87
129
  export class Conversations {
88
130
  kit;
89
131
  conversations = new Map();
90
- acks = new Map();
91
132
  questions = new Map();
92
- carrying = 0;
93
133
  reporting = 0;
94
134
  watched = false;
95
135
  constructor(kit) {
@@ -98,37 +138,26 @@ export class Conversations {
98
138
  setInterval(() => this.look(), 2_000);
99
139
  }
100
140
  input(input) {
101
- this.carrying++;
102
- const carried = this.acks.get(input.input_id) ??
103
- (() => {
104
- const conversation = this.conversation(input.conversation_id);
105
- if (input.kind === 'kill') {
106
- conversation.kills++;
107
- conversation.killed.abort(new Error(KILLED));
108
- for (const waiting of conversation.waiting.splice(0))
109
- waiting.settle({ refused: KILLED });
110
- this.halt(conversation);
111
- }
112
- if (input.kind === 'interrupt') {
113
- conversation.interrupts++;
114
- conversation.interrupted.abort();
115
- }
116
- const handled = conversation.queue.then(() => this.carry(conversation, input));
117
- conversation.queue = handled.catch(() => undefined);
118
- const work = handled.then(({ ack }) => ack);
119
- this.acks.set(input.input_id, work);
120
- return work;
121
- })();
122
- carried
123
- .then((body) => this.kit.house.deliver(`/kit/inputs/${input.input_id}/ack`, body))
124
- .catch(logged)
125
- .finally(() => {
126
- this.carrying--;
127
- this.kit.changed();
141
+ void this.kit.inputs.carry(input.input_id, () => {
142
+ const conversation = this.conversation(input.conversation_id);
143
+ if (input.kind === 'kill') {
144
+ conversation.kills++;
145
+ conversation.killed.abort(new Error(KILLED));
146
+ for (const waiting of conversation.waiting.splice(0))
147
+ waiting.settle({ refused: KILLED });
148
+ this.halt(conversation);
149
+ }
150
+ if (input.kind === 'interrupt') {
151
+ conversation.interrupts++;
152
+ conversation.interrupted.abort();
153
+ }
154
+ const handled = conversation.queue.then(() => this.carry(conversation, input));
155
+ conversation.queue = handled.catch(() => undefined);
156
+ return handled.then(async ({ ack }) => ({ ack: await ack }));
128
157
  });
129
158
  }
130
159
  idle() {
131
- if (this.carrying > 0 || this.reporting > 0)
160
+ if (this.reporting > 0)
132
161
  return false;
133
162
  for (const conversation of this.conversations.values()) {
134
163
  const turn = conversation.turn;
@@ -208,10 +237,10 @@ export class Conversations {
208
237
  if (conversation.running !== null)
209
238
  return {};
210
239
  try {
211
- return { provider_session_id: await this.start(conversation, await this.route(conversation, input.agent_id), null) };
240
+ return { provider_session_id: await this.start(conversation, await this.route(conversation, input.agent_id), null, true) };
212
241
  }
213
242
  catch (error) {
214
- return { refused: causeOf(error) };
243
+ return { refused: refusal(conversation, error) };
215
244
  }
216
245
  }
217
246
  async message(conversation, input) {
@@ -228,7 +257,7 @@ export class Conversations {
228
257
  throw new Error(KILLED);
229
258
  }
230
259
  catch (error) {
231
- return { ack: Promise.resolve({ refused: conversation.kills > 0 ? KILLED : causeOf(error) }) };
260
+ return { ack: Promise.resolve({ refused: refusal(conversation, error) }) };
232
261
  }
233
262
  return {
234
263
  ack: new Promise((settle) => {
@@ -251,13 +280,17 @@ export class Conversations {
251
280
  async send(conversation, waiting) {
252
281
  const { input } = waiting;
253
282
  let ack = {};
283
+ let started = false;
254
284
  const prompt = [];
255
285
  try {
256
286
  if (conversation.running === null) {
257
- const resumed = input.provider_session_id ?? conversation.session;
258
- const opened = await this.start(conversation, waiting.route, resumed);
259
- if (resumed === null)
260
- ack = { provider_session_id: opened };
287
+ const stored = conversation.session ?? input.provider_session_id;
288
+ const empty = input.first === true && conversation.prompted !== stored;
289
+ started = (await this.start(conversation, waiting.route, stored, empty)) !== stored;
290
+ }
291
+ const session = conversation.running.sessionId;
292
+ if (started || (input.first === true && session !== input.provider_session_id)) {
293
+ ack = { provider_session_id: session };
261
294
  }
262
295
  if (input.first === true) {
263
296
  prompt.push({
@@ -269,11 +302,11 @@ export class Conversations {
269
302
  throw new Error(KILLED);
270
303
  }
271
304
  catch (error) {
272
- if ('provider_session_id' in ack) {
305
+ if (started) {
273
306
  conversation.session = null;
274
307
  await this.kill(conversation);
275
308
  }
276
- waiting.settle({ refused: conversation.kills > 0 ? KILLED : causeOf(error) });
309
+ waiting.settle({ refused: refusal(conversation, error) });
277
310
  return;
278
311
  }
279
312
  this.prompt(conversation, [...prompt, ...waiting.content]);
@@ -351,7 +384,7 @@ export class Conversations {
351
384
  }
352
385
  return route;
353
386
  }
354
- async start(conversation, route, sessionId) {
387
+ async start(conversation, route, sessionId, empty) {
355
388
  const reader = readerFor(route.kind);
356
389
  reader.session = sessionId;
357
390
  const app = client({ name: '@agentshouse/kit' })
@@ -359,7 +392,7 @@ export class Conversations {
359
392
  .onRequest('elicitation/create', ({ params, signal }) => this.ask(conversation, 'elicitation/create', params, asksSecret(params), signal))
360
393
  .onRequest('_x.ai/exit_plan_mode', EXIT_PLAN_MODE, ({ params, signal }) => this.ask(conversation, '_x.ai/exit_plan_mode', params, false, signal));
361
394
  const cli = await unlessAborted(this.kit.agents.cli(route.kind), conversation.killed.signal);
362
- const bridge = await openBridge(this.kit.house, this.kit.copies, conversation.id, conversation.killed.signal);
395
+ const bridge = await step(unbridged, openBridge(this.kit.house, this.kit.copies, conversation.id, conversation.killed.signal));
363
396
  if (conversation.kills > 0) {
364
397
  bridge.close();
365
398
  throw new Error(KILLED);
@@ -367,27 +400,28 @@ export class Conversations {
367
400
  conversation.opening = bridge;
368
401
  const work = workFor(route.kind, route.working_directory, () => this.kit.changed());
369
402
  const scope = new Scope(marker(bridge), randomUUID(), cli);
403
+ let adapter = null;
370
404
  try {
371
405
  await unlessAborted(createHowWeWork(bridge), conversation.killed.signal);
372
406
  await CLIS[route.kind].allowHouse(bridge.env.PATH);
373
- const adapter = await startAdapter(route.kind, cli, route.working_directory, app, (message) => {
407
+ const streamed = await step(NO_CONNECTION, work.open(cli));
408
+ adapter = await step(NO_START, startAdapter(route.kind, cli, route.working_directory, app, (message) => {
374
409
  work.read(message);
375
410
  this.read(conversation, reader, message);
376
- }, { ...bridge.env, ...(await work.open(cli)) }, undefined, scope);
411
+ }, { ...bridge.env, ...streamed }, undefined, scope));
377
412
  void adapter.exited.then(() => bridge.close());
378
413
  const agent = adapter.connection.agent;
379
414
  const cwd = route.working_directory;
380
415
  const _meta = CLIS[route.kind].sessionMeta(bridge.env.HOUSE_BRIDGE, conversation.id);
381
- const opened = sessionId === null
382
- ? await agent.request('session/new', { cwd, mcpServers: [], _meta })
383
- : { ...(await agent.request('session/resume', { sessionId, cwd, mcpServers: [], _meta })), sessionId };
416
+ const resumed = sessionId === null ? null : await this.resume(conversation, route.kind, adapter, { sessionId, cwd, mcpServers: [], _meta }, empty);
417
+ const opened = resumed ?? (await step(NO_SESSION, agent.request('session/new', { cwd, mcpServers: [], _meta })));
384
418
  reader.session = opened.sessionId;
385
- if (sessionId === null && CLIS[route.kind].renames) {
419
+ if (opened.sessionId !== sessionId && CLIS[route.kind].renames) {
386
420
  const prompt = [{ type: 'text', text: `/rename ${conversation.id}` }];
387
- await agent.request('session/prompt', { sessionId: opened.sessionId, prompt });
421
+ await step(NO_SESSION, agent.request('session/prompt', { sessionId: opened.sessionId, prompt }));
388
422
  }
389
423
  const running = { adapter, bridge, sessionId: opened.sessionId, killed: false, reader, work, scope };
390
- const options = await this.launchSettings(running, route, opened.configOptions ?? []);
424
+ const options = await step(NO_SETTINGS, this.launchSettings(running, route, opened.configOptions ?? []));
391
425
  void work.settled().then(() => scope.record());
392
426
  conversation.running = running;
393
427
  conversation.session = running.sessionId;
@@ -395,11 +429,11 @@ export class Conversations {
395
429
  if (conversation.commands !== null)
396
430
  this.commands(conversation, conversation.commands);
397
431
  this.options(conversation, options);
398
- void adapter.exited.then((cause) => this.exited(conversation, adapter, cause));
432
+ void adapter.lost.then((cause) => this.exited(conversation, running, cause));
399
433
  return running.sessionId;
400
434
  }
401
435
  catch (error) {
402
- scope.kill();
436
+ adapter?.retire();
403
437
  work.close();
404
438
  conversation.leftovers.add(scope);
405
439
  bridge.close();
@@ -409,6 +443,18 @@ export class Conversations {
409
443
  conversation.opening = null;
410
444
  }
411
445
  }
446
+ async resume(conversation, kind, adapter, request, empty) {
447
+ try {
448
+ return { ...(await adapter.connection.agent.request('session/resume', request)), sessionId: request.sessionId };
449
+ }
450
+ catch (error) {
451
+ if (!empty || conversation.kills > 0 || !CLIS[kind].unknownSession(error)) {
452
+ throw new NotStarted(NO_RESUME, { cause: error });
453
+ }
454
+ logged(new Error(`conversation ${conversation.id} opens a new session: ${NO_RESUME}: ${causeOf(error)}`));
455
+ return null;
456
+ }
457
+ }
412
458
  async launchSettings(running, route, offered) {
413
459
  let options = offered;
414
460
  const settings = [
@@ -430,13 +476,10 @@ export class Conversations {
430
476
  }
431
477
  return options;
432
478
  }
433
- exited(conversation, adapter, cause) {
434
- const running = conversation.running;
435
- if (running?.adapter !== adapter)
436
- return;
479
+ exited(conversation, running, cause) {
437
480
  this.detach(conversation, running);
438
481
  if (conversation.turn !== null) {
439
- this.end(conversation, conversation.turn, { failed: running.killed ? KILLED : cause });
482
+ this.end(conversation, conversation.turn, exitedWith(running, cause));
440
483
  }
441
484
  this.next(conversation);
442
485
  this.kit.changed();
@@ -475,7 +518,7 @@ export class Conversations {
475
518
  return delivered;
476
519
  }
477
520
  write(conversation, turn, operation) {
478
- if (!turn.admitted || turn.ended || !this.watched)
521
+ if (turn.ended || !this.watched)
479
522
  return;
480
523
  this.kit.send({ type: 'turn', conversation_id: conversation.id, turn_id: turn.id, ...operation });
481
524
  }
@@ -486,12 +529,7 @@ export class Conversations {
486
529
  begin(conversation) {
487
530
  const turn = new Turn((operation) => this.write(conversation, turn, operation));
488
531
  conversation.open++;
489
- void this.deliver(conversation, turn, 'started', {})
490
- .then(() => {
491
- turn.admitted = true;
492
- this.replay(conversation, turn);
493
- })
494
- .catch(logged);
532
+ void this.deliver(conversation, turn, 'started', {}).catch(logged);
495
533
  conversation.turn = turn;
496
534
  if (conversation.context !== null)
497
535
  turn.parts.measure(conversation.context);
@@ -500,13 +538,17 @@ export class Conversations {
500
538
  this.kit.changed();
501
539
  return turn;
502
540
  }
503
- end(conversation, turn, failed) {
541
+ end(conversation, turn, failed, notice = null) {
504
542
  if (turn.ended)
505
543
  return;
544
+ if (failed !== null)
545
+ logged(new Error(`conversation ${conversation.id} turn ${turn.id} failed: ${failed.cause}`));
506
546
  if (conversation.turn === turn) {
507
547
  for (const event of conversation.running?.reader.closeTurn() ?? [])
508
548
  this.apply(conversation, turn, event);
509
549
  }
550
+ if (notice !== null)
551
+ turn.parts.start({ type: 'marker', marker: 'retry', text: notice });
510
552
  turn.ended = true;
511
553
  conversation.open--;
512
554
  if (conversation.turn === turn)
@@ -514,7 +556,7 @@ export class Conversations {
514
556
  const body = Promise.allSettled(turn.uploads).then(() => ({
515
557
  parts: turn.parts.saved(),
516
558
  context: turn.parts.context,
517
- ...failed,
559
+ ...(failed === null ? {} : { failed: failed.failure }),
518
560
  }));
519
561
  void this.deliver(conversation, turn, 'ended', body).catch(logged);
520
562
  this.next(conversation);
@@ -524,16 +566,25 @@ export class Conversations {
524
566
  const running = conversation.running;
525
567
  const turn = this.begin(conversation);
526
568
  turn.prompted = true;
527
- running.adapter.connection.agent.request('session/prompt', { sessionId: running.sessionId, prompt }).then(() => this.end(conversation, turn, null), async (error) => {
569
+ conversation.prompted = running.sessionId;
570
+ running.adapter.connection.agent.request('session/prompt', { sessionId: running.sessionId, prompt }).then((answer) => {
571
+ const notice = answeredFailure(answer);
572
+ if (notice !== null) {
573
+ this.end(conversation, turn, { failure: notice.limit ? 'limit' : 'error', cause: notice.title }, notice.title);
574
+ }
575
+ else {
576
+ this.end(conversation, turn, answer.stopReason === 'refusal' ? { failure: 'refused', cause: REFUSED } : null);
577
+ }
578
+ }, async (error) => {
528
579
  if (running.adapter.connection.signal.aborted) {
529
- this.end(conversation, turn, { failed: running.killed ? KILLED : await running.adapter.exited });
580
+ this.end(conversation, turn, exitedWith(running, await running.adapter.exited));
530
581
  }
531
582
  else {
532
583
  if (conversation.open === 1 && conversation.running === running) {
533
- running.scope.kill();
584
+ running.adapter.retire();
534
585
  this.detach(conversation, running);
535
586
  }
536
- this.end(conversation, turn, { failed: causeOf(error) });
587
+ this.end(conversation, turn, answeredWith(error));
537
588
  }
538
589
  });
539
590
  }