@capybara-hq/kernel 0.1.0-rc.16 → 0.1.0-rc.17

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.
@@ -188,7 +188,7 @@ export declare function headsToResolve(entries: readonly MarketplaceEntry[], imp
188
188
  /** The host versions (an entry's `minKernel` is judged against them). */
189
189
  export declare const HOST_VERSIONS: {
190
190
  readonly kernel: string;
191
- readonly sdk: "0.1.0-rc.12";
191
+ readonly sdk: "0.1.0-rc.13";
192
192
  };
193
193
  /** Whether this deployment satisfies the entry's `minKernel`. */
194
194
  export declare const entryCompatible: (e: Pick<MarketplaceEntry, "minKernel">) => boolean;
@@ -59,7 +59,11 @@ export declare function approvalCard(d: CardDeps, approvalId: string, base: stri
59
59
  /** 答案摘要:每个字段的值(多选用逗号连接),多个字段用分号分隔。 */
60
60
  export declare function answerSummary(answer: FormAnswer | null): string | undefined;
61
61
  /** 提问卡片。不能在 IM 内作答的表单(`questionRenderableInline` 为 false)由渠道只渲染网页链接。 */
62
- export declare function questionCard(questionId: string, form: InputForm, state: "pending" | "answered" | "expired" | "cancelled", webUrl: string, answer?: FormAnswer | null): Extract<ChannelCard, {
62
+ export declare function questionCard(questionId: string, form: InputForm, state: Extract<ChannelCard, {
63
+ kind: "question";
64
+ }>["state"], webUrl: string, answer?: FormAnswer | null,
65
+ /** A pending card of the delivered conversation's own run: a text reply also works (§10.2). */
66
+ textReply?: "plain" | "mention"): Extract<ChannelCard, {
63
67
  kind: "question";
64
68
  }>;
65
69
  /** 一条只带一张卡片的消息(随附文本为固定话术)。 */
@@ -210,7 +210,9 @@ export function answerSummary(answer) {
210
210
  return parts.length === 0 ? undefined : parts.join("; ");
211
211
  }
212
212
  /** 提问卡片。不能在 IM 内作答的表单(`questionRenderableInline` 为 false)由渠道只渲染网页链接。 */
213
- export function questionCard(questionId, form, state, webUrl, answer = null) {
213
+ export function questionCard(questionId, form, state, webUrl, answer = null,
214
+ /** A pending card of the delivered conversation's own run: a text reply also works (§10.2). */
215
+ textReply) {
214
216
  const summary = answerSummary(answer);
215
217
  return {
216
218
  kind: "question",
@@ -219,6 +221,7 @@ export function questionCard(questionId, form, state, webUrl, answer = null) {
219
221
  state,
220
222
  ...(summary !== undefined && { answerSummary: summary }),
221
223
  webUrl,
224
+ ...(state === "pending" && textReply !== undefined && { textReply }),
222
225
  };
223
226
  }
224
227
  /** 一条只带一张卡片的消息(随附文本为固定话术)。 */
@@ -894,7 +894,10 @@ class Delivery {
894
894
  await this.send({ text: phrase("handleOnWeb", this.locale) }, this.key(seq));
895
895
  return this.advance(seq);
896
896
  }
897
- const card = questionCard(questionId, form, "pending", this.questionUrl(this.base, child));
897
+ // the person can skip the conversation's own question by replying; a sub-agent's question
898
+ // stays answerable on its card only (reply-while-question spec §4.3)
899
+ const textReply = child === undefined ? (this.target.chatType === "dm" ? "plain" : "mention") : undefined;
900
+ const card = questionCard(questionId, form, "pending", this.questionUrl(this.base, child), null, textReply);
898
901
  await this.recordCard("question", card.id);
899
902
  const { messageId } = await this.send(cardMessage(phrase("questionTitle", this.locale), card), this.key(seq));
900
903
  return this.advance(seq, { card: { kind: "question", id: card.id, messageId } });
@@ -6,10 +6,12 @@ import type { TitleSource } from "../db/kernel-schema.js";
6
6
  import { ConversationDevices, type NewConversationDevice } from "../devices/conversation-devices.js";
7
7
  import { KernelDb } from "../infra/database/kernel-db.js";
8
8
  import { TransactionHost } from "../infra/database/transaction-host.js";
9
+ import { KernelEvents } from "../infra/events/kernel-events.js";
9
10
  import type { KernelLogger } from "../log.js";
10
11
  import { ProviderRegistry } from "../runtime/providers.js";
11
12
  import { type RenderedInvocation, SkillInvoker } from "../skills/invoke.js";
12
13
  import { ConnectorContent } from "../tools/connector-content.js";
14
+ import { type QuestionRecord, QuestionsService } from "../tools/questions.service.js";
13
15
  import { ConversationAccess } from "./access.js";
14
16
  import { type AgentTreeNode } from "./agent-tree.js";
15
17
  import { AttachmentsService } from "./attachments.service.js";
@@ -207,6 +209,9 @@ export declare class ConversationsService {
207
209
  private readonly background;
208
210
  /** The device a new conversation is bound to (environments spec §4.1 item 1). */
209
211
  private readonly devices;
212
+ /** A starter's message skips the run's pending questions (reply-while-question spec §8.1). */
213
+ private readonly questions;
214
+ private readonly events;
210
215
  readonly txHost: TransactionHost;
211
216
  constructor(db: KernelDb, eventLog: EventLog, agents: Pick<AgentLookup, "get">, access: Pick<ConversationAccess, "isAccessible">,
212
217
  /** Starts and stops the conversation's runs. */
@@ -222,7 +227,9 @@ export declare class ConversationsService {
222
227
  /** Stops a deleted conversation's background tasks. */
223
228
  background: Pick<BackgroundTaskStopper, "stopForDeletedConversation">,
224
229
  /** The device a new conversation is bound to (environments spec §4.1 item 1). */
225
- devices: Pick<ConversationDevices, "forNew">, txHost: TransactionHost);
230
+ devices: Pick<ConversationDevices, "forNew">,
231
+ /** A starter's message skips the run's pending questions (reply-while-question spec §8.1). */
232
+ questions: Pick<QuestionsService, "skipPendingIn">, events: Pick<KernelEvents, "emit">, txHost: TransactionHost);
226
233
  /**
227
234
  * 不阻塞地调用一次 `ensureRun`(打开 SSE、读事件时):它幂等,并顺带清理孤儿运行,
228
235
  * 会话不会因为某次运行结束后的投递失败而永远停在「运行中」。失败只记日志。
@@ -408,10 +415,27 @@ export declare class ConversationsService {
408
415
  * queue re-plans it against the selection in effect at dispatch.
409
416
  */
410
417
  checkPick(orgId: string, conv: ConversationView, accountId: string, pick: Pick<UserMessageInput, "model" | "effort">): Promise<void>;
418
+ /**
419
+ * Skips, in the caller's transaction (it wrote `accountId`'s message at `messageSeq` and holds the
420
+ * conversation row lock), the pending questions that message closes (reply-while-question spec
421
+ * §8.1). The queue's "send now" uses it as the direct send does; announce after the commit with
422
+ * {@link announceSkipped}.
423
+ */
424
+ skipQuestionsIn(conversationId: string, accountId: string, messageSeq: number): Promise<{
425
+ records: QuestionRecord[];
426
+ toSeq: number | null;
427
+ }>;
428
+ /**
429
+ * After the commit: announces each skipped question (`question.answered`, outcome `skipped`), so
430
+ * its run resumes (waits) and its IM card updates (channel jobs).
431
+ */
432
+ announceSkipped(records: readonly QuestionRecord[]): Promise<void>;
411
433
  /**
412
434
  * Writes the message (preceded by its `model_switched` when it switches) inside `t` and updates
413
435
  * `conversations.selection`; returns the events written, in seq order (the `user_message`
414
- * last). The caller notifies after commit.
436
+ * last). A message this call newly wrote (not a retry's deduped one) then skips the pending
437
+ * questions its author may answer (reply-while-question spec §8.1): `skipped` and the highest
438
+ * seq their events took. The caller notifies and emits `question.answered` after commit.
415
439
  */
416
440
  private writeMessage;
417
441
  /**
@@ -18,12 +18,14 @@ import { ApiError } from "../errors.js";
18
18
  import { KernelDb } from "../infra/database/kernel-db.js";
19
19
  import { TransactionHost } from "../infra/database/transaction-host.js";
20
20
  import { Transactional } from "../infra/database/transactional.decorator.js";
21
+ import { KernelEvents } from "../infra/events/kernel-events.js";
21
22
  import { KERNEL_LOGGER } from "../infra/tokens.js";
22
23
  import { ProviderRegistry } from "../runtime/providers.js";
23
24
  import { RunControl as RunControlService } from "../runtime/run-control.js";
24
25
  import { rootConversationOf } from "../runtime/subagents/child.js";
25
26
  import { SkillInvoker } from "../skills/invoke.js";
26
27
  import { ConnectorContent } from "../tools/connector-content.js";
28
+ import { QuestionsService } from "../tools/questions.service.js";
27
29
  import { ConversationAccess, parentReachable } from "./access.js";
28
30
  import { readAgentTree } from "./agent-tree.js";
29
31
  import { AttachmentsService } from "./attachments.service.js";
@@ -130,6 +132,8 @@ let ConversationsService = class ConversationsService {
130
132
  invoker;
131
133
  background;
132
134
  devices;
135
+ questions;
136
+ events;
133
137
  txHost;
134
138
  constructor(db, eventLog, agents, access,
135
139
  /** Starts and stops the conversation's runs. */
@@ -145,7 +149,9 @@ let ConversationsService = class ConversationsService {
145
149
  /** Stops a deleted conversation's background tasks. */
146
150
  background,
147
151
  /** The device a new conversation is bound to (environments spec §4.1 item 1). */
148
- devices, txHost) {
152
+ devices,
153
+ /** A starter's message skips the run's pending questions (reply-while-question spec §8.1). */
154
+ questions, events, txHost) {
149
155
  this.db = db;
150
156
  this.eventLog = eventLog;
151
157
  this.agents = agents;
@@ -158,6 +164,8 @@ let ConversationsService = class ConversationsService {
158
164
  this.invoker = invoker;
159
165
  this.background = background;
160
166
  this.devices = devices;
167
+ this.questions = questions;
168
+ this.events = events;
161
169
  this.txHost = txHost;
162
170
  }
163
171
  /**
@@ -246,7 +254,9 @@ let ConversationsService = class ConversationsService {
246
254
  if (!row)
247
255
  return undefined;
248
256
  const blocks = await this.bindIn(orgId, id, accountId, input.files ?? []);
249
- return this.writeMessage(orgId, id, accountId, input, next, extra, blocks, invoked);
257
+ // a new conversation has no run yet, so nothing to skip
258
+ return (await this.writeMessage(orgId, id, accountId, input, next, extra, blocks, invoked))
259
+ .events;
250
260
  }
251
261
  /**
252
262
  * The conversation (and its first event) an earlier first send with this id created. The id used
@@ -716,8 +726,9 @@ let ConversationsService = class ConversationsService {
716
726
  : await this.prepareSkill(orgId, conv.agentId, conversationId, agent, input);
717
727
  // one transaction: the sender, time zone and language are read with the message's write
718
728
  // (run context spec §3.2)
719
- const event = await this.postIn(orgId, conversationId, accountId, input, next, extra, invoked);
720
- await this.eventLog.notify(conversationId, event.seq);
729
+ const { event, skipped, toSeq } = await this.postIn(orgId, conversationId, accountId, input, next, extra, invoked);
730
+ await this.eventLog.notify(conversationId, toSeq);
731
+ await this.announceSkipped(skipped);
721
732
  if (next !== null)
722
733
  await this.eventLog.publishChanged(conversationId, "selection");
723
734
  // The message is written: it brings an archived conversation back into the default lists
@@ -740,7 +751,12 @@ let ConversationsService = class ConversationsService {
740
751
  async postIn(orgId, conversationId, accountId, input, next, extra, invoked) {
741
752
  const blocks = await this.bindIn(orgId, conversationId, accountId, input.files ?? []);
742
753
  const written = await this.writeMessage(orgId, conversationId, accountId, input, next, extra, blocks, invoked);
743
- return written.at(-1);
754
+ const event = written.events.at(-1);
755
+ return {
756
+ event,
757
+ skipped: written.skipped,
758
+ toSeq: Math.max(event.seq, written.skippedToSeq ?? 0),
759
+ };
744
760
  }
745
761
  /**
746
762
  * The selection a message switches to, or null when it changes nothing. A model equal to the
@@ -779,10 +795,36 @@ let ConversationsService = class ConversationsService {
779
795
  const agent = await this.agents.get(orgId, conv.agentId, accountId);
780
796
  await this.planSwitch(orgId, agent.definition, conv.selection, pick);
781
797
  }
798
+ /**
799
+ * Skips, in the caller's transaction (it wrote `accountId`'s message at `messageSeq` and holds the
800
+ * conversation row lock), the pending questions that message closes (reply-while-question spec
801
+ * §8.1). The queue's "send now" uses it as the direct send does; announce after the commit with
802
+ * {@link announceSkipped}.
803
+ */
804
+ async skipQuestionsIn(conversationId, accountId, messageSeq) {
805
+ return this.questions.skipPendingIn(conversationId, accountId, messageSeq);
806
+ }
807
+ /**
808
+ * After the commit: announces each skipped question (`question.answered`, outcome `skipped`), so
809
+ * its run resumes (waits) and its IM card updates (channel jobs).
810
+ */
811
+ async announceSkipped(records) {
812
+ for (const q of records) {
813
+ await this.events.emit("question.answered", {
814
+ id: q.id,
815
+ orgId: q.orgId,
816
+ conversationId: q.conversationId,
817
+ runId: q.runId,
818
+ outcome: "skipped",
819
+ });
820
+ }
821
+ }
782
822
  /**
783
823
  * Writes the message (preceded by its `model_switched` when it switches) inside `t` and updates
784
824
  * `conversations.selection`; returns the events written, in seq order (the `user_message`
785
- * last). The caller notifies after commit.
825
+ * last). A message this call newly wrote (not a retry's deduped one) then skips the pending
826
+ * questions its author may answer (reply-while-question spec §8.1): `skipped` and the highest
827
+ * seq their events took. The caller notifies and emits `question.answered` after commit.
786
828
  */
787
829
  async writeMessage(orgId, conversationId, accountId, input, next, extra, blocks, invoked) {
788
830
  const events = [];
@@ -803,7 +845,11 @@ let ConversationsService = class ConversationsService {
803
845
  im: input.im,
804
846
  });
805
847
  events.push(this.userMessage(accountId, input, extra, blocks, invoked, context));
806
- const written = await this.eventLog.appendIn(conversationId, events);
848
+ const { events: written, fresh } = await this.eventLog.appendInReport(conversationId, events);
849
+ const message = written.at(-1);
850
+ const skips = fresh.at(-1) === true
851
+ ? await this.skipQuestionsIn(conversationId, accountId, message.seq)
852
+ : { records: [], toSeq: null };
807
853
  if (next !== null) {
808
854
  const t = this.db.db;
809
855
  // the cache follows the log (a retried message's switch may be an old, deduped event that a
@@ -814,7 +860,7 @@ let ConversationsService = class ConversationsService {
814
860
  .where("id", "=", conversationId)
815
861
  .execute();
816
862
  }
817
- return written;
863
+ return { events: written, skipped: skips.records, skippedToSeq: skips.toSeq };
818
864
  }
819
865
  /**
820
866
  * Renders the message's skill invocation (phase D), or undefined when it has none. Resolved
@@ -940,6 +986,9 @@ __decorate([
940
986
  __decorate([
941
987
  Transactional({ propagation: "requires_new" })
942
988
  ], ConversationsService.prototype, "postIn", null);
989
+ __decorate([
990
+ Transactional({ propagation: "mandatory" })
991
+ ], ConversationsService.prototype, "skipQuestionsIn", null);
943
992
  __decorate([
944
993
  Transactional({ propagation: "mandatory" })
945
994
  ], ConversationsService.prototype, "writeMessage", null);
@@ -960,6 +1009,8 @@ ConversationsService = __decorate([
960
1009
  __param(9, Inject(SkillInvoker)),
961
1010
  __param(10, Inject(BackgroundTaskStopper)),
962
1011
  __param(11, Inject(ConversationDevices)),
963
- __param(12, Inject(TransactionHost))
1012
+ __param(12, Inject(QuestionsService)),
1013
+ __param(13, Inject(KernelEvents)),
1014
+ __param(14, Inject(TransactionHost))
964
1015
  ], ConversationsService);
965
1016
  export { ConversationsService };
@@ -97,6 +97,15 @@ export declare class EventLog {
97
97
  * commit with the highest seq it wrote.
98
98
  */
99
99
  appendIn(conversationId: string, events: AppendEvent[]): Promise<ConversationEvent[]>;
100
+ /**
101
+ * {@link appendIn} that also says, per input, whether this call wrote the event (`fresh[i]`) or
102
+ * returned an earlier one by its idempotency key (a retried message, reply-while-question spec
103
+ * §8.1). Same transaction contract: the caller's, `mandatory`.
104
+ */
105
+ appendInReport(conversationId: string, events: AppendEvent[]): Promise<{
106
+ events: ConversationEvent[];
107
+ fresh: boolean[];
108
+ }>;
100
109
  private appendOwn;
101
110
  /**
102
111
  * 发布「日志已推进到 toSeq」;发布失败只记日志(事件已提交,订阅方可按 seq 补读)。Then announces
@@ -110,11 +110,21 @@ let EventLog = class EventLog {
110
110
  * commit with the highest seq it wrote.
111
111
  */
112
112
  async appendIn(conversationId, events) {
113
+ return (await this.appendInReport(conversationId, events)).events;
114
+ }
115
+ /**
116
+ * {@link appendIn} that also says, per input, whether this call wrote the event (`fresh[i]`) or
117
+ * returned an earlier one by its idempotency key (a retried message, reply-while-question spec
118
+ * §8.1). Same transaction contract: the caller's, `mandatory`.
119
+ */
120
+ async appendInReport(conversationId, events) {
113
121
  const bodies = events.map((e) => this.validate(e));
114
- return (await this.write(conversationId, events, bodies)).events;
122
+ const { events: out, fresh } = await this.write(conversationId, events, bodies);
123
+ return { events: out, fresh };
115
124
  }
116
125
  async appendOwn(conversationId, events, bodies) {
117
- return this.write(conversationId, events, bodies);
126
+ const { events: out, toSeq } = await this.write(conversationId, events, bodies);
127
+ return { events: out, toSeq };
118
128
  }
119
129
  /**
120
130
  * 发布「日志已推进到 toSeq」;发布失败只记日志(事件已提交,订阅方可按 seq 补读)。Then announces
@@ -333,10 +343,14 @@ let EventLog = class EventLog {
333
343
  fresh.push(i);
334
344
  }
335
345
  });
346
+ // written by this call: the first input of each new event (a repeat in the batch points to it)
347
+ const freshSet = new Set(fresh);
348
+ const isFresh = events.map((_, i) => freshSet.has(i));
336
349
  if (fresh.length === 0) {
337
350
  return {
338
351
  events: plan.map((p) => p.event),
339
352
  toSeq: null,
353
+ fresh: isFresh,
340
354
  };
341
355
  }
342
356
  const n = fresh.length;
@@ -368,12 +382,16 @@ let EventLog = class EventLog {
368
382
  return {
369
383
  events: plan.map((p) => p.kind === "old" ? p.event : bySeq.get(base + p.index + 1)),
370
384
  toSeq,
385
+ fresh: isFresh,
371
386
  };
372
387
  }
373
388
  };
374
389
  __decorate([
375
390
  Transactional({ propagation: "mandatory" })
376
391
  ], EventLog.prototype, "appendIn", null);
392
+ __decorate([
393
+ Transactional({ propagation: "mandatory" })
394
+ ], EventLog.prototype, "appendInReport", null);
377
395
  __decorate([
378
396
  Transactional({ propagation: "requires_new" })
379
397
  ], EventLog.prototype, "appendOwn", null);
@@ -687,7 +687,9 @@ export declare const ConversationEventSchema: z.ZodDiscriminatedUnion<[z.ZodObje
687
687
  answered: "answered";
688
688
  cancelled: "cancelled";
689
689
  expired: "expired";
690
+ skipped: "skipped";
690
691
  }>;
692
+ skippedBySeq: z.ZodOptional<z.ZodNumber>;
691
693
  }, z.core.$strip>;
692
694
  }, z.core.$strip>, z.ZodObject<{
693
695
  type: z.ZodLiteral<"media_excluded">;
@@ -539,13 +539,18 @@ export const ConversationEventSchema = z.discriminatedUnion("type", [
539
539
  form: InputFormSchema,
540
540
  expiresAt: z.string(),
541
541
  })),
542
- /** 输入请求已结束:回答 / 超时 / 取消(后两者 `answer` 与 `byAccountId` 为 null)。 */
542
+ /**
543
+ * An input request ended: answered, skipped (the run's starter sent a message instead; `answer`
544
+ * null, `byAccountId` the message's author, `skippedBySeq` that `user_message`'s seq, written in
545
+ * the same transaction), expired or cancelled (`answer` and `byAccountId` null). Not model-visible.
546
+ */
543
547
  ev("question_answered", z.object({
544
548
  questionId: z.string(),
545
549
  callId: z.string(),
546
550
  answer: FormAnswerSchema.nullable(),
547
551
  byAccountId: z.string().nullable(),
548
- outcome: z.enum(["answered", "expired", "cancelled"]),
552
+ outcome: z.enum(["answered", "skipped", "expired", "cancelled"]),
553
+ skippedBySeq: seqNo.optional(),
549
554
  })),
550
555
  /**
551
556
  * 带媒体的模型请求被供应商以不可重试的 `invalid_request` / `context_overflow` 拒绝(Ruling M8):这些附件
@@ -15,7 +15,7 @@ export declare class ConversationQueue {
15
15
  /** Starts the conversation's next run (`ensureRun`). */
16
16
  private readonly runs;
17
17
  readonly txHost: TransactionHost;
18
- constructor(db: KernelDb, eventLog: Pick<EventLog, "appendIn" | "notify" | "publishChanged" | "findByIdem">, conversations: Pick<ConversationsService, "get" | "writable" | "resolveExtra" | "bindIn" | "checkPick" | "prepareQueuedSkill">,
18
+ constructor(db: KernelDb, eventLog: Pick<EventLog, "appendIn" | "notify" | "publishChanged" | "findByIdem">, conversations: Pick<ConversationsService, "get" | "writable" | "resolveExtra" | "bindIn" | "checkPick" | "prepareQueuedSkill" | "skipQuestionsIn" | "announceSkipped">,
19
19
  /** Starts the conversation's next run (`ensureRun`). */
20
20
  runs: Pick<RunControl, "ensureRun" | "wakeHeldTree">, txHost: TransactionHost);
21
21
  list(orgId: string, conversationId: string, accountId: string): Promise<{
@@ -242,9 +242,10 @@ let ConversationQueue = class ConversationQueue {
242
242
  */
243
243
  async sendNow(orgId, conversationId, accountId, itemId) {
244
244
  await this.conversations.writable(orgId, conversationId, accountId);
245
- const { event, fresh, switched, unarchived } = await this.sendNowIn(conversationId, itemId);
245
+ const { event, fresh, switched, unarchived, skipped, toSeq } = await this.sendNowIn(conversationId, itemId);
246
246
  if (fresh)
247
- await this.eventLog.notify(conversationId, event.seq);
247
+ await this.eventLog.notify(conversationId, toSeq);
248
+ await this.conversations.announceSkipped(skipped);
248
249
  if (switched)
249
250
  await this.eventLog.publishChanged(conversationId, "selection");
250
251
  if (unarchived)
@@ -284,7 +285,17 @@ let ConversationQueue = class ConversationQueue {
284
285
  const sent = await alreadySent(t, conversationId, row.client_message_id);
285
286
  const out = await appendQueuedIn(t, this.eventLog, conversationId, row);
286
287
  await unpauseIfEmpty(t, conversationId);
287
- return { ...out, fresh: !sent };
288
+ // sent during the run, the item is the author's message like a direct send: it skips the
289
+ // pending questions they may answer (reply-while-question spec §4.2)
290
+ const skips = sent
291
+ ? { records: [], toSeq: null }
292
+ : await this.conversations.skipQuestionsIn(conversationId, row.account_id, out.event.seq);
293
+ return {
294
+ ...out,
295
+ fresh: !sent,
296
+ skipped: skips.records,
297
+ toSeq: Math.max(out.event.seq, skips.toSeq ?? 0),
298
+ };
288
299
  }
289
300
  /** "Continue sending": clears the pause and lets `ensureRun` send the head item. */
290
301
  async resume(orgId, conversationId, accountId) {
@@ -518,7 +518,7 @@ export interface QuestionsTable {
518
518
  run_id: string;
519
519
  call_id: string;
520
520
  spec: Json<unknown>;
521
- status: Generated<"pending" | "answered" | "expired" | "cancelled">;
521
+ status: Generated<"pending" | "answered" | "skipped" | "expired" | "cancelled">;
522
522
  answer: ColumnType<unknown, string | null | undefined, string | null>;
523
523
  answered_by: string | null;
524
524
  answered_at: TsNullable;
@@ -58,8 +58,10 @@ export interface TrajectoryToolCall {
58
58
  question: {
59
59
  questionId: string;
60
60
  askedAt: string;
61
- outcome: "answered" | "expired" | "cancelled" | null;
61
+ outcome: "answered" | "skipped" | "expired" | "cancelled" | null;
62
62
  byAccountId: string | null;
63
+ /** A skipped question: the seq of the user message that skipped it. */
64
+ skippedBySeq?: number;
63
65
  } | null;
64
66
  /** A `tool.preExecute` policy hook denied the call (it never started). */
65
67
  hookDenied?: TrajectoryHookDenial;
@@ -281,6 +281,9 @@ function buildRun({ started, events }, prices, forks) {
281
281
  askedAt: iso(q.createdAt),
282
282
  outcome: ans?.payload.outcome ?? null,
283
283
  byAccountId: ans?.payload.byAccountId ?? null,
284
+ ...(ans?.payload.skippedBySeq !== undefined && {
285
+ skippedBySeq: ans.payload.skippedBySeq,
286
+ }),
284
287
  },
285
288
  };
286
289
  });
@@ -34,7 +34,8 @@ export interface KernelEventMap {
34
34
  status: "approved" | "denied";
35
35
  };
36
36
  /**
37
- * A person answered a tool call's input request (after the commit, exactly once). Same
37
+ * A person answered a tool call's input request, or skipped it with a message (after the commit,
38
+ * exactly once). Same
38
39
  * consumers and the same re-derivation as `approval.decided`.
39
40
  */
40
41
  "question.answered": {
@@ -42,6 +43,8 @@ export interface KernelEventMap {
42
43
  orgId: string;
43
44
  conversationId: string;
44
45
  runId: string;
46
+ /** `skipped`: the run's starter sent a message instead (reply-while-question spec §6.3). */
47
+ outcome: "answered" | "skipped";
45
48
  };
46
49
  /**
47
50
  * EventAppended: a conversation's event log advanced to `toSeq` (after the commit, from
@@ -5,8 +5,9 @@ const SPEC = {
5
5
  "need the user's preference or decision to continue, or when a request is ambiguous; do not use " +
6
6
  "it for questions you can answer yourself. Each question has a short header, 2-4 options with a " +
7
7
  "label and a description, and can allow selecting several options (multiSelect). The user can " +
8
- "always choose Other and type their own answer instead. The run pauses until the user answers; " +
9
- "if they do not answer in time, the result says so.",
8
+ "always choose Other and type their own answer instead. The run pauses until the user answers. " +
9
+ "The user may instead reply with a message; then the result says so and their message follows. " +
10
+ "If they do not answer in time, the result says so.",
10
11
  inputSchema: {
11
12
  type: "object",
12
13
  properties: {
@@ -35,6 +35,13 @@ export declare const approvalExpiredText: (hours: number) => string;
35
35
  export declare const APPROVAL_CANCELLED_TEXT = "The run was stopped while waiting for approval.";
36
36
  /** 等待用户输入期间 run 被中止。 */
37
37
  export declare const INPUT_CANCELLED_TEXT = "The run was stopped while waiting for user input.";
38
+ /**
39
+ * The result of an `AskUserQuestion` call whose question the user skipped by sending a message
40
+ * (reply-while-question spec §7.1): the message follows in the log as an ordinary user message.
41
+ */
42
+ export declare const QUESTION_SKIPPED_TEXT = "The user did not answer this question and sent a message instead; read it below and continue.";
43
+ /** The result of any other tool's input request (extension `input_required`, MCP elicitation) skipped by a message. */
44
+ export declare const INPUT_SKIPPED_TEXT = "The user did not provide the requested input and sent a message instead; read it below and continue. Call the tool again only if it is still needed.";
38
45
  /** 同一调用连续请求输入超过上限。 */
39
46
  export declare const TOO_MANY_INPUT_REQUESTS_TEXT = "Tool requested user input too many times.";
40
47
  /** run 已被中止 / 到达截止时间,调用尚未开始执行。 */
@@ -66,12 +66,38 @@ export const approvalExpiredText = (hours) => `Approval timed out after ${hours}
66
66
  export const APPROVAL_CANCELLED_TEXT = "The run was stopped while waiting for approval.";
67
67
  /** 等待用户输入期间 run 被中止。 */
68
68
  export const INPUT_CANCELLED_TEXT = "The run was stopped while waiting for user input.";
69
+ /**
70
+ * The result of an `AskUserQuestion` call whose question the user skipped by sending a message
71
+ * (reply-while-question spec §7.1): the message follows in the log as an ordinary user message.
72
+ */
73
+ export const QUESTION_SKIPPED_TEXT = "The user did not answer this question and sent a message instead; read it below and continue.";
74
+ /** The result of any other tool's input request (extension `input_required`, MCP elicitation) skipped by a message. */
75
+ export const INPUT_SKIPPED_TEXT = "The user did not provide the requested input and sent a message instead; read it below and continue. Call the tool again only if it is still needed.";
69
76
  /** 同一调用连续请求输入超过上限。 */
70
77
  export const TOO_MANY_INPUT_REQUESTS_TEXT = "Tool requested user input too many times.";
71
78
  /** run 已被中止 / 到达截止时间,调用尚未开始执行。 */
72
79
  export const RUN_STOPPED_TEXT = "The run was stopped before this tool call was executed.";
73
80
  /** 调用执行中 run 被中止 / 到达截止时间。 */
74
81
  export const RUN_STOPPED_DURING_TEXT = "The run was stopped while this tool call was running; its outcome is unknown. Verify before retrying.";
82
+ /** The fixed result of a call whose input request was skipped by a message (spec §7.1). */
83
+ const skippedResult = (toolKey) => toolKey === "AskUserQuestion"
84
+ ? {
85
+ content: QUESTION_SKIPPED_TEXT,
86
+ isError: false,
87
+ display: {
88
+ summary: {
89
+ zh: "用户没有回答,直接发了消息",
90
+ en: "User sent a message instead of answering",
91
+ },
92
+ },
93
+ }
94
+ : {
95
+ content: INPUT_SKIPPED_TEXT,
96
+ isError: true,
97
+ display: {
98
+ summary: { zh: "用户没有提供输入,直接发了消息", en: "User sent a message instead" },
99
+ },
100
+ };
75
101
  const timeoutText = (key, ms) => `Tool ${key} timed out after ${ms / 1000}s. The operation may or may not have completed; verify before retrying.`;
76
102
  const failedText = (key, errName) => `Tool ${key} failed (${errName}). The operation may not have completed; check before retrying or try another approach.`;
77
103
  /** The connector slugs and extension names among the run's tools (to read old `<x>__<tool>` keys). */
@@ -289,6 +315,12 @@ let ToolPipeline = class ToolPipeline {
289
315
  const last = rounds.at(-1);
290
316
  if (last?.status === "pending")
291
317
  return inputWait(last);
318
+ // skipped by the starter's message: the kernel writes the result, the tool is not called again
319
+ // (spec D6: an extension would only see `answer: null`, an MCP call would be re-sent)
320
+ if (last?.status === "skipped") {
321
+ await this.writeResult(ctx, call.id, last.toolKey, skippedResult(last.toolKey));
322
+ return undefined;
323
+ }
292
324
  const attempt = rounds.length + 1;
293
325
  const started = await this.deps.eventLog.findByIdem(ctx.conversationId, idem.started(ctx.runId, call.id, attempt));
294
326
  if (started?.type === "tool_call_started") {
@@ -4,7 +4,7 @@ import { KernelConfigService } from "../infra/config/kernel-config.service.js";
4
4
  import { KernelDb } from "../infra/database/kernel-db.js";
5
5
  import { TransactionHost } from "../infra/database/transaction-host.js";
6
6
  import { KernelEvents } from "../infra/events/kernel-events.js";
7
- export type QuestionStatus = "pending" | "answered" | "expired" | "cancelled";
7
+ export type QuestionStatus = "pending" | "answered" | "skipped" | "expired" | "cancelled";
8
8
  /** 一条输入请求(`AskUserQuestion`、插件工具的 `input_required`、MCP elicitation 共用)。 */
9
9
  export interface QuestionRecord {
10
10
  id: string;
@@ -75,6 +75,13 @@ export declare class QuestionsService {
75
75
  * 传 `orgId` 时还要求输入请求属于该组织(HTTP 接口用)。
76
76
  */
77
77
  answer(questionId: string, accountId: string, answer: unknown, orgId?: string): Promise<QuestionRecord>;
78
+ /**
79
+ * Locks the conversation row of question `questionId` (`SELECT … FOR UPDATE`; re-entrant with the
80
+ * lock `EventLog.appendIn` takes later). Every write to a question row takes the conversation lock
81
+ * first, then the question row (reply-while-question spec §8.2, D7), as the message's skip and
82
+ * the stop of a waiting run do, so no two of them can deadlock. False: no such question (in `orgId`).
83
+ */
84
+ private lockConversationOf;
78
85
  private answerIn;
79
86
  /**
80
87
  * 超时(等待机制的超时任务调用):仍 pending 且已到期 → 置 expired、写 `question_answered{expired}`。
@@ -99,6 +106,20 @@ export declare class QuestionsService {
99
106
  records: QuestionRecord[];
100
107
  seqs: Map<string, number>;
101
108
  }>;
109
+ /**
110
+ * Skips, in the caller's transaction (`mandatory`, the message's), every question a message by
111
+ * `accountId` just written at `messageSeq` closes (reply-while-question spec §4.2, §8.1): pending,
112
+ * not yet due, asked in `conversationId` (a sub-agent's question lives in the child conversation)
113
+ * by a run of it that is `running` or `waiting` and was started by `accountId`. The caller holds
114
+ * the conversation row lock (its `appendIn`), so only questions logged before the message are
115
+ * seen. Each becomes `skipped` with `question_answered { outcome: "skipped", skippedBySeq }`.
116
+ * Returns the records and the highest seq written (null: none); the caller notifies and emits
117
+ * `question.answered` after the commit.
118
+ */
119
+ skipPendingIn(conversationId: string, accountId: string, messageSeq: number): Promise<{
120
+ records: QuestionRecord[];
121
+ toSeq: number | null;
122
+ }>;
102
123
  /** `question_answered` in the current transaction; returns its seq. */
103
124
  private answeredEvent;
104
125
  }
@@ -169,13 +169,38 @@ let QuestionsService = class QuestionsService {
169
169
  orgId: record.orgId,
170
170
  conversationId: record.conversationId,
171
171
  runId: record.runId,
172
+ outcome: "answered",
172
173
  });
173
174
  return record;
174
175
  }
176
+ /**
177
+ * Locks the conversation row of question `questionId` (`SELECT … FOR UPDATE`; re-entrant with the
178
+ * lock `EventLog.appendIn` takes later). Every write to a question row takes the conversation lock
179
+ * first, then the question row (reply-while-question spec §8.2, D7), as the message's skip and
180
+ * the stop of a waiting run do, so no two of them can deadlock. False: no such question (in `orgId`).
181
+ */
182
+ async lockConversationOf(questionId, orgId) {
183
+ const t = this.db.db;
184
+ let q = t.selectFrom("questions").select("conversation_id").where("id", "=", questionId);
185
+ if (orgId !== undefined)
186
+ q = q.where("org_id", "=", orgId);
187
+ const row = await q.executeTakeFirst();
188
+ if (!row)
189
+ return false;
190
+ await t
191
+ .selectFrom("conversations")
192
+ .select("id")
193
+ .where("id", "=", row.conversation_id)
194
+ .forUpdate()
195
+ .executeTakeFirst();
196
+ return true;
197
+ }
175
198
  // its own transaction (as the legacy one was): `question.answered` is emitted only after the
176
199
  // answer committed, even when a caller has a transaction open
177
200
  async answerIn(questionId, accountId, answer, orgId) {
178
201
  const t = this.db.db;
202
+ if (!(await this.lockConversationOf(questionId, orgId)))
203
+ throw notFound();
179
204
  let q = t
180
205
  .selectFrom("questions")
181
206
  .selectAll()
@@ -194,6 +219,9 @@ let QuestionsService = class QuestionsService {
194
219
  if (run?.trigger_account_id !== accountId)
195
220
  throw notInitiator();
196
221
  const before = toRecord(locked);
222
+ if (before.status === "skipped") {
223
+ throw new ApiError("question.already_answered", "Question was skipped: the user sent a message instead");
224
+ }
197
225
  if (before.status !== "pending") {
198
226
  throw new ApiError("question.already_answered", "Question has already been answered");
199
227
  }
@@ -233,6 +261,8 @@ let QuestionsService = class QuestionsService {
233
261
  }
234
262
  async expireIn(questionId) {
235
263
  const t = this.db.db;
264
+ if (!(await this.lockConversationOf(questionId)))
265
+ return { outcome: "settled" };
236
266
  const locked = await t
237
267
  .selectFrom("questions")
238
268
  .selectAll()
@@ -291,10 +321,33 @@ let QuestionsService = class QuestionsService {
291
321
  */
292
322
  async cancelPendingFor(orgId, accountId) {
293
323
  const t = this.db.db;
324
+ // the conversations first, in id order (lock order D7: conversation, then question rows)
325
+ const convs = await t
326
+ .selectFrom("questions")
327
+ .select("conversation_id")
328
+ .distinct()
329
+ .where("org_id", "=", orgId)
330
+ .where("status", "=", "pending")
331
+ .where("run_id", "in", (eb) => eb.selectFrom("runs").select("id").where("trigger_account_id", "=", accountId))
332
+ .execute();
333
+ if (convs.length > 0) {
334
+ await t
335
+ .selectFrom("conversations")
336
+ .select("id")
337
+ .where("id", "in", convs.map((c) => c.conversation_id))
338
+ .orderBy("id")
339
+ .forUpdate()
340
+ .execute();
341
+ }
342
+ // only the conversations locked above: a question asked meanwhile in another one waits for
343
+ // the run's stop (offboarding aborts it) or the sweep, so no conversation is locked after a row
344
+ if (convs.length === 0)
345
+ return { records: [], seqs: new Map() };
294
346
  const rows = await t
295
347
  .selectFrom("questions")
296
348
  .selectAll()
297
349
  .where("org_id", "=", orgId)
350
+ .where("conversation_id", "in", convs.map((c) => c.conversation_id))
298
351
  .where("status", "=", "pending")
299
352
  .where("run_id", "in", (eb) => eb.selectFrom("runs").select("id").where("trigger_account_id", "=", accountId))
300
353
  .orderBy("created_at")
@@ -315,14 +368,60 @@ let QuestionsService = class QuestionsService {
315
368
  }
316
369
  return { records, seqs };
317
370
  }
371
+ /**
372
+ * Skips, in the caller's transaction (`mandatory`, the message's), every question a message by
373
+ * `accountId` just written at `messageSeq` closes (reply-while-question spec §4.2, §8.1): pending,
374
+ * not yet due, asked in `conversationId` (a sub-agent's question lives in the child conversation)
375
+ * by a run of it that is `running` or `waiting` and was started by `accountId`. The caller holds
376
+ * the conversation row lock (its `appendIn`), so only questions logged before the message are
377
+ * seen. Each becomes `skipped` with `question_answered { outcome: "skipped", skippedBySeq }`.
378
+ * Returns the records and the highest seq written (null: none); the caller notifies and emits
379
+ * `question.answered` after the commit.
380
+ */
381
+ async skipPendingIn(conversationId, accountId, messageSeq) {
382
+ const t = this.db.db;
383
+ const rows = await t
384
+ .selectFrom("questions as q")
385
+ .innerJoin("runs as r", "r.id", "q.run_id")
386
+ .selectAll("q")
387
+ .where("q.conversation_id", "=", conversationId)
388
+ .where("q.status", "=", "pending")
389
+ .where(sql `q.expires_at > now()`)
390
+ .where("r.status", "in", ["running", "waiting"])
391
+ .where("r.trigger_account_id", "=", accountId)
392
+ .orderBy("q.created_at")
393
+ .orderBy("q.id")
394
+ .forUpdate("q")
395
+ .execute();
396
+ const records = [];
397
+ let toSeq = null;
398
+ for (const row of rows) {
399
+ const updated = await t
400
+ .updateTable("questions")
401
+ .set({ status: "skipped", answered_by: accountId, answered_at: sql `now()` })
402
+ .where("id", "=", row.id)
403
+ .returningAll()
404
+ .executeTakeFirstOrThrow();
405
+ toSeq = await this.answeredEvent(toRecord(row), null, accountId, "skipped", messageSeq);
406
+ records.push(toRecord(updated));
407
+ }
408
+ return { records, toSeq };
409
+ }
318
410
  /** `question_answered` in the current transaction; returns its seq. */
319
- async answeredEvent(q, answer, byAccountId, outcome) {
411
+ async answeredEvent(q, answer, byAccountId, outcome, skippedBySeq) {
320
412
  const written = await this.eventLog.appendIn(q.conversationId, [
321
413
  {
322
414
  type: "question_answered",
323
415
  runId: q.runId,
324
416
  idemKey: `question_answered:${q.id}`,
325
- payload: { questionId: q.id, callId: q.callId, answer, byAccountId, outcome },
417
+ payload: {
418
+ questionId: q.id,
419
+ callId: q.callId,
420
+ answer,
421
+ byAccountId,
422
+ outcome,
423
+ ...(skippedBySeq !== undefined && { skippedBySeq }),
424
+ },
326
425
  },
327
426
  ]);
328
427
  return Math.max(...written.map((e) => e.seq));
@@ -343,6 +442,9 @@ __decorate([
343
442
  __decorate([
344
443
  Transactional({ propagation: "mandatory" })
345
444
  ], QuestionsService.prototype, "cancelPendingFor", null);
445
+ __decorate([
446
+ Transactional({ propagation: "mandatory" })
447
+ ], QuestionsService.prototype, "skipPendingIn", null);
346
448
  QuestionsService = __decorate([
347
449
  Injectable(),
348
450
  __param(0, Inject(KernelDb)),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capybara-hq/kernel",
3
- "version": "0.1.0-rc.16",
3
+ "version": "0.1.0-rc.17",
4
4
  "description": "Capybara kernel: config, database, plugin host and HTTP API of the Capybara agent platform",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -65,7 +65,7 @@
65
65
  "yaml": "^2.9.1",
66
66
  "yauzl": "3.4.0",
67
67
  "zod": "^4.6.7",
68
- "@capybara-hq/plugin-sdk": "^0.1.0-rc.12"
68
+ "@capybara-hq/plugin-sdk": "^0.1.0-rc.13"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@langchain/langgraph-checkpoint-validation": "^1.2.0",
@@ -0,0 +1,6 @@
1
+ -- A message from the run's starter closes a pending question as skipped (reply-while-question spec
2
+ -- §6.2). Widening the inline CHECK of 0010 (PG's default name questions_status_check): drop and add
3
+ -- in the file's one transaction, idempotent.
4
+ ALTER TABLE questions DROP CONSTRAINT IF EXISTS questions_status_check;
5
+ ALTER TABLE questions ADD CONSTRAINT questions_status_check
6
+ CHECK (status IN ('pending', 'answered', 'skipped', 'expired', 'cancelled'));