@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.
- package/dist/catalog/marketplaces.service.d.ts +1 -1
- package/dist/channels/cards.d.ts +5 -1
- package/dist/channels/cards.js +4 -1
- package/dist/channels/outbound.js +4 -1
- package/dist/conversations/conversations.service.d.ts +26 -2
- package/dist/conversations/conversations.service.js +60 -9
- package/dist/conversations/event-log.d.ts +9 -0
- package/dist/conversations/event-log.js +20 -2
- package/dist/conversations/events.d.ts +2 -0
- package/dist/conversations/events.js +7 -2
- package/dist/conversations/queue.service.d.ts +1 -1
- package/dist/conversations/queue.service.js +14 -3
- package/dist/db/kernel-schema.d.ts +1 -1
- package/dist/evals/trajectory.d.ts +3 -1
- package/dist/evals/trajectory.js +3 -0
- package/dist/infra/events/kernel-events.d.ts +4 -1
- package/dist/tools/builtins/ask-user.js +3 -2
- package/dist/tools/pipeline.d.ts +7 -0
- package/dist/tools/pipeline.js +32 -0
- package/dist/tools/questions.service.d.ts +22 -1
- package/dist/tools/questions.service.js +104 -2
- package/package.json +2 -2
- package/src/migrations/0108_question_skipped.sql +6 -0
|
@@ -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.
|
|
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;
|
package/dist/channels/cards.d.ts
CHANGED
|
@@ -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:
|
|
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
|
/** 一条只带一张卡片的消息(随附文本为固定话术)。 */
|
package/dist/channels/cards.js
CHANGED
|
@@ -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
|
-
|
|
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">,
|
|
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).
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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).
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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,
|
|
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
|
-
|
|
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;
|
package/dist/evals/trajectory.js
CHANGED
|
@@ -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,
|
|
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
|
-
"
|
|
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: {
|
package/dist/tools/pipeline.d.ts
CHANGED
|
@@ -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 已被中止 / 到达截止时间,调用尚未开始执行。 */
|
package/dist/tools/pipeline.js
CHANGED
|
@@ -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: {
|
|
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.
|
|
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.
|
|
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'));
|