@downcity/agent 1.1.401 → 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 (83) hide show
  1. package/README.md +4 -3
  2. package/bin/agent/WorkspaceEntry.js +1 -1
  3. package/bin/agent/WorkspaceEntry.js.map +1 -1
  4. package/bin/city/types/PluginHostContext.d.ts +3 -0
  5. package/bin/city/types/PluginHostContext.d.ts.map +1 -1
  6. package/bin/executor/core-engine/CoreEngineError.js +8 -2
  7. package/bin/executor/core-engine/CoreEngineError.js.map +1 -1
  8. package/bin/executor/core-engine/CoreEngineRunner.d.ts.map +1 -1
  9. package/bin/executor/core-engine/CoreEngineRunner.js +36 -0
  10. package/bin/executor/core-engine/CoreEngineRunner.js.map +1 -1
  11. package/bin/executor/model/ModelGenerate.d.ts.map +1 -1
  12. package/bin/executor/model/ModelGenerate.js +2 -1
  13. package/bin/executor/model/ModelGenerate.js.map +1 -1
  14. package/bin/executor/model/ModelStepRunner.d.ts +2 -0
  15. package/bin/executor/model/ModelStepRunner.d.ts.map +1 -1
  16. package/bin/executor/model/ModelStepRunner.js +33 -7
  17. package/bin/executor/model/ModelStepRunner.js.map +1 -1
  18. package/bin/executor/model/ModelStreamFailure.d.ts +22 -0
  19. package/bin/executor/model/ModelStreamFailure.d.ts.map +1 -0
  20. package/bin/executor/model/ModelStreamFailure.js +35 -0
  21. package/bin/executor/model/ModelStreamFailure.js.map +1 -0
  22. package/bin/group/GroupDispatchSession.d.ts +36 -0
  23. package/bin/group/GroupDispatchSession.d.ts.map +1 -0
  24. package/bin/group/GroupDispatchSession.js +154 -0
  25. package/bin/group/GroupDispatchSession.js.map +1 -0
  26. package/bin/group/GroupSession.d.ts +3 -1
  27. package/bin/group/GroupSession.d.ts.map +1 -1
  28. package/bin/group/GroupSession.js +65 -28
  29. package/bin/group/GroupSession.js.map +1 -1
  30. package/bin/index.d.ts +2 -0
  31. package/bin/index.d.ts.map +1 -1
  32. package/bin/index.js.map +1 -1
  33. package/bin/session/messages/SessionAssistantMessageWriter.d.ts.map +1 -1
  34. package/bin/session/messages/SessionAssistantMessageWriter.js +10 -6
  35. package/bin/session/messages/SessionAssistantMessageWriter.js.map +1 -1
  36. package/bin/types/group/DispatchStrategy.d.ts +3 -0
  37. package/bin/types/group/DispatchStrategy.d.ts.map +1 -1
  38. package/bin/types/group/DispatchStrategy.js +66 -18
  39. package/bin/types/group/DispatchStrategy.js.map +1 -1
  40. package/bin/types/group/GroupDispatchSession.d.ts +65 -0
  41. package/bin/types/group/GroupDispatchSession.d.ts.map +1 -0
  42. package/bin/types/group/GroupDispatchSession.js +8 -0
  43. package/bin/types/group/GroupDispatchSession.js.map +1 -0
  44. package/bin/types/group/GroupSession.d.ts +2 -0
  45. package/bin/types/group/GroupSession.d.ts.map +1 -1
  46. package/bin/types/group/GroupSessionStore.d.ts +6 -1
  47. package/bin/types/group/GroupSessionStore.d.ts.map +1 -1
  48. package/bin/types/plugin/PluginNotification.d.ts +31 -0
  49. package/bin/types/plugin/PluginNotification.d.ts.map +1 -0
  50. package/bin/types/plugin/PluginNotification.js +8 -0
  51. package/bin/types/plugin/PluginNotification.js.map +1 -0
  52. package/bin/workspace/store/LocalGroupDispatchSessionDataStore.d.ts +28 -0
  53. package/bin/workspace/store/LocalGroupDispatchSessionDataStore.d.ts.map +1 -0
  54. package/bin/workspace/store/LocalGroupDispatchSessionDataStore.js +102 -0
  55. package/bin/workspace/store/LocalGroupDispatchSessionDataStore.js.map +1 -0
  56. package/bin/workspace/store/LocalGroupSessionDataStore.d.ts +2 -0
  57. package/bin/workspace/store/LocalGroupSessionDataStore.d.ts.map +1 -1
  58. package/bin/workspace/store/LocalGroupSessionDataStore.js +25 -8
  59. package/bin/workspace/store/LocalGroupSessionDataStore.js.map +1 -1
  60. package/city-test/group.test.mjs +252 -1
  61. package/package.json +4 -4
  62. package/scripts/agent-multi-workspace.test.mjs +67 -0
  63. package/scripts/core-engine-context-compaction.test.mjs +49 -0
  64. package/scripts/core-engine-model-stream.test.mjs +69 -0
  65. package/src/agent/WorkspaceEntry.ts +1 -1
  66. package/src/city/types/PluginHostContext.ts +4 -0
  67. package/src/executor/core-engine/CoreEngineError.ts +8 -2
  68. package/src/executor/core-engine/CoreEngineRunner.ts +44 -0
  69. package/src/executor/model/ModelGenerate.ts +3 -1
  70. package/src/executor/model/ModelStepRunner.ts +36 -5
  71. package/src/executor/model/ModelStreamFailure.ts +44 -0
  72. package/src/group/GroupDispatchSession.ts +175 -0
  73. package/src/group/GroupSession.ts +83 -28
  74. package/src/index.ts +10 -0
  75. package/src/session/messages/SessionAssistantMessageWriter.ts +9 -5
  76. package/src/types/group/DispatchStrategy.ts +79 -19
  77. package/src/types/group/GroupDispatchSession.ts +80 -0
  78. package/src/types/group/GroupSession.ts +2 -0
  79. package/src/types/group/GroupSessionStore.ts +6 -1
  80. package/src/types/plugin/PluginNotification.ts +38 -0
  81. package/src/workspace/store/LocalGroupDispatchSessionDataStore.ts +118 -0
  82. package/src/workspace/store/LocalGroupSessionDataStore.ts +35 -9
  83. package/tsconfig.tsbuildinfo +1 -1
@@ -77,6 +77,73 @@ test("one Agent enters multiple Workspaces with contextual Plugin execution", as
77
77
 
78
78
  });
79
79
 
80
+ test("PluginContext sessions keep the current Workspace binding", async () => {
81
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "downcity-plugin-sessions-"));
82
+ let linked_session_id = "";
83
+ const plugin = create_plugin({
84
+ name: "session_probe",
85
+ title: "Session Probe",
86
+ description: "Validates the current Workspace Session view.",
87
+ actions: {
88
+ inspect: {
89
+ description: "Restore one Session and create another in the current Workspace.",
90
+ execute: async ({ context }) => {
91
+ const linked_session = await context.sessions.get(linked_session_id);
92
+ const task_session = await context.sessions.create();
93
+ await context.sessions.runtime(linked_session_id).append_assistant_message({
94
+ text: "task completed",
95
+ });
96
+ return {
97
+ success: true,
98
+ data: {
99
+ linked_workspace_id: linked_session.workspace_id,
100
+ task_workspace_id: task_session.workspace_id,
101
+ },
102
+ };
103
+ },
104
+ },
105
+ },
106
+ });
107
+ const agent = new Agent({ id: "plugin-session-agent", plugins: [plugin] });
108
+ const entry = create_workspace_entry(agent, new Workspace({
109
+ id: "plugin-session-workspace",
110
+ path: root,
111
+ data_root_path: path.join(root, "data"),
112
+ }));
113
+
114
+ try {
115
+ const linked_session = await entry.sessions.create();
116
+ linked_session_id = linked_session.id;
117
+ const mutations = [];
118
+ const unsubscribe = linked_session.subscribe((mutation) => {
119
+ mutations.push(mutation);
120
+ });
121
+ const result = await entry.plugins.run_action({
122
+ plugin: "session_probe",
123
+ action: "inspect",
124
+ });
125
+ unsubscribe();
126
+ assert.equal(result.success, true);
127
+ assert.deepEqual(result.data, {
128
+ linked_workspace_id: "plugin-session-workspace",
129
+ task_workspace_id: "plugin-session-workspace",
130
+ });
131
+ const messages = await linked_session.messages();
132
+ assert.equal(messages.items.at(-1)?.type, "assistant");
133
+ assert.equal(messages.items.at(-1)?.parts.at(-1)?.type, "text");
134
+ assert.equal(messages.items.at(-1)?.parts.at(-1)?.text, "task completed");
135
+ assert.equal(
136
+ mutations.some((mutation) =>
137
+ mutation.variant === "message" && mutation.type === "assistant"
138
+ ),
139
+ true,
140
+ );
141
+ } finally {
142
+ await agent.dispose();
143
+ await fs.rm(root, { recursive: true, force: true });
144
+ }
145
+ });
146
+
80
147
  test("Plugin runtime data is isolated by Agent and shared across Workspaces", async () => {
81
148
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "downcity-agent-plugin-data-"));
82
149
  const contexts = [];
@@ -146,6 +146,55 @@ test("新的持久化 Summary 只按 50% 水位验收一次", async () => {
146
146
  assert.equal(second.compact_required, undefined);
147
147
  });
148
148
 
149
+ test("Provider 在输出前发生可重试流错误时自动重试", async () => {
150
+ let call_count = 0;
151
+ const model = {
152
+ id: "retryable-stream-model",
153
+ async stream() {
154
+ call_count += 1;
155
+ return new ReadableStream({
156
+ start(controller) {
157
+ controller.enqueue({
158
+ type: "model_start",
159
+ request_id: `request_${call_count}`,
160
+ model_id: "retryable-stream-model",
161
+ });
162
+ if (call_count === 1) {
163
+ controller.enqueue({
164
+ type: "model_error",
165
+ error: {
166
+ code: "transport_error",
167
+ message: "temporary disconnect",
168
+ retryable: true,
169
+ },
170
+ });
171
+ } else {
172
+ controller.enqueue({ type: "text_start", content_id: "text_1" });
173
+ controller.enqueue({ type: "text_delta", content_id: "text_1", delta: "done" });
174
+ controller.enqueue({ type: "text_finish", content_id: "text_1" });
175
+ controller.enqueue({
176
+ type: "model_usage",
177
+ usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
178
+ });
179
+ controller.enqueue({ type: "model_finish", finish_reason: "stop" });
180
+ }
181
+ controller.close();
182
+ },
183
+ });
184
+ },
185
+ };
186
+ const messages = [{
187
+ role: "user",
188
+ content: [{ type: "text", text: "latest request" }],
189
+ }];
190
+
191
+ const result = await create_runner().execute(create_turn_input(model, messages));
192
+
193
+ assert.equal(call_count, 2);
194
+ assert.equal(result.success, true);
195
+ assert.equal(result.text, "done");
196
+ });
197
+
149
198
  test("显式 compact 后在下一次 provider 调用前重载 canonical history", async () => {
150
199
  const provider_prompts = [];
151
200
  const compacted_messages = [{
@@ -85,3 +85,72 @@ test("Agent 拒绝不合法的 Downcity 模型流状态", async () => {
85
85
  }), /Invalid Downcity model stream/);
86
86
  }
87
87
  });
88
+
89
+ test("模型生成无效工具输入时向下一 Step 返回 failed tool_result", async () => {
90
+ let executed = false;
91
+ const model = {
92
+ id: "invalid-tool-input-model",
93
+ async stream() {
94
+ return new ReadableStream({
95
+ start(controller) {
96
+ controller.enqueue({ type: "model_start", request_id: "request_1", model_id: "invalid-tool-input-model" });
97
+ controller.enqueue({ type: "tool_call_start", content_id: "tool_1", tool_call_id: "call_1", tool_name: "write" });
98
+ controller.enqueue({ type: "tool_call_delta", content_id: "tool_1", input_delta: "{\"content\":\"partial" });
99
+ controller.enqueue({
100
+ type: "tool_call_finish",
101
+ content_id: "tool_1",
102
+ input: {},
103
+ input_error: "Tool call arguments are invalid JSON: Unterminated string",
104
+ });
105
+ controller.enqueue({ type: "model_usage", usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } });
106
+ controller.enqueue({ type: "model_finish", finish_reason: "tool_call" });
107
+ controller.close();
108
+ },
109
+ });
110
+ },
111
+ };
112
+
113
+ const result = await run_model_step({
114
+ model,
115
+ system: [],
116
+ messages: [{ role: "user", content: [{ type: "text", text: "write" }] }],
117
+ tools: { write: { execute: async () => { executed = true; } } },
118
+ abort_signal: new AbortController().signal,
119
+ });
120
+
121
+ assert.equal(executed, false);
122
+ assert.equal(result.step_result.tool_results[0].success, false);
123
+ const tool_message = result.step_result.response.messages[1];
124
+ assert.equal(tool_message.content[0].outcome, "failed");
125
+ assert.match(tool_message.content[0].content[0].value.error, /invalid JSON/);
126
+ });
127
+
128
+ test("Tool 返回 success false 时不会被标记为成功", async () => {
129
+ const model = {
130
+ id: "structured-tool-failure-model",
131
+ async stream() {
132
+ return new ReadableStream({
133
+ start(controller) {
134
+ controller.enqueue({ type: "model_start", request_id: "request_1", model_id: "structured-tool-failure-model" });
135
+ controller.enqueue({ type: "tool_call_start", content_id: "tool_1", tool_call_id: "call_1", tool_name: "shell" });
136
+ controller.enqueue({ type: "tool_call_finish", content_id: "tool_1", input: {} });
137
+ controller.enqueue({ type: "model_usage", usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } });
138
+ controller.enqueue({ type: "model_finish", finish_reason: "tool_call" });
139
+ controller.close();
140
+ },
141
+ });
142
+ },
143
+ };
144
+
145
+ const result = await run_model_step({
146
+ model,
147
+ system: [],
148
+ messages: [{ role: "user", content: [{ type: "text", text: "run" }] }],
149
+ tools: { shell: { execute: async () => ({ success: false, exit_code: 1, error: "command failed" }) } },
150
+ abort_signal: new AbortController().signal,
151
+ });
152
+
153
+ assert.equal(result.step_result.tool_results[0].success, false);
154
+ assert.equal(result.assistant_parts[0].state, "failed");
155
+ assert.equal(result.step_result.response.messages[1].content[0].outcome, "failed");
156
+ });
@@ -125,7 +125,7 @@ export class WorkspaceEntry {
125
125
  return contextual_plugins;
126
126
  },
127
127
  get_sessions: () => {
128
- return this.agent.sessions;
128
+ return this.sessions;
129
129
  },
130
130
  } satisfies Parameters<typeof create_plugin_context>[0];
131
131
  this.context = create_plugin_context(context_input);
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { JsonObject, Logger } from "@/index.js";
12
+ import type { PluginNotificationPublisher } from "@/types/plugin/PluginNotification.js";
12
13
 
13
14
  /** City 可以为未来宿主能力增加的显式扩展集合。 */
14
15
  export type PluginHostExtensions = Readonly<Record<string, unknown>>;
@@ -27,6 +28,9 @@ export interface PluginHostContext {
27
28
  /** City 提供的宿主日志器。 */
28
29
  readonly logger: Logger;
29
30
 
31
+ /** 宿主可选提供的通知发布能力;不支持通知的宿主省略。 */
32
+ readonly notifications?: PluginNotificationPublisher;
33
+
30
34
  /** City 为未来宿主能力保留的显式扩展区。 */
31
35
  readonly extensions: PluginHostExtensions;
32
36
  }
@@ -38,10 +38,16 @@ export function resolve_effective_core_engine_error(params: {
38
38
  */
39
39
  streamError?: unknown;
40
40
  }): string {
41
- const outerError = String(params.error ?? "").trim();
42
- const innerError = String(params.streamError ?? "").trim();
41
+ const outerError = read_error_message(params.error);
42
+ const innerError = read_error_message(params.streamError);
43
43
  if (/AI_NoOutputGeneratedError|No output generated/i.test(outerError) && innerError) {
44
44
  return innerError;
45
45
  }
46
46
  return outerError || innerError || "Unknown execution error";
47
47
  }
48
+
49
+ /** 读取适合向上层展示的错误正文,避免泄漏内部 Error class 名称。 */
50
+ function read_error_message(error: unknown): string {
51
+ if (error instanceof Error) return error.message.trim();
52
+ return String(error ?? "").trim();
53
+ }
@@ -34,6 +34,7 @@ import {
34
34
  type ModelStepResult,
35
35
  type ModelStepToolCall,
36
36
  } from "@executor/model/ModelStepRunner.js";
37
+ import { is_retryable_empty_model_stream_failure } from "@executor/model/ModelStreamFailure.js";
37
38
  import { CoreEngineMessageState } from "@executor/core-engine/CoreEngineMessageState.js";
38
39
  import {
39
40
  deep_compact_model_messages,
@@ -58,6 +59,10 @@ const TURN_STOPPED_MESSAGE = "Turn stopped";
58
59
 
59
60
  /** Provider context-length error 在当前 step 内最多压缩重试三次。 */
60
61
  const MAX_CONTEXT_ERROR_COMPACTION_RETRIES = 3;
62
+ /** 无任何可见输出时,临时 Provider 流错误的最大自动重试次数。 */
63
+ const MAX_EMPTY_STREAM_RETRIES = 2;
64
+ /** 无输出流错误的重试退避,避免立即重复冲击同一 Provider。 */
65
+ const EMPTY_STREAM_RETRY_DELAYS_MS = [300, 1_000] as const;
61
66
 
62
67
  interface CoreEngineRunnerOptions {
63
68
  /** 当前 Session 稳定标识。 */
@@ -181,6 +186,7 @@ export class CoreEngineRunner {
181
186
 
182
187
  let incomplete_response_recovery_count = 0;
183
188
  let context_error_compaction_retries = 0;
189
+ let empty_stream_retry_count = 0;
184
190
  let compact_pending = false;
185
191
  let compact_validation_pending = Boolean(
186
192
  persisted_compaction_summary_id &&
@@ -293,6 +299,24 @@ export class CoreEngineRunner {
293
299
  ) {
294
300
  await input.turn_context.output.assistant.abort_step();
295
301
  }
302
+ if (
303
+ is_retryable_empty_model_stream_failure(error) &&
304
+ empty_stream_retry_count < MAX_EMPTY_STREAM_RETRIES
305
+ ) {
306
+ empty_stream_retry_count += 1;
307
+ await this.logger.log("warn", "[agent] model_stream.retry", {
308
+ session_id,
309
+ retry_count: empty_stream_retry_count,
310
+ error_code: error.code,
311
+ error: error.message,
312
+ provider_request_id: error.provider_request_id ?? null,
313
+ });
314
+ await wait_for_stream_retry(
315
+ EMPTY_STREAM_RETRY_DELAYS_MS[empty_stream_retry_count - 1] ?? 1_000,
316
+ input.turn_context.lifecycle.abort_signal,
317
+ );
318
+ continue;
319
+ }
296
320
  const compact_error = this.should_compact_on_error(error)
297
321
  ? error
298
322
  : last_observed_stream_error;
@@ -328,6 +352,7 @@ export class CoreEngineRunner {
328
352
  }
329
353
 
330
354
  context_error_compaction_retries = 0;
355
+ empty_stream_retry_count = 0;
331
356
  const last_step = executed_steps[executed_steps.length - 1];
332
357
  if (!last_step) break;
333
358
 
@@ -533,6 +558,25 @@ export class CoreEngineRunner {
533
558
  }
534
559
  }
535
560
 
561
+ /** 等待下一次模型流重试,并在 Turn 停止时立即结束等待。 */
562
+ async function wait_for_stream_retry(
563
+ delay_ms: number,
564
+ abort_signal: AbortSignal,
565
+ ): Promise<void> {
566
+ if (abort_signal.aborted) throw abort_signal.reason;
567
+ await new Promise<void>((resolve, reject) => {
568
+ const timer = setTimeout(() => {
569
+ abort_signal.removeEventListener("abort", on_abort);
570
+ resolve();
571
+ }, delay_ms);
572
+ const on_abort = (): void => {
573
+ clearTimeout(timer);
574
+ reject(abort_signal.reason);
575
+ };
576
+ abort_signal.addEventListener("abort", on_abort, { once: true });
577
+ });
578
+ }
579
+
536
580
  /** 在工具执行前接入 Session canonical Interaction 生命周期。 */
537
581
  async function resolve_tool_approval(input: {
538
582
  call: ModelStepToolCall;
@@ -51,7 +51,9 @@ export async function generate_model(
51
51
  tools.set(event.content_id, { tool_call_id: event.tool_call_id, tool_name: event.tool_name });
52
52
  } else if (event.type === "tool_call_finish") {
53
53
  const tool = tools.get(event.content_id);
54
- if (tool) tool_calls.push({ type: "tool_call", ...tool, input: event.input });
54
+ if (tool && !event.input_error) {
55
+ tool_calls.push({ type: "tool_call", ...tool, input: event.input });
56
+ }
55
57
  } else if (event.type === "model_usage") usage = event.usage;
56
58
  else if (event.type === "model_finish") finish_reason = event.finish_reason;
57
59
  }
@@ -26,6 +26,7 @@ import type {
26
26
  SessionAssistantMessagePart,
27
27
  SessionAssistantToolPart,
28
28
  } from "@/types/session/SessionMessage.js";
29
+ import { ModelStreamFailure } from "@/executor/model/ModelStreamFailure.js";
29
30
 
30
31
  /** 单个工具调用的执行事实。 */
31
32
  export interface ModelStepToolCall {
@@ -35,6 +36,8 @@ export interface ModelStepToolCall {
35
36
  tool_name: string;
36
37
  /** 已解析工具输入。 */
37
38
  input: ModelJsonValue;
39
+ /** 模型生成的工具输入无法解析时记录的错误。 */
40
+ input_error?: string;
38
41
  }
39
42
 
40
43
  /** 单个工具结果的执行事实。 */
@@ -162,7 +165,14 @@ class StepEventCollector {
162
165
 
163
166
  /** 消费单个标准模型事件。 */
164
167
  accept(event: ModelStreamEvent): void {
165
- if (event.type === "model_error") throw new Error(event.error.message);
168
+ if (event.type === "model_error") {
169
+ throw new ModelStreamFailure(
170
+ event.error,
171
+ this.text.length > 0 ||
172
+ this.reasoning_by_id.size > 0 ||
173
+ this.tool_by_content_id.size > 0,
174
+ );
175
+ }
166
176
  if (event.type === "text_start") {
167
177
  this.text_by_id.set(event.content_id, "");
168
178
  } else if (event.type === "text_delta") {
@@ -213,6 +223,7 @@ class StepEventCollector {
213
223
  const tool = this.tool_by_content_id.get(event.content_id);
214
224
  if (tool) {
215
225
  tool.input = event.input;
226
+ if (event.input_error) tool.input_error = event.input_error;
216
227
  this.model_content.push({
217
228
  type: "tool_call",
218
229
  tool_call_id: tool.tool_call_id,
@@ -265,6 +276,14 @@ async function execute_tools(
265
276
  ): Promise<ModelStepToolResult[]> {
266
277
  const results: ModelStepToolResult[] = [];
267
278
  for (const call of calls) {
279
+ if (call.input_error) {
280
+ results.push({
281
+ ...call,
282
+ success: false,
283
+ output: { error: call.input_error },
284
+ });
285
+ continue;
286
+ }
268
287
  const tool = input.tools[call.tool_name];
269
288
  if (!tool || typeof tool.execute !== "function") {
270
289
  results.push({
@@ -292,7 +311,11 @@ async function execute_tools(
292
311
  abort_signal: input.abort_signal,
293
312
  };
294
313
  const output = await tool.execute(call.input, options);
295
- results.push({ ...call, success: true, output });
314
+ results.push({
315
+ ...call,
316
+ success: !is_structured_tool_failure(output),
317
+ output,
318
+ });
296
319
  } catch (error) {
297
320
  results.push({
298
321
  ...call,
@@ -304,6 +327,12 @@ async function execute_tools(
304
327
  return results;
305
328
  }
306
329
 
330
+ /** 识别 Tool 正常返回的结构化失败,避免把业务失败标记成执行成功。 */
331
+ function is_structured_tool_failure(output: unknown): boolean {
332
+ if (!output || typeof output !== "object" || Array.isArray(output)) return false;
333
+ return (output as { success?: unknown }).success === false;
334
+ }
335
+
307
336
  /** 把 System 快照转换成标准 ModelMessage。 */
308
337
  function convert_system_messages(messages: SessionSystemMessage[]): ModelMessage[] {
309
338
  return messages.map((message) => ({
@@ -373,9 +402,11 @@ function append_tool_results(
373
402
 
374
403
  /** 从结构化工具失败结果中提取可展示错误文本。 */
375
404
  function read_tool_error(value: unknown): string {
376
- if (value && typeof value === "object" && "error" in value) {
377
- const error = (value as { error?: unknown }).error;
378
- if (typeof error === "string" && error.trim()) return error;
405
+ if (value && typeof value === "object") {
406
+ const result = value as { error?: unknown; message?: unknown; output?: unknown };
407
+ for (const candidate of [result.error, result.message, result.output]) {
408
+ if (typeof candidate === "string" && candidate.trim()) return candidate;
409
+ }
379
410
  }
380
411
  return "Tool execution failed";
381
412
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * 模型流失败对象。
3
+ *
4
+ * 该对象完整保留 Model Protocol 的错误分类,避免 Executor 只能依赖错误文本
5
+ * 判断是否可以重试。它只描述一次模型流失败,不负责决定恢复策略。
6
+ */
7
+
8
+ import type { ModelError } from "@downcity/type";
9
+
10
+ /** 一次具有稳定协议语义的模型流失败。 */
11
+ export class ModelStreamFailure extends Error {
12
+ /** Downcity 稳定模型错误码。 */
13
+ readonly code: ModelError["code"];
14
+
15
+ /** 当前模型调用是否可以安全重试。 */
16
+ readonly retryable: boolean;
17
+
18
+ /** 失败前是否已经向用户发布正文或工具输入。 */
19
+ readonly has_partial_output: boolean;
20
+
21
+ /** Provider 返回的可观测请求标识。 */
22
+ readonly provider_request_id?: string;
23
+
24
+ constructor(error: ModelError, has_partial_output: boolean) {
25
+ super(error.message);
26
+ this.name = "ModelStreamFailure";
27
+ this.code = error.code;
28
+ this.retryable = error.retryable;
29
+ this.has_partial_output = has_partial_output;
30
+ if (error.provider_request_id) {
31
+ this.provider_request_id = error.provider_request_id;
32
+ }
33
+ }
34
+ }
35
+
36
+ /** 判断未知错误是否为可安全自动重试的无输出模型流失败。 */
37
+ export function is_retryable_empty_model_stream_failure(
38
+ error: unknown,
39
+ ): error is ModelStreamFailure {
40
+ return error instanceof ModelStreamFailure &&
41
+ error.retryable &&
42
+ !error.has_partial_output &&
43
+ error.code !== "cancelled";
44
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * GroupDispatchSession:单个 GroupSession 独享的持久化调度执行边界。
3
+ *
4
+ * 本对象统一拥有调度队列、当前模型取消信号和调度 Turn 状态。调度策略只负责决策,
5
+ * 不再自行拥有跨 GroupSession 的运行状态或停止语义。
6
+ */
7
+
8
+ import { nanoid } from "nanoid";
9
+ import type {
10
+ GroupDispatchSessionDataStore,
11
+ GroupDispatchSessionInput,
12
+ GroupDispatchSessionOptions,
13
+ GroupDispatchSessionResult,
14
+ GroupDispatchTurnRecord,
15
+ } from "@/types/group/GroupDispatchSession.js";
16
+
17
+ /** 主动停止 Dispatch Session 时使用的内部错误。 */
18
+ export class GroupDispatchStoppedError extends Error {
19
+ constructor() {
20
+ super("Group dispatch stopped");
21
+ this.name = "GroupDispatchStoppedError";
22
+ }
23
+ }
24
+
25
+ /** GroupSession 私有的调度 Session。 */
26
+ export class GroupDispatchSession {
27
+ private readonly dispatch_strategy: GroupDispatchSessionOptions["dispatch_strategy"];
28
+ private store?: GroupDispatchSessionDataStore;
29
+ private queue_tail: Promise<void> = Promise.resolve();
30
+ private active_abort_controller?: AbortController;
31
+ private stopping = false;
32
+ private disposed = false;
33
+
34
+ constructor(options: GroupDispatchSessionOptions) {
35
+ this.dispatch_strategy = options.dispatch_strategy;
36
+ }
37
+
38
+ /** 绑定持久化视图,并把进程中断留下的运行态明确收口为 stopped。 */
39
+ async initialize(store: GroupDispatchSessionDataStore): Promise<this> {
40
+ if (this.store && this.store !== store) {
41
+ throw new Error("GroupDispatchSession is already initialized with another Store");
42
+ }
43
+ if (this.store === store) return this;
44
+ this.store = store;
45
+ const interrupted_turns = (await store.list_turns()).filter((turn) => (
46
+ turn.status === "queued" || turn.status === "running"
47
+ ));
48
+ for (const turn of interrupted_turns) {
49
+ await store.append_turn({
50
+ ...turn,
51
+ status: "stopped",
52
+ error: "Group dispatch interrupted before recovery",
53
+ updated_at: Date.now(),
54
+ });
55
+ }
56
+ return this;
57
+ }
58
+
59
+ /** 按提交顺序执行一次调度,并在返回前持久化最终决定。 */
60
+ decide(input: GroupDispatchSessionInput): Promise<GroupDispatchSessionResult> {
61
+ this.assert_available();
62
+ const store = this.require_store();
63
+ const created_at = Date.now();
64
+ const queued_turn: GroupDispatchTurnRecord = {
65
+ dispatch_id: `group-dispatch-${nanoid(12)}`,
66
+ trigger: input.trigger,
67
+ message_id: input.message.id,
68
+ pending_message_ids: input.pending_messages.map((message) => message.id),
69
+ status: "queued",
70
+ created_at,
71
+ updated_at: created_at,
72
+ };
73
+ const persist_queued = store.append_turn(queued_turn);
74
+ const operation = this.queue_tail.then(async () => {
75
+ await persist_queued;
76
+ return await this.execute_turn(input, queued_turn);
77
+ });
78
+ this.queue_tail = operation.then(() => undefined, () => undefined);
79
+ return operation;
80
+ }
81
+
82
+ /** 中断当前模型调用,并让尚未开始的调度 Turn 按队列顺序收口。 */
83
+ async stop(): Promise<void> {
84
+ if (this.disposed || this.stopping) return;
85
+ this.stopping = true;
86
+ this.active_abort_controller?.abort(new GroupDispatchStoppedError());
87
+ await this.queue_tail;
88
+ this.active_abort_controller = undefined;
89
+ this.stopping = false;
90
+ }
91
+
92
+ /** 永久释放当前 Dispatch Session。 */
93
+ async dispose(): Promise<void> {
94
+ if (this.disposed) return;
95
+ await this.stop();
96
+ this.disposed = true;
97
+ this.store = undefined;
98
+ }
99
+
100
+ /** 执行队首调度 Turn,确保成功、失败和停止都形成持久化终态。 */
101
+ private async execute_turn(
102
+ input: GroupDispatchSessionInput,
103
+ queued_turn: GroupDispatchTurnRecord,
104
+ ): Promise<GroupDispatchSessionResult> {
105
+ const store = this.require_store();
106
+ if (this.stopping || this.disposed) {
107
+ await this.persist_terminal_turn(queued_turn, "stopped", "Group dispatch stopped");
108
+ throw new GroupDispatchStoppedError();
109
+ }
110
+ const abort_controller = new AbortController();
111
+ this.active_abort_controller = abort_controller;
112
+ await store.append_turn({
113
+ ...queued_turn,
114
+ status: "running",
115
+ updated_at: Date.now(),
116
+ });
117
+ try {
118
+ const decision = await this.dispatch_strategy.decide_dispatch({
119
+ ...input,
120
+ abort_signal: abort_controller.signal,
121
+ });
122
+ if (this.stopping || this.disposed || abort_controller.signal.aborted) {
123
+ throw new GroupDispatchStoppedError();
124
+ }
125
+ await store.append_turn({
126
+ ...queued_turn,
127
+ status: "completed",
128
+ decision,
129
+ updated_at: Date.now(),
130
+ });
131
+ return { dispatch_id: queued_turn.dispatch_id, decision };
132
+ } catch (error) {
133
+ if (
134
+ error instanceof GroupDispatchStoppedError ||
135
+ this.stopping ||
136
+ this.disposed ||
137
+ abort_controller.signal.aborted
138
+ ) {
139
+ await this.persist_terminal_turn(queued_turn, "stopped", "Group dispatch stopped");
140
+ throw new GroupDispatchStoppedError();
141
+ }
142
+ const detail = error instanceof Error ? error.message : String(error);
143
+ await this.persist_terminal_turn(queued_turn, "failed", detail);
144
+ throw error;
145
+ } finally {
146
+ if (this.active_abort_controller === abort_controller) {
147
+ this.active_abort_controller = undefined;
148
+ }
149
+ }
150
+ }
151
+
152
+ /** 提交没有 decision 的失败或停止终态。 */
153
+ private async persist_terminal_turn(
154
+ turn: GroupDispatchTurnRecord,
155
+ status: "failed" | "stopped",
156
+ error: string,
157
+ ): Promise<void> {
158
+ await this.require_store().append_turn({
159
+ ...turn,
160
+ status,
161
+ error,
162
+ updated_at: Date.now(),
163
+ });
164
+ }
165
+
166
+ private require_store(): GroupDispatchSessionDataStore {
167
+ if (!this.store) throw new Error("GroupDispatchSession is not initialized");
168
+ return this.store;
169
+ }
170
+
171
+ private assert_available(): void {
172
+ if (this.disposed) throw new Error("GroupDispatchSession is disposed");
173
+ if (this.stopping) throw new Error("GroupDispatchSession is stopping");
174
+ }
175
+ }