@downcity/agent 1.1.400 → 1.1.408

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.
Files changed (84) hide show
  1. package/LICENSE +183 -0
  2. package/README.md +4 -3
  3. package/bin/agent/WorkspaceEntry.js +1 -1
  4. package/bin/agent/WorkspaceEntry.js.map +1 -1
  5. package/bin/city/types/PluginHostContext.d.ts +3 -0
  6. package/bin/city/types/PluginHostContext.d.ts.map +1 -1
  7. package/bin/executor/core-engine/CoreEngineError.js +8 -2
  8. package/bin/executor/core-engine/CoreEngineError.js.map +1 -1
  9. package/bin/executor/core-engine/CoreEngineRunner.d.ts.map +1 -1
  10. package/bin/executor/core-engine/CoreEngineRunner.js +36 -0
  11. package/bin/executor/core-engine/CoreEngineRunner.js.map +1 -1
  12. package/bin/executor/model/ModelGenerate.d.ts.map +1 -1
  13. package/bin/executor/model/ModelGenerate.js +2 -1
  14. package/bin/executor/model/ModelGenerate.js.map +1 -1
  15. package/bin/executor/model/ModelStepRunner.d.ts +2 -0
  16. package/bin/executor/model/ModelStepRunner.d.ts.map +1 -1
  17. package/bin/executor/model/ModelStepRunner.js +33 -7
  18. package/bin/executor/model/ModelStepRunner.js.map +1 -1
  19. package/bin/executor/model/ModelStreamFailure.d.ts +22 -0
  20. package/bin/executor/model/ModelStreamFailure.d.ts.map +1 -0
  21. package/bin/executor/model/ModelStreamFailure.js +35 -0
  22. package/bin/executor/model/ModelStreamFailure.js.map +1 -0
  23. package/bin/group/GroupDispatchSession.d.ts +36 -0
  24. package/bin/group/GroupDispatchSession.d.ts.map +1 -0
  25. package/bin/group/GroupDispatchSession.js +154 -0
  26. package/bin/group/GroupDispatchSession.js.map +1 -0
  27. package/bin/group/GroupSession.d.ts +3 -1
  28. package/bin/group/GroupSession.d.ts.map +1 -1
  29. package/bin/group/GroupSession.js +65 -28
  30. package/bin/group/GroupSession.js.map +1 -1
  31. package/bin/index.d.ts +2 -0
  32. package/bin/index.d.ts.map +1 -1
  33. package/bin/index.js.map +1 -1
  34. package/bin/session/messages/SessionAssistantMessageWriter.d.ts.map +1 -1
  35. package/bin/session/messages/SessionAssistantMessageWriter.js +10 -6
  36. package/bin/session/messages/SessionAssistantMessageWriter.js.map +1 -1
  37. package/bin/types/group/DispatchStrategy.d.ts +3 -0
  38. package/bin/types/group/DispatchStrategy.d.ts.map +1 -1
  39. package/bin/types/group/DispatchStrategy.js +66 -18
  40. package/bin/types/group/DispatchStrategy.js.map +1 -1
  41. package/bin/types/group/GroupDispatchSession.d.ts +65 -0
  42. package/bin/types/group/GroupDispatchSession.d.ts.map +1 -0
  43. package/bin/types/group/GroupDispatchSession.js +8 -0
  44. package/bin/types/group/GroupDispatchSession.js.map +1 -0
  45. package/bin/types/group/GroupSession.d.ts +2 -0
  46. package/bin/types/group/GroupSession.d.ts.map +1 -1
  47. package/bin/types/group/GroupSessionStore.d.ts +6 -1
  48. package/bin/types/group/GroupSessionStore.d.ts.map +1 -1
  49. package/bin/types/plugin/PluginNotification.d.ts +31 -0
  50. package/bin/types/plugin/PluginNotification.d.ts.map +1 -0
  51. package/bin/types/plugin/PluginNotification.js +8 -0
  52. package/bin/types/plugin/PluginNotification.js.map +1 -0
  53. package/bin/workspace/store/LocalGroupDispatchSessionDataStore.d.ts +28 -0
  54. package/bin/workspace/store/LocalGroupDispatchSessionDataStore.d.ts.map +1 -0
  55. package/bin/workspace/store/LocalGroupDispatchSessionDataStore.js +102 -0
  56. package/bin/workspace/store/LocalGroupDispatchSessionDataStore.js.map +1 -0
  57. package/bin/workspace/store/LocalGroupSessionDataStore.d.ts +2 -0
  58. package/bin/workspace/store/LocalGroupSessionDataStore.d.ts.map +1 -1
  59. package/bin/workspace/store/LocalGroupSessionDataStore.js +25 -8
  60. package/bin/workspace/store/LocalGroupSessionDataStore.js.map +1 -1
  61. package/city-test/group.test.mjs +252 -1
  62. package/package.json +15 -17
  63. package/scripts/agent-multi-workspace.test.mjs +67 -0
  64. package/scripts/core-engine-context-compaction.test.mjs +49 -0
  65. package/scripts/core-engine-model-stream.test.mjs +69 -0
  66. package/src/agent/WorkspaceEntry.ts +1 -1
  67. package/src/city/types/PluginHostContext.ts +4 -0
  68. package/src/executor/core-engine/CoreEngineError.ts +8 -2
  69. package/src/executor/core-engine/CoreEngineRunner.ts +44 -0
  70. package/src/executor/model/ModelGenerate.ts +3 -1
  71. package/src/executor/model/ModelStepRunner.ts +36 -5
  72. package/src/executor/model/ModelStreamFailure.ts +44 -0
  73. package/src/group/GroupDispatchSession.ts +175 -0
  74. package/src/group/GroupSession.ts +83 -28
  75. package/src/index.ts +10 -0
  76. package/src/session/messages/SessionAssistantMessageWriter.ts +9 -5
  77. package/src/types/group/DispatchStrategy.ts +79 -19
  78. package/src/types/group/GroupDispatchSession.ts +80 -0
  79. package/src/types/group/GroupSession.ts +2 -0
  80. package/src/types/group/GroupSessionStore.ts +6 -1
  81. package/src/types/plugin/PluginNotification.ts +38 -0
  82. package/src/workspace/store/LocalGroupDispatchSessionDataStore.ts +118 -0
  83. package/src/workspace/store/LocalGroupSessionDataStore.ts +35 -9
  84. package/tsconfig.tsbuildinfo +1 -1
package/src/index.ts CHANGED
@@ -26,6 +26,11 @@ export type {
26
26
  GroupSessionHistoryMeta,
27
27
  GroupSessionStore,
28
28
  } from "./types/group/GroupSessionStore.js";
29
+ export type {
30
+ GroupDispatchSessionDataStore,
31
+ GroupDispatchTurnRecord,
32
+ GroupDispatchTurnStatus,
33
+ } from "./types/group/GroupDispatchSession.js";
29
34
  export { GroupSession, GroupSessions } from "./group/index.js";
30
35
  export type {
31
36
  GroupMemberRuntime,
@@ -238,6 +243,11 @@ export type {
238
243
  } from "./types/sdk/AgentSessionCompact.js";
239
244
  export type { PluginContext } from "./types/plugin/PluginContext.js";
240
245
  export type { AgentPluginContext } from "./types/plugin/AgentPluginContext.js";
246
+ export type {
247
+ PluginNotificationInput,
248
+ PluginNotificationPublisher,
249
+ PluginNotificationTopicInput,
250
+ } from "./types/plugin/PluginNotification.js";
241
251
  export type {
242
252
  PluginWebServices,
243
253
  } from "./types/plugin/PluginServices.js";
@@ -215,7 +215,8 @@ export class SessionAssistantMessageWriter {
215
215
  /** 在单写队列中应用标准模型事件。 */
216
216
  private async apply_model_event_serialized(event: ModelStreamEvent): Promise<void> {
217
217
  if (this.closed) throw new Error("Assistant Message writer is closed");
218
- if (event.type === "model_error") throw new Error(event.error.message);
218
+ // model_error Executor 的恢复策略消费;Writer 保留已经写入的草稿事实。
219
+ if (event.type === "model_error") return;
219
220
  if (event.type === "text_start" || event.type === "reasoning_start") {
220
221
  const type = event.type === "text_start" ? "text" : "reasoning";
221
222
  const part_id = `${type}:${generate_id()}`;
@@ -283,8 +284,9 @@ export class SessionAssistantMessageWriter {
283
284
  const tool = this.require_tool(tool_call_id);
284
285
  await this.upsert_tool(tool_call_id, {
285
286
  tool_name: tool.tool_name,
286
- state: "ready",
287
+ state: event.input_error ? "failed" : "ready",
287
288
  input: event.input,
289
+ ...(event.input_error ? { error: event.input_error } : {}),
288
290
  });
289
291
  }
290
292
  }
@@ -435,9 +437,11 @@ export class SessionAssistantMessageWriter {
435
437
 
436
438
  /** 从 Tool 失败输出中提取稳定错误文本。 */
437
439
  function read_tool_error(output: unknown): string {
438
- if (output && typeof output === "object" && "error" in output) {
439
- const error = (output as { error?: unknown }).error;
440
- if (typeof error === "string" && error.trim()) return error;
440
+ if (output && typeof output === "object") {
441
+ const result = output as { error?: unknown; message?: unknown; output?: unknown };
442
+ for (const candidate of [result.error, result.message, result.output]) {
443
+ if (typeof candidate === "string" && candidate.trim()) return candidate;
444
+ }
441
445
  }
442
446
  return "Tool execution failed";
443
447
  }
@@ -3,7 +3,12 @@
3
3
  import type { GroupMessage } from "@/types/group/Group.js";
4
4
  import type { Agent } from "@/agent/Agent.js";
5
5
  import type { AgentModel } from "@/agent/AgentModel.js";
6
- import type { ModelClient, ModelJsonValue } from "@downcity/type";
6
+ import type {
7
+ ModelClient,
8
+ ModelContent,
9
+ ModelJsonValue,
10
+ ModelMessage,
11
+ } from "@downcity/type";
7
12
  import { generate_model } from "@executor/model/ModelGenerate.js";
8
13
  import { z } from "zod";
9
14
 
@@ -49,6 +54,8 @@ export interface DispatchStrategy {
49
54
  readonly messages: readonly GroupMessage[];
50
55
  /** 当前 Group 成员快照。 */
51
56
  readonly members: readonly Agent[];
57
+ /** 当前 Dispatch Session Turn 的取消信号。 */
58
+ readonly abort_signal: AbortSignal;
52
59
  }): Promise<DispatchDecision> | DispatchDecision;
53
60
  }
54
61
 
@@ -65,6 +72,8 @@ const dispatch_group_input_schema = z.object({
65
72
  next: z.enum(["stop", "continue"]),
66
73
  });
67
74
 
75
+ const max_dispatch_model_steps = 3;
76
+
68
77
  /** 使用 Group.model 理解群聊意图并通过内部 tool call 生成成员投递决定。 */
69
78
  export class AiDispatchStrategy implements DispatchStrategy {
70
79
  private readonly model?: ModelClient;
@@ -79,29 +88,45 @@ export class AiDispatchStrategy implements DispatchStrategy {
79
88
  readonly pending_messages: readonly GroupMessage[];
80
89
  readonly messages: readonly GroupMessage[];
81
90
  readonly members: readonly Agent[];
91
+ readonly abort_signal: AbortSignal;
82
92
  }): Promise<DispatchDecision> {
83
93
  if (!this.model) {
84
94
  throw new Error("Group requires a configured model for dispatch");
85
95
  }
86
96
  try {
87
- const result = await generate_model(this.model, {
88
- messages: [
89
- { role: "system", content: [{ type: "text", text: "你是群聊消息调度器。你只能调用 dispatch_group,不回答用户问题。普通任务默认只选择一个最合适的成员;只有用户明确要求多人分别回答,或确实存在并行的独立工作时,才把多个成员放在同一阶段。外层 steps 阶段按顺序执行,后一个阶段等待前一个阶段完成。只有真实存在的成员才能被选择。" }] },
90
- { role: "user", content: [{ type: "text", text: build_dispatch_prompt(input) }] },
91
- ],
92
- tools: [{
93
- name: "dispatch_group",
94
- description: "提交当前 Group 的成员投递路径。只调用一次;不要输出普通文本。没有成员需要回复时使用空 steps 和 next=stop。",
95
- input_schema: z.toJSONSchema(dispatch_group_input_schema) as Record<string, ModelJsonValue>,
96
- }],
97
- tool_choice: { type: "tool", tool_name: "dispatch_group" },
98
- max_output_tokens: 600,
99
- reasoning: { enabled: false },
100
- });
101
- if (result.tool_calls.length !== 1 || result.tool_calls[0]?.tool_name !== "dispatch_group") {
102
- throw new Error("Group dispatch model did not call dispatch_group");
97
+ const messages: ModelMessage[] = [
98
+ { role: "system", content: [{ type: "text", text: "你是群聊消息调度器。你只能调用 dispatch_group,不回答用户问题。普通任务默认只选择一个最合适的成员;只有用户明确要求多人分别回答,或确实存在并行的独立工作时,才把多个成员放在同一阶段。外层 steps 阶段按顺序执行,后一个阶段等待前一个阶段完成。只有真实存在的成员才能被选择。" }] },
99
+ { role: "user", content: [{ type: "text", text: build_dispatch_prompt(input) }] },
100
+ ];
101
+ let protocol_error = "Group dispatch model did not call dispatch_group";
102
+ for (let step_index = 0; step_index < max_dispatch_model_steps; step_index += 1) {
103
+ const result = await generate_model(this.model, {
104
+ messages,
105
+ tools: [{
106
+ name: "dispatch_group",
107
+ description: "提交当前 Group 的成员投递路径。只调用一次;不要输出普通文本。没有成员需要回复时使用空 steps 和 next=stop。",
108
+ input_schema: z.toJSONSchema(dispatch_group_input_schema) as Record<string, ModelJsonValue>,
109
+ }],
110
+ tool_choice: { type: "tool", tool_name: "dispatch_group" },
111
+ max_output_tokens: 600,
112
+ reasoning: { enabled: false },
113
+ }, input.abort_signal);
114
+ const dispatch_call = result.tool_calls.length === 1 && result.tool_calls[0]?.tool_name === "dispatch_group"
115
+ ? result.tool_calls[0]
116
+ : undefined;
117
+ if (dispatch_call) {
118
+ try {
119
+ return normalize_dispatch_tool_input(dispatch_call.input, input.members);
120
+ } catch (error) {
121
+ protocol_error = error instanceof Error ? error.message : String(error);
122
+ }
123
+ } else {
124
+ protocol_error = "Group dispatch model did not call dispatch_group exactly once";
125
+ }
126
+ if (step_index + 1 >= max_dispatch_model_steps) break;
127
+ append_dispatch_retry_messages(messages, result.text, result.tool_calls, protocol_error);
103
128
  }
104
- return normalize_dispatch_tool_input(result.tool_calls[0].input, input.members);
129
+ throw new Error(`${protocol_error} after ${max_dispatch_model_steps} attempts`);
105
130
  } catch (error) {
106
131
  const detail = error instanceof Error ? error.message : String(error);
107
132
  throw new Error(`Group dispatch model failed: ${detail}`, { cause: error });
@@ -109,6 +134,41 @@ export class AiDispatchStrategy implements DispatchStrategy {
109
134
  }
110
135
  }
111
136
 
137
+ /** 把协议错误作为下一模型 step 的显式上下文,允许模型在同一调度 Turn 内纠正。 */
138
+ function append_dispatch_retry_messages(
139
+ messages: ModelMessage[],
140
+ text: string,
141
+ tool_calls: readonly { type: "tool_call"; tool_call_id: string; tool_name: string; input: ModelJsonValue }[],
142
+ error: string,
143
+ ): void {
144
+ const assistant_content: ModelContent[] = [
145
+ ...(text.trim() ? [{ type: "text" as const, text }] : []),
146
+ ...tool_calls,
147
+ ];
148
+ if (assistant_content.length > 0) {
149
+ messages.push({ role: "assistant", content: assistant_content });
150
+ }
151
+ if (tool_calls.length > 0) {
152
+ messages.push({
153
+ role: "tool",
154
+ content: tool_calls.map((tool_call) => ({
155
+ type: "tool_result" as const,
156
+ tool_call_id: tool_call.tool_call_id,
157
+ tool_name: tool_call.tool_name,
158
+ outcome: "failed" as const,
159
+ content: [{ type: "text" as const, text: error }],
160
+ })),
161
+ });
162
+ }
163
+ messages.push({
164
+ role: "user",
165
+ content: [{
166
+ type: "text",
167
+ text: `上一响应不符合调度协议:${error}。请只调用一次 dispatch_group。`,
168
+ }],
169
+ });
170
+ }
171
+
112
172
  /** 为 AI 调度器构造稳定、有限长度的群聊上下文。 */
113
173
  function build_dispatch_prompt(input: {
114
174
  readonly trigger: DispatchTrigger;
@@ -152,7 +212,7 @@ function normalize_dispatch_tool_input(value: unknown, members: readonly Agent[]
152
212
  member_ids: unique_member_ids,
153
213
  response_mode: unique_member_ids.length > 1 ? "parallel" as const : "single" as const,
154
214
  depends_on_node_ids: index === 0 ? [] : [`step-${index - 1}`],
155
- instruction: "根据当前 Group 上下文判断是否需要回复,只代表自己发言;不需要时返回空内容。",
215
+ instruction: "你已被 Group 调度选中,请直接针对当前消息给出回复,并且只代表自己发言。",
156
216
  };
157
217
  });
158
218
  if (nodes.length === 0 && parsed.next === "continue") {
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Group Dispatch Session 领域类型。
3
+ *
4
+ * Dispatch Session 只记录群聊调度 Turn,不复制 GroupMessage 正文;消息正文仍由
5
+ * GroupSession 的消息日志作为唯一事实源,调度记录通过消息 ID 建立稳定引用。
6
+ */
7
+
8
+ import type { Agent } from "@/agent/Agent.js";
9
+ import type { GroupMessage } from "@/types/group/Group.js";
10
+ import type {
11
+ DispatchDecision,
12
+ DispatchStrategy,
13
+ DispatchTrigger,
14
+ } from "@/types/group/DispatchStrategy.js";
15
+
16
+ /** Dispatch Session 中一次调度 Turn 的持久化状态。 */
17
+ export type GroupDispatchTurnStatus =
18
+ | "queued"
19
+ | "running"
20
+ | "completed"
21
+ | "failed"
22
+ | "stopped";
23
+
24
+ /** Dispatch Session 中一次调度 Turn 的完整持久化快照。 */
25
+ export interface GroupDispatchTurnRecord {
26
+ /** 当前调度 Turn 的稳定标识。 */
27
+ readonly dispatch_id: string;
28
+ /** 本次调度由用户消息还是自动传播触发。 */
29
+ readonly trigger: DispatchTrigger;
30
+ /** 当前调度所围绕的 GroupMessage 标识。 */
31
+ readonly message_id: string;
32
+ /** 当前调度需要一起判断的新增 GroupMessage 标识。 */
33
+ readonly pending_message_ids: readonly string[];
34
+ /** 当前调度 Turn 的生命周期状态。 */
35
+ readonly status: GroupDispatchTurnStatus;
36
+ /** 调度成功后已经校验并持久化的成员投递决定。 */
37
+ readonly decision?: DispatchDecision;
38
+ /** 调度失败或停止时用于诊断的稳定错误文本。 */
39
+ readonly error?: string;
40
+ /** 当前调度 Turn 首次入队的 Unix 毫秒时间戳。 */
41
+ readonly created_at: number;
42
+ /** 当前状态最近一次提交的 Unix 毫秒时间戳。 */
43
+ readonly updated_at: number;
44
+ }
45
+
46
+ /** Dispatch Session 持久化所需的最小数据能力。 */
47
+ export interface GroupDispatchSessionDataStore {
48
+ /** 读取每个 dispatch_id 的最新调度 Turn 快照。 */
49
+ list_turns(): Promise<GroupDispatchTurnRecord[]>;
50
+ /** 以追加日志方式提交一个调度 Turn 的最新完整快照。 */
51
+ append_turn(turn: GroupDispatchTurnRecord): Promise<void>;
52
+ }
53
+
54
+ /** Dispatch Session 成功完成一次调度后的运行结果。 */
55
+ export interface GroupDispatchSessionResult {
56
+ /** 当前调度 Turn 的稳定标识。 */
57
+ readonly dispatch_id: string;
58
+ /** 已经校验并持久化的成员投递决定。 */
59
+ readonly decision: DispatchDecision;
60
+ }
61
+
62
+ /** 创建 Group Dispatch Session 的运行依赖。 */
63
+ export interface GroupDispatchSessionOptions {
64
+ /** 当前 GroupSession 使用的消息调度策略。 */
65
+ readonly dispatch_strategy: DispatchStrategy;
66
+ }
67
+
68
+ /** 向 Group Dispatch Session 提交一次调度判断的输入快照。 */
69
+ export interface GroupDispatchSessionInput {
70
+ /** 本次调度由用户消息还是自动传播触发。 */
71
+ readonly trigger: DispatchTrigger;
72
+ /** 当前调度所围绕的 GroupMessage。 */
73
+ readonly message: GroupMessage;
74
+ /** 当前调度需要一起判断的新增 GroupMessage。 */
75
+ readonly pending_messages: readonly GroupMessage[];
76
+ /** 当前 GroupSession 的完整共享消息快照。 */
77
+ readonly messages: readonly GroupMessage[];
78
+ /** 当前 Group 的成员快照。 */
79
+ readonly members: readonly Agent[];
80
+ }
@@ -143,6 +143,8 @@ export interface GroupTurnRuntime {
143
143
  readonly root_message_id: string;
144
144
  /** 用户消息写入时冻结的上下文消息标识。 */
145
145
  readonly context_message_ids: readonly string[];
146
+ /** 当前 Turn 位于初始用户调度还是自动传播阶段。 */
147
+ dispatch_stage: "user" | "auto";
146
148
  /** 当前 GroupTurn 的异步 user dispatch。 */
147
149
  user_dispatch?: Promise<void>;
148
150
  /** 当前 GroupTurn 是否等待 GroupSession 的 auto dispatch。 */
@@ -1,12 +1,13 @@
1
1
  /** GroupSession 持久化协议:只描述 GroupSession 所需的底层数据能力。 */
2
2
 
3
3
  import type { GroupMessage } from "@/types/group/Group.js";
4
+ import type { GroupDispatchSessionDataStore } from "@/types/group/GroupDispatchSession.js";
4
5
  import type { FileSystem } from "@downcity/workspace";
5
6
 
6
7
  /** GroupSession 的持久化元数据。 */
7
8
  export interface GroupSessionHistoryMeta {
8
9
  /** metadata schema 版本。 */
9
- readonly v: 1;
10
+ readonly v: 2;
10
11
  /** 当前 GroupSession 标识。 */
11
12
  readonly session_id: string;
12
13
  /** 所属 Group 标识。 */
@@ -37,12 +38,16 @@ export interface GroupSessionTurnCheckpoint {
37
38
  readonly root_message_id: string;
38
39
  /** 用户消息写入时冻结的上下文消息标识。 */
39
40
  readonly context_message_ids: readonly string[];
41
+ /** 恢复后应重新执行初始用户调度,还是继续等待自动传播收口。 */
42
+ readonly dispatch_stage: "user" | "auto";
40
43
  }
41
44
 
42
45
  /** 单个 GroupSession 的持久化数据视图。 */
43
46
  export interface GroupSessionDataStore {
44
47
  /** 当前 GroupSession 标识。 */
45
48
  readonly session_id: string;
49
+ /** 当前 GroupSession 独享的 Dispatch Session 持久化视图。 */
50
+ readonly dispatch_session: GroupDispatchSessionDataStore;
46
51
  /** 初始化当前 GroupSession 的存储布局。 */
47
52
  initialize(): Promise<void>;
48
53
  /** 读取完整共享消息历史。 */
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Agent Plugin 向宿主发布用户注意事项的通知协议。
3
+ *
4
+ * Plugin 只描述通知内容与自身工作区路由,不感知 Desktop、Dock、持久化或已读策略。
5
+ * 宿主负责绑定 Plugin 身份、聚合通知并决定具体呈现方式。
6
+ */
7
+
8
+ import type { JsonObject } from "@/types/common/Json.js";
9
+
10
+ /** Agent Plugin 发布的一条宿主通知。 */
11
+ export interface PluginNotificationInput {
12
+ /** Plugin 内稳定的聚合键;同一键的新通知替换旧的未读通知。 */
13
+ readonly topic_key: string;
14
+
15
+ /** 简短的用户可见标题。 */
16
+ readonly title: string;
17
+
18
+ /** 可选的用户可见补充说明。 */
19
+ readonly body?: string;
20
+
21
+ /** 打开通知时使用的当前 Plugin 工作区 JSON 路由。 */
22
+ readonly route?: JsonObject;
23
+ }
24
+
25
+ /** Plugin 要清除的一项本地通知主题。 */
26
+ export interface PluginNotificationTopicInput {
27
+ /** Plugin 内稳定的聚合键。 */
28
+ readonly topic_key: string;
29
+ }
30
+
31
+ /** 宿主注入给 Agent Plugin factory 的通知发布能力。 */
32
+ export interface PluginNotificationPublisher {
33
+ /** 发布一条由当前 Plugin 拥有的通知。 */
34
+ publish(input: PluginNotificationInput): Promise<void>;
35
+
36
+ /** 清除当前 Plugin 命名空间内一个主题的未读通知。 */
37
+ dismiss(input: PluginNotificationTopicInput): Promise<void>;
38
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * LocalGroupDispatchSessionDataStore:Group Dispatch Session 的追加式 Turn 日志。
3
+ *
4
+ * 每次状态迁移都追加完整快照;读取时按 dispatch_id 选择最后一条记录,因此中断前
5
+ * 已提交的状态不会被覆盖,文件尾部的非完整写入也可以安全修复。
6
+ */
7
+
8
+ import path from "node:path";
9
+ import type { FileSystem } from "@downcity/workspace";
10
+ import type {
11
+ GroupDispatchSessionDataStore,
12
+ GroupDispatchTurnRecord,
13
+ GroupDispatchTurnStatus,
14
+ } from "@/types/group/GroupDispatchSession.js";
15
+
16
+ const dispatch_turn_statuses = new Set<GroupDispatchTurnStatus>([
17
+ "queued",
18
+ "running",
19
+ "completed",
20
+ "failed",
21
+ "stopped",
22
+ ]);
23
+
24
+ /** 基于 GroupSession 私有目录的 Dispatch Session Store。 */
25
+ export class LocalGroupDispatchSessionDataStore implements GroupDispatchSessionDataStore {
26
+ private readonly files: FileSystem;
27
+ private readonly dispatch_root_path: string;
28
+ private readonly turns_file_path: string;
29
+ private readonly transaction_lock_path: string;
30
+
31
+ constructor(options: {
32
+ /** 当前 Group StorageScope 文件能力。 */
33
+ readonly files: FileSystem;
34
+ /** 当前 GroupSession 的私有存储根目录。 */
35
+ readonly session_root_path: string;
36
+ }) {
37
+ this.files = options.files;
38
+ this.dispatch_root_path = path.join(options.session_root_path, "dispatch");
39
+ this.turns_file_path = path.join(this.dispatch_root_path, "turns.jsonl");
40
+ this.transaction_lock_path = path.join(this.dispatch_root_path, ".lock");
41
+ }
42
+
43
+ /** 读取每个调度 Turn 最后一次成功提交的状态。 */
44
+ async list_turns(): Promise<GroupDispatchTurnRecord[]> {
45
+ if (!(await this.files.path_exists(this.turns_file_path))) return [];
46
+ await this.files.ensure_directory(this.dispatch_root_path);
47
+ return await this.files.with_file_lock(this.transaction_lock_path, async () => {
48
+ const records = await this.read_records(true);
49
+ const turns_by_id = new Map<string, GroupDispatchTurnRecord>();
50
+ for (const record of records) {
51
+ turns_by_id.delete(record.dispatch_id);
52
+ turns_by_id.set(record.dispatch_id, record);
53
+ }
54
+ return [...turns_by_id.values()];
55
+ });
56
+ }
57
+
58
+ /** 追加一个完整状态快照,状态提交与其它调度 Turn 串行。 */
59
+ async append_turn(turn: GroupDispatchTurnRecord): Promise<void> {
60
+ assert_dispatch_turn(turn);
61
+ await this.files.ensure_directory(this.dispatch_root_path);
62
+ await this.files.with_file_lock(this.transaction_lock_path, async () => {
63
+ await this.files.append_file(this.turns_file_path, `${JSON.stringify(turn)}\n`);
64
+ });
65
+ }
66
+
67
+ /** 读取状态日志;只允许修复进程中断产生的最后一条残缺记录。 */
68
+ private async read_records(repair_tail: boolean): Promise<GroupDispatchTurnRecord[]> {
69
+ const content = (await this.files.read_file(this.turns_file_path)).toString("utf8");
70
+ const lines = content.split("\n");
71
+ const records: GroupDispatchTurnRecord[] = [];
72
+ for (let line_index = 0; line_index < lines.length; line_index += 1) {
73
+ const line = lines[line_index];
74
+ if (!line.trim()) continue;
75
+ try {
76
+ const record = JSON.parse(line) as GroupDispatchTurnRecord;
77
+ assert_dispatch_turn(record);
78
+ records.push(record);
79
+ } catch (error) {
80
+ const is_tail = lines.slice(line_index + 1).every((item) => !item.trim());
81
+ if (!repair_tail || !is_tail) throw error;
82
+ const repaired = records.map((record) => JSON.stringify(record)).join("\n");
83
+ await this.files.write_file_atomically(
84
+ this.turns_file_path,
85
+ repaired ? `${repaired}\n` : "",
86
+ );
87
+ break;
88
+ }
89
+ }
90
+ return records;
91
+ }
92
+ }
93
+
94
+ /** 防止损坏的持久化记录进入 Dispatch Session 恢复流程。 */
95
+ function assert_dispatch_turn(value: GroupDispatchTurnRecord): void {
96
+ if (!value || typeof value !== "object") throw new Error("Invalid Group dispatch turn record");
97
+ if (typeof value.dispatch_id !== "string" || !value.dispatch_id.trim()) {
98
+ throw new Error("Invalid Group dispatch turn id");
99
+ }
100
+ if (value.trigger !== "user" && value.trigger !== "auto") {
101
+ throw new Error(`Invalid Group dispatch trigger: ${String(value.trigger)}`);
102
+ }
103
+ if (typeof value.message_id !== "string" || !value.message_id.trim()) {
104
+ throw new Error(`Invalid Group dispatch message id: ${value.dispatch_id}`);
105
+ }
106
+ if (!Array.isArray(value.pending_message_ids) || value.pending_message_ids.some((item) => typeof item !== "string" || !item.trim())) {
107
+ throw new Error(`Invalid Group dispatch pending messages: ${value.dispatch_id}`);
108
+ }
109
+ if (!dispatch_turn_statuses.has(value.status)) {
110
+ throw new Error(`Invalid Group dispatch status: ${String(value.status)}`);
111
+ }
112
+ if (!Number.isFinite(value.created_at) || !Number.isFinite(value.updated_at)) {
113
+ throw new Error(`Invalid Group dispatch timestamps: ${value.dispatch_id}`);
114
+ }
115
+ if (value.status === "completed" && !value.decision) {
116
+ throw new Error(`Completed Group dispatch is missing its decision: ${value.dispatch_id}`);
117
+ }
118
+ }
@@ -3,6 +3,8 @@
3
3
  import path from "node:path";
4
4
  import type { FileSystem } from "@downcity/workspace";
5
5
  import type { GroupMessage } from "@/types/group/Group.js";
6
+ import type { GroupDispatchSessionDataStore } from "@/types/group/GroupDispatchSession.js";
7
+ import { LocalGroupDispatchSessionDataStore } from "@/workspace/store/LocalGroupDispatchSessionDataStore.js";
6
8
  import type {
7
9
  GroupSessionDataStore,
8
10
  GroupSessionHistoryMeta,
@@ -12,6 +14,7 @@ import type {
12
14
  /** 单个 GroupSession 的本地文件存储。 */
13
15
  export class LocalGroupSessionDataStore implements GroupSessionDataStore {
14
16
  readonly session_id: string;
17
+ readonly dispatch_session: GroupDispatchSessionDataStore;
15
18
  private readonly files: FileSystem;
16
19
  private readonly group_id: string;
17
20
  private readonly session_root_path: string;
@@ -38,6 +41,10 @@ export class LocalGroupSessionDataStore implements GroupSessionDataStore {
38
41
  "sessions",
39
42
  encodeURIComponent(this.session_id),
40
43
  );
44
+ this.dispatch_session = new LocalGroupDispatchSessionDataStore({
45
+ files: this.files,
46
+ session_root_path: this.session_root_path,
47
+ });
41
48
  this.metadata_file_path = path.join(this.session_root_path, "meta.json");
42
49
  this.messages_file_path = path.join(this.session_root_path, "messages.jsonl");
43
50
  this.transaction_lock_path = path.join(this.session_root_path, ".lock");
@@ -82,7 +89,10 @@ export class LocalGroupSessionDataStore implements GroupSessionDataStore {
82
89
  if (!(await this.files.path_exists(this.metadata_file_path))) {
83
90
  return this.create_initial_metadata();
84
91
  }
85
- return await this.read_metadata_unlocked();
92
+ return await this.files.with_file_lock(
93
+ this.transaction_lock_path,
94
+ async () => await this.read_metadata_unlocked(),
95
+ );
86
96
  }
87
97
 
88
98
  /** 在调用方已经持有事务锁时读取 metadata。 */
@@ -92,15 +102,21 @@ export class LocalGroupSessionDataStore implements GroupSessionDataStore {
92
102
  }
93
103
  const raw = JSON.parse(
94
104
  (await this.files.read_file(this.metadata_file_path)).toString("utf8"),
95
- ) as Partial<GroupSessionHistoryMeta>;
105
+ ) as Omit<Partial<GroupSessionHistoryMeta>, "v" | "pending_turns"> & {
106
+ readonly v?: unknown;
107
+ readonly pending_turns?: unknown[];
108
+ };
109
+ if (raw.v !== 1 && raw.v !== 2) {
110
+ throw new Error(`Unsupported GroupSession metadata version: ${String(raw.v)}`);
111
+ }
96
112
  if (raw.session_id !== this.session_id) {
97
113
  throw new Error(`Invalid GroupSession ownership metadata: ${this.session_id}`);
98
114
  }
99
115
  if (raw.group_id && raw.group_id !== this.group_id) {
100
116
  throw new Error(`GroupSession "${this.session_id}" belongs to another Group`);
101
117
  }
102
- return {
103
- v: 1,
118
+ const metadata: GroupSessionHistoryMeta = {
119
+ v: 2,
104
120
  session_id: this.session_id,
105
121
  group_id: String(raw.group_id || this.group_id),
106
122
  ...(typeof raw.workspace_id === "string" && raw.workspace_id.trim() ? { workspace_id: raw.workspace_id.trim() } : {}),
@@ -112,12 +128,14 @@ export class LocalGroupSessionDataStore implements GroupSessionDataStore {
112
128
  ? { member_session_ids: normalize_member_session_ids(raw.member_session_ids) }
113
129
  : {}),
114
130
  ...(Array.isArray(raw.pending_turns)
115
- ? { pending_turns: normalize_pending_turns(raw.pending_turns) }
131
+ ? { pending_turns: normalize_pending_turns(raw.pending_turns, raw.v === 1 ? "auto" : undefined) }
116
132
  : {}),
117
133
  ...(Array.isArray(raw.auto_frontier_message_ids)
118
134
  ? { auto_frontier_message_ids: normalize_message_ids(raw.auto_frontier_message_ids) }
119
135
  : {}),
120
136
  };
137
+ if (raw.v === 1) await this.write_metadata_unlocked(metadata);
138
+ return metadata;
121
139
  }
122
140
 
123
141
  /** 原子写入当前 metadata。 */
@@ -148,7 +166,7 @@ export class LocalGroupSessionDataStore implements GroupSessionDataStore {
148
166
  ...patch,
149
167
  session_id: this.session_id,
150
168
  group_id: this.group_id,
151
- v: 1 as const,
169
+ v: 2 as const,
152
170
  };
153
171
  await this.write_metadata_unlocked(next_metadata);
154
172
  return next_metadata;
@@ -200,7 +218,7 @@ export class LocalGroupSessionDataStore implements GroupSessionDataStore {
200
218
  private create_initial_metadata(): GroupSessionHistoryMeta {
201
219
  const created_at = Date.now();
202
220
  return {
203
- v: 1,
221
+ v: 2,
204
222
  session_id: this.session_id,
205
223
  group_id: this.group_id,
206
224
  created_at,
@@ -214,7 +232,10 @@ function normalize_member_session_ids(input: object): Record<string, string> {
214
232
  return Object.fromEntries(Object.entries(input).filter(([agent_id, session_id]) => Boolean(agent_id.trim()) && typeof session_id === "string" && session_id.trim()));
215
233
  }
216
234
 
217
- function normalize_pending_turns(input: unknown[]): GroupSessionTurnCheckpoint[] {
235
+ function normalize_pending_turns(
236
+ input: unknown[],
237
+ default_dispatch_stage?: GroupSessionTurnCheckpoint["dispatch_stage"],
238
+ ): GroupSessionTurnCheckpoint[] {
218
239
  return input.flatMap((item) => {
219
240
  if (!item || typeof item !== "object") return [];
220
241
  const candidate = item as Partial<GroupSessionTurnCheckpoint>;
@@ -223,7 +244,12 @@ function normalize_pending_turns(input: unknown[]): GroupSessionTurnCheckpoint[]
223
244
  const context_message_ids = Array.isArray(candidate.context_message_ids)
224
245
  ? normalize_message_ids(candidate.context_message_ids)
225
246
  : [];
226
- return turn_id && root_message_id ? [{ turn_id, root_message_id, context_message_ids }] : [];
247
+ const dispatch_stage = candidate.dispatch_stage === "user" || candidate.dispatch_stage === "auto"
248
+ ? candidate.dispatch_stage
249
+ : default_dispatch_stage;
250
+ return turn_id && root_message_id && dispatch_stage
251
+ ? [{ turn_id, root_message_id, context_message_ids, dispatch_stage }]
252
+ : [];
227
253
  });
228
254
  }
229
255