@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.
- package/README.md +4 -3
- package/bin/agent/WorkspaceEntry.js +1 -1
- package/bin/agent/WorkspaceEntry.js.map +1 -1
- package/bin/city/types/PluginHostContext.d.ts +3 -0
- package/bin/city/types/PluginHostContext.d.ts.map +1 -1
- package/bin/executor/core-engine/CoreEngineError.js +8 -2
- package/bin/executor/core-engine/CoreEngineError.js.map +1 -1
- package/bin/executor/core-engine/CoreEngineRunner.d.ts.map +1 -1
- package/bin/executor/core-engine/CoreEngineRunner.js +36 -0
- package/bin/executor/core-engine/CoreEngineRunner.js.map +1 -1
- package/bin/executor/model/ModelGenerate.d.ts.map +1 -1
- package/bin/executor/model/ModelGenerate.js +2 -1
- package/bin/executor/model/ModelGenerate.js.map +1 -1
- package/bin/executor/model/ModelStepRunner.d.ts +2 -0
- package/bin/executor/model/ModelStepRunner.d.ts.map +1 -1
- package/bin/executor/model/ModelStepRunner.js +33 -7
- package/bin/executor/model/ModelStepRunner.js.map +1 -1
- package/bin/executor/model/ModelStreamFailure.d.ts +22 -0
- package/bin/executor/model/ModelStreamFailure.d.ts.map +1 -0
- package/bin/executor/model/ModelStreamFailure.js +35 -0
- package/bin/executor/model/ModelStreamFailure.js.map +1 -0
- package/bin/group/GroupDispatchSession.d.ts +36 -0
- package/bin/group/GroupDispatchSession.d.ts.map +1 -0
- package/bin/group/GroupDispatchSession.js +154 -0
- package/bin/group/GroupDispatchSession.js.map +1 -0
- package/bin/group/GroupSession.d.ts +3 -1
- package/bin/group/GroupSession.d.ts.map +1 -1
- package/bin/group/GroupSession.js +65 -28
- package/bin/group/GroupSession.js.map +1 -1
- package/bin/index.d.ts +2 -0
- package/bin/index.d.ts.map +1 -1
- package/bin/index.js.map +1 -1
- package/bin/session/messages/SessionAssistantMessageWriter.d.ts.map +1 -1
- package/bin/session/messages/SessionAssistantMessageWriter.js +10 -6
- package/bin/session/messages/SessionAssistantMessageWriter.js.map +1 -1
- package/bin/types/group/DispatchStrategy.d.ts +3 -0
- package/bin/types/group/DispatchStrategy.d.ts.map +1 -1
- package/bin/types/group/DispatchStrategy.js +66 -18
- package/bin/types/group/DispatchStrategy.js.map +1 -1
- package/bin/types/group/GroupDispatchSession.d.ts +65 -0
- package/bin/types/group/GroupDispatchSession.d.ts.map +1 -0
- package/bin/types/group/GroupDispatchSession.js +8 -0
- package/bin/types/group/GroupDispatchSession.js.map +1 -0
- package/bin/types/group/GroupSession.d.ts +2 -0
- package/bin/types/group/GroupSession.d.ts.map +1 -1
- package/bin/types/group/GroupSessionStore.d.ts +6 -1
- package/bin/types/group/GroupSessionStore.d.ts.map +1 -1
- package/bin/types/plugin/PluginNotification.d.ts +31 -0
- package/bin/types/plugin/PluginNotification.d.ts.map +1 -0
- package/bin/types/plugin/PluginNotification.js +8 -0
- package/bin/types/plugin/PluginNotification.js.map +1 -0
- package/bin/workspace/store/LocalGroupDispatchSessionDataStore.d.ts +28 -0
- package/bin/workspace/store/LocalGroupDispatchSessionDataStore.d.ts.map +1 -0
- package/bin/workspace/store/LocalGroupDispatchSessionDataStore.js +102 -0
- package/bin/workspace/store/LocalGroupDispatchSessionDataStore.js.map +1 -0
- package/bin/workspace/store/LocalGroupSessionDataStore.d.ts +2 -0
- package/bin/workspace/store/LocalGroupSessionDataStore.d.ts.map +1 -1
- package/bin/workspace/store/LocalGroupSessionDataStore.js +25 -8
- package/bin/workspace/store/LocalGroupSessionDataStore.js.map +1 -1
- package/city-test/group.test.mjs +252 -1
- package/package.json +4 -4
- package/scripts/agent-multi-workspace.test.mjs +67 -0
- package/scripts/core-engine-context-compaction.test.mjs +49 -0
- package/scripts/core-engine-model-stream.test.mjs +69 -0
- package/src/agent/WorkspaceEntry.ts +1 -1
- package/src/city/types/PluginHostContext.ts +4 -0
- package/src/executor/core-engine/CoreEngineError.ts +8 -2
- package/src/executor/core-engine/CoreEngineRunner.ts +44 -0
- package/src/executor/model/ModelGenerate.ts +3 -1
- package/src/executor/model/ModelStepRunner.ts +36 -5
- package/src/executor/model/ModelStreamFailure.ts +44 -0
- package/src/group/GroupDispatchSession.ts +175 -0
- package/src/group/GroupSession.ts +83 -28
- package/src/index.ts +10 -0
- package/src/session/messages/SessionAssistantMessageWriter.ts +9 -5
- package/src/types/group/DispatchStrategy.ts +79 -19
- package/src/types/group/GroupDispatchSession.ts +80 -0
- package/src/types/group/GroupSession.ts +2 -0
- package/src/types/group/GroupSessionStore.ts +6 -1
- package/src/types/plugin/PluginNotification.ts +38 -0
- package/src/workspace/store/LocalGroupDispatchSessionDataStore.ts +118 -0
- package/src/workspace/store/LocalGroupSessionDataStore.ts +35 -9
- 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.
|
|
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 =
|
|
42
|
-
const innerError =
|
|
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
|
|
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")
|
|
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({
|
|
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"
|
|
377
|
-
const
|
|
378
|
-
|
|
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
|
+
}
|