@capybara-hq/kernel 0.1.0-rc.15 → 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/channels.schemas.d.ts +4 -0
- package/dist/channels/channels.schemas.js +2 -0
- package/dist/channels/connections.controller.d.ts +5 -3
- package/dist/channels/connections.controller.js +6 -4
- package/dist/channels/connections.service.d.ts +26 -0
- package/dist/channels/connections.service.js +79 -6
- 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/extensions/guide-places.d.ts +3 -1
- package/dist/extensions/guide-places.js +1 -1
- package/dist/extensions/permission-view.d.ts +4 -4
- package/dist/extensions/permission-view.js +7 -10
- 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 +3 -3
- 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
|
/** 一条只带一张卡片的消息(随附文本为固定话术)。 */
|
|
@@ -9,6 +9,10 @@ export declare const ChannelCreateSchema: z.ZodObject<{
|
|
|
9
9
|
anonymous: "anonymous";
|
|
10
10
|
bind: "bind";
|
|
11
11
|
}>>;
|
|
12
|
+
inboundMode: z.ZodOptional<z.ZodEnum<{
|
|
13
|
+
long_connection: "long_connection";
|
|
14
|
+
webhook: "webhook";
|
|
15
|
+
}>>;
|
|
12
16
|
}, z.core.$strict>;
|
|
13
17
|
export declare const ChannelPatchSchema: z.ZodObject<{
|
|
14
18
|
name: z.ZodOptional<z.ZodString>;
|
|
@@ -8,6 +8,8 @@ export const ChannelCreateSchema = z
|
|
|
8
8
|
agentId: z.string().min(1).max(100),
|
|
9
9
|
config: Config,
|
|
10
10
|
unboundPolicy: Policy.optional(),
|
|
11
|
+
// default long_connection; webhook needs plugin support and channels.webhookBaseUrl (409)
|
|
12
|
+
inboundMode: z.enum(["long_connection", "webhook"]).optional(),
|
|
11
13
|
})
|
|
12
14
|
.strict();
|
|
13
15
|
export const ChannelPatchSchema = z
|
|
@@ -7,7 +7,7 @@ import { PluginIcons } from "../extensions/plugin-icons.js";
|
|
|
7
7
|
import { type Principal } from "../identity/principal.js";
|
|
8
8
|
import { type OrgContext } from "../orgs/org.guard.js";
|
|
9
9
|
import { ChannelCreateSchema as CreateBody, ChannelGuideAckSchema as GuideAckBody, ChannelPatchSchema as PatchBody } from "./channels.schemas.js";
|
|
10
|
-
import { ChannelConnections, type ChannelConnectionView } from "./connections.service.js";
|
|
10
|
+
import { ChannelConnections, type ChannelConnectionView, type ChannelInboundInfo } from "./connections.service.js";
|
|
11
11
|
/**
|
|
12
12
|
* A connection as the org's pages show it: its plugin's display name (null when it has none) and
|
|
13
13
|
* the URL of its plugin's icon on the org's catalog route (null without one, or when the org may
|
|
@@ -36,13 +36,15 @@ export declare class ChannelConnectionsController {
|
|
|
36
36
|
channels: ChannelConnectionPage[];
|
|
37
37
|
}>;
|
|
38
38
|
/**
|
|
39
|
-
* The connection form's JSON Schema,
|
|
40
|
-
*
|
|
39
|
+
* The connection form's JSON Schema, where the plugin's setup guide is (`guide`, null when the
|
|
40
|
+
* plugin has none the org may see): 新建渠道 shows it once the plugin is picked; and whether its
|
|
41
|
+
* connections can use webhook inbound (`inbound`, channel inbound mode fields spec §4).
|
|
41
42
|
*/
|
|
42
43
|
schema(org: OrgContext, plugin: string): Promise<{
|
|
43
44
|
plugin: string;
|
|
44
45
|
schema: JsonSchema;
|
|
45
46
|
guide: Omit<SetupGuideRef, "version"> | null;
|
|
47
|
+
inbound: ChannelInboundInfo;
|
|
46
48
|
}>;
|
|
47
49
|
create(principal: Principal, org: OrgContext, body: z.infer<typeof CreateBody>): Promise<{
|
|
48
50
|
channel: ChannelConnectionPage;
|
|
@@ -16,7 +16,7 @@ import { createZodDto } from "../infra/validation/create-zod-dto.js";
|
|
|
16
16
|
import { ZBody } from "../infra/validation/zod-params.js";
|
|
17
17
|
import { CurrentOrg, OrgAction } from "../orgs/org.guard.js";
|
|
18
18
|
import { ChannelCreateSchema as CreateBody, ChannelGuideAckSchema as GuideAckBody, ChannelPatchSchema as PatchBody, } from "./channels.schemas.js";
|
|
19
|
-
import { ChannelConnections } from "./connections.service.js";
|
|
19
|
+
import { ChannelConnections, } from "./connections.service.js";
|
|
20
20
|
class ChannelCreateBody extends createZodDto(CreateBody, "ChannelCreateBody") {
|
|
21
21
|
}
|
|
22
22
|
class ChannelPatchBody extends createZodDto(PatchBody, "ChannelPatchBody") {
|
|
@@ -62,13 +62,15 @@ let ChannelConnectionsController = class ChannelConnectionsController {
|
|
|
62
62
|
return { channels: await this.withPlugins(org, await this.channels.list(org.orgId)) };
|
|
63
63
|
}
|
|
64
64
|
/**
|
|
65
|
-
* The connection form's JSON Schema,
|
|
66
|
-
*
|
|
65
|
+
* The connection form's JSON Schema, where the plugin's setup guide is (`guide`, null when the
|
|
66
|
+
* plugin has none the org may see): 新建渠道 shows it once the plugin is picked; and whether its
|
|
67
|
+
* connections can use webhook inbound (`inbound`, channel inbound mode fields spec §4).
|
|
67
68
|
*/
|
|
68
69
|
async schema(org, plugin) {
|
|
69
70
|
const schema = this.channels.jsonSchema(plugin);
|
|
71
|
+
const inbound = this.channels.inboundInfo(plugin);
|
|
70
72
|
const g = (await this.guides.channelGuides([plugin], org)).get(plugin);
|
|
71
|
-
return { plugin, schema, guide: g ? { itemId: g.itemId, guideId: g.guideId } : null };
|
|
73
|
+
return { plugin, schema, guide: g ? { itemId: g.itemId, guideId: g.guideId } : null, inbound };
|
|
72
74
|
}
|
|
73
75
|
async create(principal, org, body) {
|
|
74
76
|
const p = requireUser(principal);
|
|
@@ -15,6 +15,11 @@ import { OrgLock } from "../orgs/org-lock.js";
|
|
|
15
15
|
import type { OrgRole } from "../orgs/roles.js";
|
|
16
16
|
import { ChannelJobs } from "./jobs.js";
|
|
17
17
|
import type { ChannelLocale } from "./phrases.js";
|
|
18
|
+
/**
|
|
19
|
+
* A public https address: https, and the host is not loopback, a private / link-local / reserved
|
|
20
|
+
* IP, `localhost`, `*.localhost`, `*.local`, `*.internal` or a single label (no DNS lookup).
|
|
21
|
+
*/
|
|
22
|
+
export declare function isPublicHttpsUrl(url: string): boolean;
|
|
18
23
|
/** 连接配置里全部密钥字段存成一行:`{ 点路径: 值 }` 的 JSON。 */
|
|
19
24
|
export declare const connectionSecretKey: (connectionId: string) => string;
|
|
20
25
|
/**
|
|
@@ -82,6 +87,18 @@ export interface CreateChannelInput {
|
|
|
82
87
|
agentId: string;
|
|
83
88
|
config: Record<string, unknown>;
|
|
84
89
|
unboundPolicy?: UnboundPolicy | undefined;
|
|
90
|
+
/** Default `long_connection`; `webhook` needs plugin support and `channels.webhookBaseUrl`. */
|
|
91
|
+
inboundMode?: InboundMode | undefined;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Whether a channel plugin's connections can use webhook inbound (channel inbound mode fields spec
|
|
95
|
+
* §4): the plugin implements `Channel.webhook`; the kernel accepts webhook mode (a usable
|
|
96
|
+
* `channels.webhookBaseUrl`); and that base URL is a public https address.
|
|
97
|
+
*/
|
|
98
|
+
export interface ChannelInboundInfo {
|
|
99
|
+
webhookSupported: boolean;
|
|
100
|
+
webhookAvailable: boolean;
|
|
101
|
+
webhookPublic: boolean;
|
|
85
102
|
}
|
|
86
103
|
/** `config` 里的密钥字段:缺省 = 保留原值、`null` = 清除、其他值 = 新值(同组织插件配置)。 */
|
|
87
104
|
export interface UpdateChannelInput {
|
|
@@ -163,6 +180,15 @@ export declare class ChannelConnections implements OnApplicationShutdown {
|
|
|
163
180
|
* `channels.webhookBaseUrl` (otherwise there is no URL to give the platform): 409 otherwise.
|
|
164
181
|
*/
|
|
165
182
|
private assertWebhookAvailable;
|
|
183
|
+
/**
|
|
184
|
+
* The fields the inbound mode requires (`x-ui.inboundModes`, channel inbound mode fields spec
|
|
185
|
+
* §3) must have a value in `full` (the parsed config, stored secrets included): 400
|
|
186
|
+
* `extension.config_invalid` with one issue per missing field otherwise. A plugin without the
|
|
187
|
+
* hint passes.
|
|
188
|
+
*/
|
|
189
|
+
private assertModeFields;
|
|
190
|
+
/** Whether the plugin's connections can use webhook inbound (spec §4); `extension.not_found`. */
|
|
191
|
+
inboundInfo(plugin: string): ChannelInboundInfo;
|
|
166
192
|
private viewOf;
|
|
167
193
|
private live;
|
|
168
194
|
list(orgId: string): Promise<ChannelConnectionView[]>;
|
|
@@ -8,7 +8,8 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
|
8
8
|
return function (target, key) { decorator(target, key, paramIndex); }
|
|
9
9
|
};
|
|
10
10
|
import { randomUUID } from "node:crypto";
|
|
11
|
-
import {
|
|
11
|
+
import { isIP } from "node:net";
|
|
12
|
+
import { CHANNEL_EXTENSION_POINT, missingInboundModeFields, } from "@capybara-hq/plugin-sdk";
|
|
12
13
|
import { Inject, Injectable } from "@nestjs/common";
|
|
13
14
|
import { sql } from "kysely";
|
|
14
15
|
import { configFields, getPath, secretScalars, secretValues } from "../config/fields.js";
|
|
@@ -28,11 +29,35 @@ import { Transactional } from "../infra/database/transactional.decorator.js";
|
|
|
28
29
|
import { KernelEvents } from "../infra/events/kernel-events.js";
|
|
29
30
|
import { KERNEL_BUS, KERNEL_LOGGER, KEYRING, REDIS } from "../infra/tokens.js";
|
|
30
31
|
import { createRedactor } from "../log.js";
|
|
32
|
+
import { isForbiddenAddress, isLoopbackHost, normalizeHost } from "../net/public-address.js";
|
|
31
33
|
import { OrgLock } from "../orgs/org-lock.js";
|
|
32
34
|
import { readOrgSettings } from "../orgs/settings.service.js";
|
|
33
35
|
import { adaptToFeatures } from "./features.js";
|
|
34
36
|
import { ChannelJobs } from "./jobs.js";
|
|
35
37
|
import { webhookBase, webhookUrlOf } from "./webhook-url.js";
|
|
38
|
+
/**
|
|
39
|
+
* A public https address: https, and the host is not loopback, a private / link-local / reserved
|
|
40
|
+
* IP, `localhost`, `*.localhost`, `*.local`, `*.internal` or a single label (no DNS lookup).
|
|
41
|
+
*/
|
|
42
|
+
export function isPublicHttpsUrl(url) {
|
|
43
|
+
let u;
|
|
44
|
+
try {
|
|
45
|
+
u = new URL(url);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
if (u.protocol !== "https:")
|
|
51
|
+
return false;
|
|
52
|
+
const host = normalizeHost(u.hostname);
|
|
53
|
+
if (isLoopbackHost(host))
|
|
54
|
+
return false;
|
|
55
|
+
if (isIP(host) !== 0)
|
|
56
|
+
return !isForbiddenAddress(host);
|
|
57
|
+
if (!host.includes("."))
|
|
58
|
+
return false;
|
|
59
|
+
return ![".localhost", ".local", ".internal"].some((s) => host.endsWith(s));
|
|
60
|
+
}
|
|
36
61
|
/** 内核 secrets 命名空间(插件名不能取 `kernel`)。 */
|
|
37
62
|
const SECRETS_NAMESPACE = "kernel";
|
|
38
63
|
/** 连接配置里全部密钥字段存成一行:`{ 点路径: 值 }` 的 JSON。 */
|
|
@@ -305,6 +330,38 @@ let ChannelConnections = class ChannelConnections {
|
|
|
305
330
|
throw new ApiError("channel.webhook_unavailable", "Webhook inbound needs channels.webhookBaseUrl to be configured");
|
|
306
331
|
}
|
|
307
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* The fields the inbound mode requires (`x-ui.inboundModes`, channel inbound mode fields spec
|
|
335
|
+
* §3) must have a value in `full` (the parsed config, stored secrets included): 400
|
|
336
|
+
* `extension.config_invalid` with one issue per missing field otherwise. A plugin without the
|
|
337
|
+
* hint passes.
|
|
338
|
+
*/
|
|
339
|
+
assertModeFields(plugin, schema, mode, full) {
|
|
340
|
+
const js = this.formSchema(plugin, this.versionOf(plugin), schema)?.schema;
|
|
341
|
+
if (js === undefined)
|
|
342
|
+
return;
|
|
343
|
+
const missing = missingInboundModeFields(js, mode, (field) => {
|
|
344
|
+
const v = getPath(full, field);
|
|
345
|
+
return v !== undefined && v !== null && v !== "";
|
|
346
|
+
});
|
|
347
|
+
if (missing.length === 0)
|
|
348
|
+
return;
|
|
349
|
+
throw new ApiError("extension.config_invalid", `Invalid channel connection config for extension ${plugin}`, missing.map((field) => ({
|
|
350
|
+
path: field,
|
|
351
|
+
segments: [field],
|
|
352
|
+
message: `required in ${mode} mode`,
|
|
353
|
+
})));
|
|
354
|
+
}
|
|
355
|
+
/** Whether the plugin's connections can use webhook inbound (spec §4); `extension.not_found`. */
|
|
356
|
+
inboundInfo(plugin) {
|
|
357
|
+
const webhookSupported = this.channel(plugin).webhook !== undefined;
|
|
358
|
+
const base = this.webhookBase();
|
|
359
|
+
return {
|
|
360
|
+
webhookSupported,
|
|
361
|
+
webhookAvailable: webhookSupported && base !== undefined,
|
|
362
|
+
webhookPublic: webhookSupported && base !== undefined && isPublicHttpsUrl(base),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
308
365
|
async viewOf(row) {
|
|
309
366
|
return this.view(row, new Set(Object.keys(await this.readSecrets(row.org_id, row.id))));
|
|
310
367
|
}
|
|
@@ -331,14 +388,16 @@ let ChannelConnections = class ChannelConnections {
|
|
|
331
388
|
await this.assertEnabled(orgId, input.plugin);
|
|
332
389
|
const schema = channel.connectionSchema;
|
|
333
390
|
const full = this.parse(input.plugin, schema, input.config);
|
|
391
|
+
const mode = input.inboundMode ?? "long_connection";
|
|
392
|
+
if (mode === "webhook")
|
|
393
|
+
this.assertWebhookAvailable(input.plugin);
|
|
394
|
+
this.assertModeFields(input.plugin, schema, mode, full);
|
|
334
395
|
await this.assertAgent(orgId, input.agentId);
|
|
335
396
|
const verified = await this.verify(channel, full);
|
|
336
397
|
const id = randomUUID();
|
|
337
398
|
const { plain, secrets } = split(schema, full);
|
|
338
|
-
// the guide the 新建渠道 page showed: the plugin's current one, for the
|
|
339
|
-
const guideAck = await this.guides.currentAck(input.plugin, "channel-connection", {
|
|
340
|
-
mode: "long_connection",
|
|
341
|
-
});
|
|
399
|
+
// the guide the 新建渠道 page showed: the plugin's current one, for the mode picked there
|
|
400
|
+
const guideAck = await this.guides.currentAck(input.plugin, "channel-connection", { mode });
|
|
342
401
|
const row = await this.insertIn({
|
|
343
402
|
orgId,
|
|
344
403
|
id,
|
|
@@ -372,6 +431,7 @@ let ChannelConnections = class ChannelConnections {
|
|
|
372
431
|
agent_id: input.agentId,
|
|
373
432
|
config: JSON.stringify(a.plain),
|
|
374
433
|
unbound_policy: input.unboundPolicy ?? "bind",
|
|
434
|
+
inbound_mode: input.inboundMode ?? "long_connection",
|
|
375
435
|
bot_user_id: a.verified.botUserId,
|
|
376
436
|
tenant_id: a.verified.tenantId,
|
|
377
437
|
guide_ack: a.guideAck === null ? null : JSON.stringify(a.guideAck),
|
|
@@ -385,7 +445,11 @@ let ChannelConnections = class ChannelConnections {
|
|
|
385
445
|
orgId,
|
|
386
446
|
action: "channel.created",
|
|
387
447
|
target: id,
|
|
388
|
-
detail: {
|
|
448
|
+
detail: {
|
|
449
|
+
plugin: input.plugin,
|
|
450
|
+
agentId: input.agentId,
|
|
451
|
+
inboundMode: input.inboundMode ?? "long_connection",
|
|
452
|
+
},
|
|
389
453
|
});
|
|
390
454
|
return inserted;
|
|
391
455
|
}
|
|
@@ -437,6 +501,15 @@ let ChannelConnections = class ChannelConnections {
|
|
|
437
501
|
const modeChanged = patch.inboundMode !== undefined && patch.inboundMode !== current.inbound_mode;
|
|
438
502
|
if (modeChanged && patch.inboundMode === "webhook")
|
|
439
503
|
this.assertWebhookAvailable(current.plugin);
|
|
504
|
+
if (rotated || modeChanged) {
|
|
505
|
+
// the fields the effective mode requires (spec §3): the new config, else the stored one
|
|
506
|
+
const schema = this.channel(current.plugin).connectionSchema;
|
|
507
|
+
const full = rotated?.full ?? {
|
|
508
|
+
...(current.config ?? {}),
|
|
509
|
+
...(await this.readSecrets(orgId, id)),
|
|
510
|
+
};
|
|
511
|
+
this.assertModeFields(current.plugin, schema, patch.inboundMode ?? current.inbound_mode, full);
|
|
512
|
+
}
|
|
440
513
|
if (patch.agentId !== undefined)
|
|
441
514
|
await this.assertAgent(orgId, patch.agentId);
|
|
442
515
|
// A mode switch sets the platform app up for the new mode: the guide of that mode is the one
|
|
@@ -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
|
});
|
|
@@ -4,8 +4,10 @@ import type { KernelDb } from "../infra/database/kernel-db.js";
|
|
|
4
4
|
import type { OrgAction } from "../orgs/roles.js";
|
|
5
5
|
/** Why a known variable has no value: what the web and the `Platform` tool tell the reader. */
|
|
6
6
|
export type UnresolvedReason =
|
|
7
|
-
/** The target is not saved yet (the webhook URL
|
|
7
|
+
/** The target is not saved yet (the webhook URL). */
|
|
8
8
|
"not_saved"
|
|
9
|
+
/** No name typed yet on the new target's form (a connection name on 新建渠道). */
|
|
10
|
+
| "no_name"
|
|
9
11
|
/** `http.publicUrl` is not set (the URLs built from it). */
|
|
10
12
|
| "public_url"
|
|
11
13
|
/** `channels.webhookBaseUrl` is not set or unusable. */
|
|
@@ -58,7 +58,7 @@ export async function resolvePlace(ctx) {
|
|
|
58
58
|
const typed = ctx.query.name?.trim();
|
|
59
59
|
values.connectionName =
|
|
60
60
|
connection?.name ?? (typed === undefined || typed === "" ? null : typed);
|
|
61
|
-
reasons.connectionName = "
|
|
61
|
+
reasons.connectionName = "no_name";
|
|
62
62
|
if (connection !== undefined) {
|
|
63
63
|
target = { kind: "channel-connection", id: connection.id };
|
|
64
64
|
storedMode = connection.inbound_mode;
|
|
@@ -12,11 +12,11 @@ import type { ExtensionSource } from "./sources.js";
|
|
|
12
12
|
export declare function reviewHash(set: PermissionSet, warnings?: readonly PackageWarning[]): string;
|
|
13
13
|
/** 权限清单展示用的一组:按类别分组,`sensitive` 标出需要管理员特别留意的类别。 */
|
|
14
14
|
export interface PermissionGroup {
|
|
15
|
-
category: "provides" | "requires" | "optional" | "config" | "tables" | "secrets" | "storage" | "http" | "publicRoutes" | "tools" | "hooks" | "models" | "network" | "ui" | "client";
|
|
15
|
+
category: "provides" | "requires" | "optional" | "config" | "tables" | "secrets" | "storage" | "http" | "publicRoutes" | "tools" | "hooks" | "models" | "network" | "ui" | "components" | "client";
|
|
16
16
|
/**
|
|
17
|
-
* Sensitive category: secrets, public routes, tools, hooks, client code; also
|
|
18
|
-
* when it holds one of {@link SENSITIVE_EXTENSION_POINTS}, and the "UI" group
|
|
19
|
-
* embedded app (embed: the extension's own page in an iframe)
|
|
17
|
+
* Sensitive category: secrets, public routes, tools, hooks, client components, client code; also
|
|
18
|
+
* the "provides" group when it holds one of {@link SENSITIVE_EXTENSION_POINTS}, and the "UI" group
|
|
19
|
+
* (views and embeds) when it holds an embedded app (embed: the extension's own page in an iframe).
|
|
20
20
|
*/
|
|
21
21
|
sensitive: boolean;
|
|
22
22
|
items: string[];
|
|
@@ -38,6 +38,7 @@ const ALWAYS_SENSITIVE = new Set([
|
|
|
38
38
|
"publicRoutes",
|
|
39
39
|
"tools",
|
|
40
40
|
"hooks",
|
|
41
|
+
"components",
|
|
41
42
|
"client",
|
|
42
43
|
]);
|
|
43
44
|
const CATEGORY_REASON = {
|
|
@@ -45,13 +46,14 @@ const CATEGORY_REASON = {
|
|
|
45
46
|
publicRoutes: "public_routes",
|
|
46
47
|
tools: "tools",
|
|
47
48
|
hooks: "hooks",
|
|
49
|
+
components: "ui_component",
|
|
48
50
|
client: "client_code",
|
|
49
51
|
};
|
|
50
52
|
/**
|
|
51
53
|
* Why an item in a category is sensitive (undefined when it is not; shared by the grouped list, the
|
|
52
54
|
* per-item `added` and the CLI markers): secrets, public routes, tools, hooks and client code are
|
|
53
|
-
* always sensitive
|
|
54
|
-
*
|
|
55
|
+
* always sensitive, and so are client components (their own group, items are the component ids);
|
|
56
|
+
* {@link SENSITIVE_EXTENSION_POINTS} in "provides"; embedded apps (`embed:<id>`) in "UI".
|
|
55
57
|
*/
|
|
56
58
|
function itemReason(category, item) {
|
|
57
59
|
// storage 不算敏感:ctx.storage 的键由内核加前缀 `plugins/<插件>/<orgId>/`,插件只能读写自己的前缀
|
|
@@ -63,8 +65,6 @@ function itemReason(category, item) {
|
|
|
63
65
|
if (category === "ui") {
|
|
64
66
|
if (item.startsWith("embed:"))
|
|
65
67
|
return "ui_embed";
|
|
66
|
-
if (item.startsWith("component:"))
|
|
67
|
-
return "ui_component";
|
|
68
68
|
return undefined;
|
|
69
69
|
}
|
|
70
70
|
return ALWAYS_SENSITIVE.has(category) ? CATEGORY_REASON[category] : undefined;
|
|
@@ -89,7 +89,7 @@ function keyItem(key) {
|
|
|
89
89
|
["permissions.hooks:", "hooks", ""],
|
|
90
90
|
["permissions.hooks.policy:", "hooks", "policy:"],
|
|
91
91
|
["ui.embed:", "ui", "embed:"],
|
|
92
|
-
[UI_COMPONENT_KEY_PREFIX, "
|
|
92
|
+
[UI_COMPONENT_KEY_PREFIX, "components", ""],
|
|
93
93
|
["ui.view:", "ui", "view:"],
|
|
94
94
|
];
|
|
95
95
|
for (const [prefix, category, itemPrefix] of table) {
|
|
@@ -162,12 +162,9 @@ export function permissionGroups(set) {
|
|
|
162
162
|
{ category: "network", items: p.network },
|
|
163
163
|
{
|
|
164
164
|
category: "ui",
|
|
165
|
-
items: [
|
|
166
|
-
...ui.views.map((v) => `view:${v}`),
|
|
167
|
-
...ui.embeds.map((e) => `embed:${e}`),
|
|
168
|
-
...components.map((c) => `component:${c}`),
|
|
169
|
-
],
|
|
165
|
+
items: [...ui.views.map((v) => `view:${v}`), ...ui.embeds.map((e) => `embed:${e}`)],
|
|
170
166
|
},
|
|
167
|
+
{ category: "components", items: components },
|
|
171
168
|
{ category: "client", items: p.client === true ? [CLIENT_ITEM] : [] },
|
|
172
169
|
];
|
|
173
170
|
return groups
|
|
@@ -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",
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"@types/ws": "^8.18.2",
|
|
75
75
|
"@types/yauzl": "3.4.0",
|
|
76
76
|
"ws": "^8.22.0",
|
|
77
|
-
"@capybara-hq/testing": "0.1.0-rc.
|
|
77
|
+
"@capybara-hq/testing": "0.1.0-rc.15"
|
|
78
78
|
},
|
|
79
79
|
"scripts": {
|
|
80
80
|
"build": "tsc -b"
|
|
@@ -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'));
|