@downcity/agent 1.1.265 → 1.1.269
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/bin/executor/core-engine/CoreEngineRunner.d.ts.map +1 -1
- package/bin/executor/core-engine/CoreEngineRunner.js +1 -2
- package/bin/executor/core-engine/CoreEngineRunner.js.map +1 -1
- package/bin/executor/services/ExecutorRecoveryPolicy.d.ts +0 -1
- package/bin/executor/services/ExecutorRecoveryPolicy.d.ts.map +1 -1
- package/bin/executor/services/ExecutorRecoveryPolicy.js +2 -17
- package/bin/executor/services/ExecutorRecoveryPolicy.js.map +1 -1
- package/bin/executor/types/SessionRecords.d.ts +5 -7
- package/bin/executor/types/SessionRecords.d.ts.map +1 -1
- package/bin/executor/types/SessionRecords.js +3 -4
- package/bin/executor/types/SessionRecords.js.map +1 -1
- package/bin/index.d.ts +1 -1
- package/bin/index.d.ts.map +1 -1
- package/bin/index.js.map +1 -1
- package/bin/session/SessionMessages.d.ts.map +1 -1
- package/bin/session/SessionMessages.js +14 -1
- package/bin/session/SessionMessages.js.map +1 -1
- package/bin/session/SessionTitle.d.ts.map +1 -1
- package/bin/session/SessionTitle.js +0 -6
- package/bin/session/SessionTitle.js.map +1 -1
- package/bin/session/SessionTurn.d.ts.map +1 -1
- package/bin/session/SessionTurn.js +0 -25
- package/bin/session/SessionTurn.js.map +1 -1
- package/bin/session/messages/SessionAssistantMessageWriter.d.ts +1 -1
- package/bin/session/messages/SessionAssistantMessageWriter.d.ts.map +1 -1
- package/bin/session/messages/SessionAssistantMessageWriter.js +153 -12
- package/bin/session/messages/SessionAssistantMessageWriter.js.map +1 -1
- package/bin/session/messages/SessionJsonValue.d.ts +3 -1
- package/bin/session/messages/SessionJsonValue.d.ts.map +1 -1
- package/bin/session/messages/SessionJsonValue.js +12 -0
- package/bin/session/messages/SessionJsonValue.js.map +1 -1
- package/bin/session/messages/SessionMessageCodec.d.ts.map +1 -1
- package/bin/session/messages/SessionMessageCodec.js +226 -19
- package/bin/session/messages/SessionMessageCodec.js.map +1 -1
- package/bin/types/sdk/AgentSessionTurn.d.ts +2 -1
- package/bin/types/sdk/AgentSessionTurn.d.ts.map +1 -1
- package/bin/types/session/SessionMessage.d.ts +87 -3
- package/bin/types/session/SessionMessage.d.ts.map +1 -1
- package/package.json +3 -3
- package/scripts/executor-failure-result.test.mjs +81 -0
- package/scripts/openai-responses-continuation.test.mjs +35 -5
- package/scripts/session-messages.test.mjs +287 -1
- package/scripts/session-shell-approval.test.mjs +3 -3
- package/scripts/session-turn-failure.test.mjs +101 -0
- package/src/executor/core-engine/CoreEngineRunner.ts +1 -5
- package/src/executor/services/ExecutorRecoveryPolicy.ts +2 -23
- package/src/executor/types/SessionRecords.ts +5 -7
- package/src/index.ts +11 -0
- package/src/session/SessionMessages.ts +14 -1
- package/src/session/SessionTitle.ts +0 -6
- package/src/session/SessionTurn.ts +0 -29
- package/src/session/messages/SessionAssistantMessageWriter.ts +170 -12
- package/src/session/messages/SessionJsonValue.ts +12 -1
- package/src/session/messages/SessionMessageCodec.ts +226 -18
- package/src/types/sdk/AgentSessionTurn.ts +2 -1
- package/src/types/session/SessionMessage.ts +96 -3
- package/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@downcity/agent",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.269",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Downcity Agent 运行时 — 单 Agent 执行壳与本机 RPC 能力",
|
|
6
6
|
"main": "./bin/index.js",
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@downcity/shell": "^0.1.
|
|
16
|
-
"@downcity/type": "0.1.
|
|
15
|
+
"@downcity/shell": "^0.1.125",
|
|
16
|
+
"@downcity/type": "0.1.155",
|
|
17
17
|
"ai": "^6.0.193",
|
|
18
18
|
"commander": "^15.0.0",
|
|
19
19
|
"dotenv": "^17.4.2",
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file 验证执行器失败结果不会伪造 Assistant 正文。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { MockLanguageModelV3 } from "ai/test";
|
|
8
|
+
|
|
9
|
+
import { CoreEngineRunner } from "../bin/executor/core-engine/CoreEngineRunner.js";
|
|
10
|
+
import { ExecutorRecoveryPolicy } from "../bin/executor/services/ExecutorRecoveryPolicy.js";
|
|
11
|
+
|
|
12
|
+
function create_run_context() {
|
|
13
|
+
return {
|
|
14
|
+
sessionId: "executor-failure-test",
|
|
15
|
+
injectedUserMessages: [],
|
|
16
|
+
deferredPersistedUserMessages: [],
|
|
17
|
+
pendingAssistantFileParts: [],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test("CoreEngine Provider 失败时只返回结构化错误", async () => {
|
|
22
|
+
const model = new MockLanguageModelV3({
|
|
23
|
+
modelId: "failing-model",
|
|
24
|
+
doStream: async () => {
|
|
25
|
+
throw new Error("quota exceeded");
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
const runner = new CoreEngineRunner({
|
|
29
|
+
session_id: "executor-failure-test",
|
|
30
|
+
logger: { log: async () => {} },
|
|
31
|
+
should_compact_on_error: () => false,
|
|
32
|
+
});
|
|
33
|
+
const messages = [{
|
|
34
|
+
id: "user-1",
|
|
35
|
+
role: "user",
|
|
36
|
+
metadata: {
|
|
37
|
+
v: 1,
|
|
38
|
+
ts: 1,
|
|
39
|
+
sessionId: "executor-failure-test",
|
|
40
|
+
source: "ingress",
|
|
41
|
+
kind: "normal",
|
|
42
|
+
},
|
|
43
|
+
parts: [{ type: "text", text: "hello" }],
|
|
44
|
+
}];
|
|
45
|
+
|
|
46
|
+
const result = await runner.run({
|
|
47
|
+
execute_input: { query: "hello", system: [], messages, tools: {} },
|
|
48
|
+
model,
|
|
49
|
+
run_context: create_run_context(),
|
|
50
|
+
resolve_step_inputs: async () => ({ model, system: [], tools: {} }),
|
|
51
|
+
reload_history: async () => messages,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
assert.equal(result.success, false);
|
|
55
|
+
assert.match(result.error, /quota exceeded/);
|
|
56
|
+
assert.equal(result.assistantMessage, undefined);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("恢复策略捕获普通异常后只返回结构化错误", async () => {
|
|
60
|
+
const policy = new ExecutorRecoveryPolicy({
|
|
61
|
+
session_id: "executor-failure-test",
|
|
62
|
+
logger: { log: async () => {} },
|
|
63
|
+
should_compact: () => false,
|
|
64
|
+
});
|
|
65
|
+
const run_context = create_run_context();
|
|
66
|
+
const result = await policy.run_with_retry({
|
|
67
|
+
query: "hello",
|
|
68
|
+
model: {},
|
|
69
|
+
run_context,
|
|
70
|
+
prepare_execute_input: async () => {
|
|
71
|
+
throw new Error("configuration failed");
|
|
72
|
+
},
|
|
73
|
+
execute_prepared_run: async () => {
|
|
74
|
+
throw new Error("unexpected execution");
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
assert.equal(result.success, false);
|
|
79
|
+
assert.match(result.error, /configuration failed/);
|
|
80
|
+
assert.equal(result.assistantMessage, undefined);
|
|
81
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @file 验证
|
|
2
|
+
* @file 验证 Session 使用完整工具事务续接 OpenAI Responses。
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import test from "node:test";
|
|
@@ -7,7 +7,8 @@ import assert from "node:assert/strict";
|
|
|
7
7
|
import { createOpenAI } from "@ai-sdk/openai";
|
|
8
8
|
import { convertToModelMessages } from "ai";
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
/** 捕获 AI SDK 在指定 store 策略下生成的 Responses 请求体。 */
|
|
11
|
+
async function capture_request_body(store) {
|
|
11
12
|
let request_body;
|
|
12
13
|
const openai = createOpenAI({
|
|
13
14
|
apiKey: "test-api-key",
|
|
@@ -30,11 +31,25 @@ test("OpenAI Responses 默认 store 策略使用 item_reference continuation", a
|
|
|
30
31
|
}]);
|
|
31
32
|
|
|
32
33
|
await assert.rejects(
|
|
33
|
-
openai.responses("gpt-5").doGenerate({
|
|
34
|
+
openai.responses("gpt-5").doGenerate({
|
|
35
|
+
prompt: messages,
|
|
36
|
+
providerOptions: { openai: { store } },
|
|
37
|
+
}),
|
|
34
38
|
/request captured/,
|
|
35
39
|
);
|
|
40
|
+
return request_body;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test("Responses store=false 发送完整 function_call", async () => {
|
|
44
|
+
const request_body = await capture_request_body(false);
|
|
36
45
|
assert.deepEqual(request_body.input, [
|
|
37
|
-
{
|
|
46
|
+
{
|
|
47
|
+
type: "function_call",
|
|
48
|
+
call_id: "call_1",
|
|
49
|
+
name: "lookup",
|
|
50
|
+
arguments: "{\"q\":\"x\"}",
|
|
51
|
+
id: "fc_1",
|
|
52
|
+
},
|
|
38
53
|
{
|
|
39
54
|
type: "function_call_output",
|
|
40
55
|
call_id: "call_1",
|
|
@@ -42,7 +57,22 @@ test("OpenAI Responses 默认 store 策略使用 item_reference continuation", a
|
|
|
42
57
|
},
|
|
43
58
|
]);
|
|
44
59
|
assert.equal(
|
|
45
|
-
request_body.input.some((item) => item.type === "
|
|
60
|
+
request_body.input.some((item) => item.type === "item_reference"),
|
|
46
61
|
false,
|
|
47
62
|
);
|
|
48
63
|
});
|
|
64
|
+
|
|
65
|
+
test("Responses store=true 使用已存储 item_reference", async () => {
|
|
66
|
+
const request_body = await capture_request_body(true);
|
|
67
|
+
assert.deepEqual(request_body.input, [
|
|
68
|
+
{
|
|
69
|
+
type: "item_reference",
|
|
70
|
+
id: "fc_1",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
type: "function_call_output",
|
|
74
|
+
call_id: "call_1",
|
|
75
|
+
output: "ok",
|
|
76
|
+
},
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
@@ -14,6 +14,7 @@ import { SessionApprovalBroker } from "../bin/session/approval/SessionApprovalBr
|
|
|
14
14
|
import { compose_session_compaction } from "../bin/session/messages/SessionMessageCompaction.js";
|
|
15
15
|
import {
|
|
16
16
|
from_ui_assistant_parts,
|
|
17
|
+
from_ui_user_parts,
|
|
17
18
|
to_executor_history,
|
|
18
19
|
to_executor_ui_message,
|
|
19
20
|
} from "../bin/session/messages/SessionMessageCodec.js";
|
|
@@ -281,6 +282,126 @@ test("Streaming Tool Provider metadata 在输入和输出状态间完整保留",
|
|
|
281
282
|
assert.equal(tool_part.state, "completed");
|
|
282
283
|
});
|
|
283
284
|
|
|
285
|
+
test("AI SDK 流式 metadata、source、data、step 和审批状态完整持久化", async () => {
|
|
286
|
+
const { recorder, file_path } = await create_recorder("ui-chunk-semantics-test");
|
|
287
|
+
const writer = await recorder.open_assistant_message({
|
|
288
|
+
turn_id: "turn-ui-chunks",
|
|
289
|
+
segment_index: 1,
|
|
290
|
+
});
|
|
291
|
+
await writer.apply_chunk({
|
|
292
|
+
type: "text-start",
|
|
293
|
+
id: "text_1",
|
|
294
|
+
providerMetadata: { openai: { itemId: "text_start" } },
|
|
295
|
+
});
|
|
296
|
+
await writer.apply_chunk({ type: "text-delta", id: "text_1", delta: "hello" });
|
|
297
|
+
await writer.apply_chunk({
|
|
298
|
+
type: "text-end",
|
|
299
|
+
id: "text_1",
|
|
300
|
+
providerMetadata: { openai: { itemId: "text_end" } },
|
|
301
|
+
});
|
|
302
|
+
await writer.apply_chunk({ type: "reasoning-start", id: "reasoning_1" });
|
|
303
|
+
await writer.apply_chunk({
|
|
304
|
+
type: "reasoning-delta",
|
|
305
|
+
id: "reasoning_1",
|
|
306
|
+
delta: "think",
|
|
307
|
+
providerMetadata: { openai: { itemId: "reasoning_delta" } },
|
|
308
|
+
});
|
|
309
|
+
await writer.apply_chunk({ type: "reasoning-end", id: "reasoning_1" });
|
|
310
|
+
await writer.apply_chunk({
|
|
311
|
+
type: "source-url",
|
|
312
|
+
sourceId: "source_1",
|
|
313
|
+
url: "https://example.com/old",
|
|
314
|
+
});
|
|
315
|
+
await writer.apply_chunk({
|
|
316
|
+
type: "source-url",
|
|
317
|
+
sourceId: "source_1",
|
|
318
|
+
url: "https://example.com/new",
|
|
319
|
+
title: "Updated source",
|
|
320
|
+
providerMetadata: { openai: { itemId: "source_item" } },
|
|
321
|
+
});
|
|
322
|
+
await writer.apply_chunk({
|
|
323
|
+
type: "data-progress",
|
|
324
|
+
id: "progress_1",
|
|
325
|
+
data: { percent: 10 },
|
|
326
|
+
});
|
|
327
|
+
await writer.apply_chunk({
|
|
328
|
+
type: "data-progress",
|
|
329
|
+
id: "progress_1",
|
|
330
|
+
data: { percent: 90 },
|
|
331
|
+
});
|
|
332
|
+
await writer.apply_chunk({
|
|
333
|
+
type: "data-progress",
|
|
334
|
+
id: "transient_1",
|
|
335
|
+
data: { percent: 100 },
|
|
336
|
+
transient: true,
|
|
337
|
+
});
|
|
338
|
+
await writer.apply_chunk({
|
|
339
|
+
type: "tool-input-start",
|
|
340
|
+
toolCallId: "call_approval",
|
|
341
|
+
toolName: "lookup",
|
|
342
|
+
title: "Lookup",
|
|
343
|
+
dynamic: true,
|
|
344
|
+
providerMetadata: { openai: { itemId: "fc_approval" } },
|
|
345
|
+
});
|
|
346
|
+
await writer.apply_chunk({
|
|
347
|
+
type: "tool-input-available",
|
|
348
|
+
toolCallId: "call_approval",
|
|
349
|
+
toolName: "lookup",
|
|
350
|
+
input: { q: "downcity" },
|
|
351
|
+
toolMetadata: { category: "search" },
|
|
352
|
+
});
|
|
353
|
+
await writer.apply_chunk({
|
|
354
|
+
type: "tool-approval-request",
|
|
355
|
+
approvalId: "approval_stream_1",
|
|
356
|
+
toolCallId: "call_approval",
|
|
357
|
+
});
|
|
358
|
+
await writer.apply_chunk({
|
|
359
|
+
type: "tool-output-denied",
|
|
360
|
+
toolCallId: "call_approval",
|
|
361
|
+
});
|
|
362
|
+
await writer.apply_chunk({
|
|
363
|
+
type: "file",
|
|
364
|
+
mediaType: "image/png",
|
|
365
|
+
url: "https://example.com/output.png",
|
|
366
|
+
providerMetadata: { openai: { fileId: "output_file" } },
|
|
367
|
+
});
|
|
368
|
+
await writer.apply_chunk({ type: "start-step" });
|
|
369
|
+
await writer.complete();
|
|
370
|
+
|
|
371
|
+
const assistant = (await read_jsonl(file_path))[0];
|
|
372
|
+
assert.deepEqual(assistant.parts.map((part) => part.type), [
|
|
373
|
+
"text",
|
|
374
|
+
"reasoning",
|
|
375
|
+
"source",
|
|
376
|
+
"data",
|
|
377
|
+
"tool",
|
|
378
|
+
"file",
|
|
379
|
+
"step-start",
|
|
380
|
+
]);
|
|
381
|
+
assert.deepEqual(assistant.parts[0].provider_metadata, {
|
|
382
|
+
openai: { itemId: "text_end" },
|
|
383
|
+
});
|
|
384
|
+
assert.deepEqual(assistant.parts[1].provider_metadata, {
|
|
385
|
+
openai: { itemId: "reasoning_delta" },
|
|
386
|
+
});
|
|
387
|
+
assert.equal(assistant.parts[2].url, "https://example.com/new");
|
|
388
|
+
assert.equal(assistant.parts[2].title, "Updated source");
|
|
389
|
+
assert.deepEqual(assistant.parts[3].data, { percent: 90 });
|
|
390
|
+
const tool_part = assistant.parts[4];
|
|
391
|
+
assert.equal(tool_part.state, "failed");
|
|
392
|
+
assert.equal(tool_part.title, "Lookup");
|
|
393
|
+
assert.equal(tool_part.dynamic, true);
|
|
394
|
+
assert.deepEqual(tool_part.tool_metadata, { category: "search" });
|
|
395
|
+
assert.deepEqual(tool_part.approval, {
|
|
396
|
+
approval_id: "approval_stream_1",
|
|
397
|
+
approved: false,
|
|
398
|
+
});
|
|
399
|
+
assert.equal(
|
|
400
|
+
assistant.parts.some((part) => part.type === "data" && part.data_id === "transient_1"),
|
|
401
|
+
false,
|
|
402
|
+
);
|
|
403
|
+
});
|
|
404
|
+
|
|
284
405
|
test("UI Tool Provider metadata 经 Session roundtrip 后恢复为 ModelMessage providerOptions", async () => {
|
|
285
406
|
const parts = from_ui_assistant_parts([{
|
|
286
407
|
type: "dynamic-tool",
|
|
@@ -388,6 +509,171 @@ test("UI Tool Provider metadata 经 Session roundtrip 后恢复为 ModelMessage
|
|
|
388
509
|
});
|
|
389
510
|
});
|
|
390
511
|
|
|
512
|
+
test("SessionMessage 无损恢复 AI SDK UIMessage 的完整 Part 语义", () => {
|
|
513
|
+
const user_parts = [
|
|
514
|
+
{
|
|
515
|
+
type: "text",
|
|
516
|
+
text: "分析附件",
|
|
517
|
+
state: "done",
|
|
518
|
+
providerMetadata: { openai: { itemId: "user_text_1" } },
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
type: "file",
|
|
522
|
+
mediaType: "image/png",
|
|
523
|
+
url: "https://example.com/input.png",
|
|
524
|
+
filename: "input.png",
|
|
525
|
+
providerMetadata: { openai: { fileId: "file_1" } },
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
type: "data-preferences",
|
|
529
|
+
id: "preferences_1",
|
|
530
|
+
data: { locale: "zh-CN" },
|
|
531
|
+
},
|
|
532
|
+
];
|
|
533
|
+
const restored_user = to_executor_ui_message({
|
|
534
|
+
message_id: "user_roundtrip",
|
|
535
|
+
session_id: "session_roundtrip",
|
|
536
|
+
turn_id: "turn_roundtrip",
|
|
537
|
+
sequence: 1,
|
|
538
|
+
revision: 1,
|
|
539
|
+
visibility: "visible",
|
|
540
|
+
created_at: 1,
|
|
541
|
+
updated_at: 1,
|
|
542
|
+
type: "user",
|
|
543
|
+
input_type: "prompt",
|
|
544
|
+
parts: from_ui_user_parts(user_parts),
|
|
545
|
+
});
|
|
546
|
+
assert.deepEqual(restored_user.parts, user_parts);
|
|
547
|
+
|
|
548
|
+
const assistant_parts = [
|
|
549
|
+
{
|
|
550
|
+
type: "text",
|
|
551
|
+
text: "正在处理",
|
|
552
|
+
state: "streaming",
|
|
553
|
+
providerMetadata: { openai: { itemId: "msg_1" } },
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
type: "reasoning",
|
|
557
|
+
text: "先读取来源",
|
|
558
|
+
state: "done",
|
|
559
|
+
providerMetadata: { openai: { itemId: "reasoning_1" } },
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
type: "file",
|
|
563
|
+
mediaType: "application/pdf",
|
|
564
|
+
url: "https://example.com/report.pdf",
|
|
565
|
+
filename: "report.pdf",
|
|
566
|
+
providerMetadata: { openai: { fileId: "file_2" } },
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
type: "source-url",
|
|
570
|
+
sourceId: "source_url_1",
|
|
571
|
+
url: "https://example.com/source",
|
|
572
|
+
title: "Example source",
|
|
573
|
+
providerMetadata: { openai: { itemId: "source_1" } },
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
type: "source-document",
|
|
577
|
+
sourceId: "source_document_1",
|
|
578
|
+
mediaType: "application/pdf",
|
|
579
|
+
title: "Source document",
|
|
580
|
+
filename: "source.pdf",
|
|
581
|
+
providerMetadata: { openai: { itemId: "source_2" } },
|
|
582
|
+
},
|
|
583
|
+
{ type: "step-start" },
|
|
584
|
+
{
|
|
585
|
+
type: "data-progress",
|
|
586
|
+
id: "progress_1",
|
|
587
|
+
data: { percent: 80 },
|
|
588
|
+
},
|
|
589
|
+
{
|
|
590
|
+
type: "dynamic-tool",
|
|
591
|
+
toolName: "lookup",
|
|
592
|
+
toolCallId: "call_streaming",
|
|
593
|
+
state: "input-streaming",
|
|
594
|
+
title: "Lookup",
|
|
595
|
+
toolMetadata: { category: "search" },
|
|
596
|
+
providerExecuted: false,
|
|
597
|
+
callProviderMetadata: { openai: { itemId: "fc_streaming" } },
|
|
598
|
+
},
|
|
599
|
+
{
|
|
600
|
+
type: "tool-fetch",
|
|
601
|
+
toolCallId: "call_ready",
|
|
602
|
+
state: "input-available",
|
|
603
|
+
input: { url: "https://example.com" },
|
|
604
|
+
title: "Fetch",
|
|
605
|
+
toolMetadata: { category: "network" },
|
|
606
|
+
providerExecuted: true,
|
|
607
|
+
callProviderMetadata: { openai: { itemId: "fc_ready" } },
|
|
608
|
+
},
|
|
609
|
+
{
|
|
610
|
+
type: "dynamic-tool",
|
|
611
|
+
toolName: "shell_exec",
|
|
612
|
+
toolCallId: "call_approval",
|
|
613
|
+
state: "approval-requested",
|
|
614
|
+
input: { cmd: "pwd" },
|
|
615
|
+
approval: { id: "approval_1" },
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
type: "tool-shell_exec",
|
|
619
|
+
toolCallId: "call_responded",
|
|
620
|
+
state: "approval-responded",
|
|
621
|
+
input: { cmd: "ls" },
|
|
622
|
+
approval: { id: "approval_2", approved: true, reason: "Allowed" },
|
|
623
|
+
},
|
|
624
|
+
{
|
|
625
|
+
type: "dynamic-tool",
|
|
626
|
+
toolName: "lookup",
|
|
627
|
+
toolCallId: "call_completed",
|
|
628
|
+
state: "output-available",
|
|
629
|
+
input: { q: "downcity" },
|
|
630
|
+
output: { count: 1 },
|
|
631
|
+
preliminary: true,
|
|
632
|
+
approval: { id: "approval_3", approved: true, reason: "Allowed" },
|
|
633
|
+
callProviderMetadata: { openai: { itemId: "fc_completed" } },
|
|
634
|
+
resultProviderMetadata: { openai: { itemId: "result_completed" } },
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
type: "dynamic-tool",
|
|
638
|
+
toolName: "parse",
|
|
639
|
+
toolCallId: "call_error",
|
|
640
|
+
state: "output-error",
|
|
641
|
+
input: undefined,
|
|
642
|
+
rawInput: "{invalid",
|
|
643
|
+
errorText: "Invalid input",
|
|
644
|
+
approval: { id: "approval_4", approved: true },
|
|
645
|
+
resultProviderMetadata: { openai: { itemId: "result_error" } },
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
type: "dynamic-tool",
|
|
649
|
+
toolName: "delete",
|
|
650
|
+
toolCallId: "call_denied",
|
|
651
|
+
state: "output-denied",
|
|
652
|
+
input: { path: "/tmp/a" },
|
|
653
|
+
approval: { id: "approval_5", approved: false, reason: "Denied" },
|
|
654
|
+
},
|
|
655
|
+
];
|
|
656
|
+
const canonical_parts = from_ui_assistant_parts(assistant_parts);
|
|
657
|
+
const restored_assistant = to_executor_ui_message({
|
|
658
|
+
message_id: "assistant_roundtrip",
|
|
659
|
+
session_id: "session_roundtrip",
|
|
660
|
+
turn_id: "turn_roundtrip",
|
|
661
|
+
sequence: 2,
|
|
662
|
+
revision: 1,
|
|
663
|
+
visibility: "visible",
|
|
664
|
+
created_at: 2,
|
|
665
|
+
updated_at: 2,
|
|
666
|
+
type: "assistant",
|
|
667
|
+
kind: "normal",
|
|
668
|
+
segment_index: 1,
|
|
669
|
+
status: "streaming",
|
|
670
|
+
parts: canonical_parts,
|
|
671
|
+
});
|
|
672
|
+
assert.deepEqual(restored_assistant.parts, assistant_parts);
|
|
673
|
+
assert.equal(canonical_parts.find((part) => part.tool_call_id === "call_ready").dynamic, false);
|
|
674
|
+
assert.equal(canonical_parts.find((part) => part.tool_call_id === "call_completed").dynamic, true);
|
|
675
|
+
});
|
|
676
|
+
|
|
391
677
|
test("Approval Broker 只接受已经准备完整输入的 Tool", async () => {
|
|
392
678
|
const { recorder, events } = await create_recorder("tool-ready-barrier-test");
|
|
393
679
|
const approval_broker = new SessionApprovalBroker({
|
|
@@ -430,7 +716,7 @@ test("Approval Broker 只接受已经准备完整输入的 Tool", async () => {
|
|
|
430
716
|
const tool = recorder.get_message(writer.message_id).parts[0];
|
|
431
717
|
assert.equal(tool.state, "approval-required");
|
|
432
718
|
assert.equal(tool.approval.approval_id, approval_handle.approval_id);
|
|
433
|
-
assert.equal(tool.approval.command, approval_input.command);
|
|
719
|
+
assert.equal(tool.approval.request.command, approval_input.command);
|
|
434
720
|
assert.equal(tool.input.cmd, "ls -la /Users/example/Desktop");
|
|
435
721
|
await approval_broker.resolve({
|
|
436
722
|
approval_id: approval_handle.approval_id,
|
|
@@ -154,9 +154,9 @@ test("unrestricted Shell 审批保留当前 Turn 并等待用户决定", async (
|
|
|
154
154
|
.flatMap((message) => message.type === "assistant" ? message.parts : [])
|
|
155
155
|
.find((part) => part.type === "tool" && part.tool_call_id === "call_unrestricted");
|
|
156
156
|
assert.ok(approval_snapshot, JSON.stringify(messages.items));
|
|
157
|
-
assert.equal(approval_snapshot.session_id, session.id);
|
|
158
|
-
assert.equal(approval_snapshot.turn_id, turn.id);
|
|
159
|
-
assert.equal(approval_snapshot.tool_call_id, "call_unrestricted");
|
|
157
|
+
assert.equal(approval_snapshot.request.session_id, session.id);
|
|
158
|
+
assert.equal(approval_snapshot.request.turn_id, turn.id);
|
|
159
|
+
assert.equal(approval_snapshot.request.tool_call_id, "call_unrestricted");
|
|
160
160
|
assert.deepEqual(await approval_result, {
|
|
161
161
|
success: true,
|
|
162
162
|
approval_id: approval_snapshot.approval_id,
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file 验证 Turn 失败只写入一次结构化错误,并保留已经生成的 Assistant 内容。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
|
|
11
|
+
import { SessionApprovalBroker } from "../bin/session/approval/SessionApprovalBroker.js";
|
|
12
|
+
import { JsonlSessionMessageStore } from "../bin/session/messages/JsonlSessionMessageStore.js";
|
|
13
|
+
import { SessionMessages } from "../bin/session/SessionMessages.js";
|
|
14
|
+
import { SessionEventHub } from "../bin/session/runtime/SessionEventHub.js";
|
|
15
|
+
import { SessionTurn } from "../bin/session/SessionTurn.js";
|
|
16
|
+
|
|
17
|
+
async function create_turn_harness(execute_run) {
|
|
18
|
+
const session_id = "session-turn-failure-test";
|
|
19
|
+
const root_path = await fs.mkdtemp(path.join(os.tmpdir(), "downcity-turn-failure-"));
|
|
20
|
+
const messages = new SessionMessages({
|
|
21
|
+
session_id,
|
|
22
|
+
store: new JsonlSessionMessageStore({
|
|
23
|
+
session_id,
|
|
24
|
+
file_path: path.join(root_path, "active.jsonl"),
|
|
25
|
+
}),
|
|
26
|
+
publish: () => {},
|
|
27
|
+
});
|
|
28
|
+
await messages.initialize();
|
|
29
|
+
|
|
30
|
+
const turn = new SessionTurn({
|
|
31
|
+
session_id,
|
|
32
|
+
project_root: root_path,
|
|
33
|
+
executor: {
|
|
34
|
+
run: async ({ runContext }) => await execute_run(runContext),
|
|
35
|
+
stop: () => false,
|
|
36
|
+
compact_history: async () => ({ compacted: false, reason: "nothing_to_compact" }),
|
|
37
|
+
},
|
|
38
|
+
state: {
|
|
39
|
+
ensure_runnable: async () => {},
|
|
40
|
+
ensure_title_from_history: async () => {},
|
|
41
|
+
touch_metadata: async () => {},
|
|
42
|
+
},
|
|
43
|
+
messages,
|
|
44
|
+
events: new SessionEventHub(),
|
|
45
|
+
approvals: new SessionApprovalBroker({ session_id, messages }),
|
|
46
|
+
apply_command: async () => {},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return { messages, turn };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
test("Provider 在输出前失败时只持久化 Error Message", async () => {
|
|
53
|
+
const { messages, turn } = await create_turn_harness(async () => ({
|
|
54
|
+
success: false,
|
|
55
|
+
error: "quota exceeded",
|
|
56
|
+
deferredPersistedUserMessages: [],
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
const handle = await turn.prompt({ query: "hello" });
|
|
60
|
+
const result = await handle.finished;
|
|
61
|
+
const page = await messages.list_messages();
|
|
62
|
+
|
|
63
|
+
assert.equal(result.success, false);
|
|
64
|
+
assert.equal(result.error, "quota exceeded");
|
|
65
|
+
assert.equal(result.assistantMessage, undefined);
|
|
66
|
+
assert.deepEqual(page.items.map((message) => message.type), ["user", "error"]);
|
|
67
|
+
assert.equal(page.items[1].code, "turn_execution_failed");
|
|
68
|
+
assert.equal(page.items[1].message, "quota exceeded");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("Provider 在部分输出后失败时保留 failed Assistant 并追加 Error Message", async () => {
|
|
72
|
+
const { messages, turn } = await create_turn_harness(async (run_context) => {
|
|
73
|
+
await run_context.onUiMessageChunkCallback({ type: "text-start", id: "text-1" });
|
|
74
|
+
await run_context.onUiMessageChunkCallback({
|
|
75
|
+
type: "text-delta",
|
|
76
|
+
id: "text-1",
|
|
77
|
+
delta: "partial response",
|
|
78
|
+
});
|
|
79
|
+
await run_context.onUiMessageChunkCallback({ type: "text-end", id: "text-1" });
|
|
80
|
+
return {
|
|
81
|
+
success: false,
|
|
82
|
+
error: "stream interrupted",
|
|
83
|
+
deferredPersistedUserMessages: [],
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const handle = await turn.prompt({ query: "hello" });
|
|
88
|
+
const result = await handle.finished;
|
|
89
|
+
const page = await messages.list_messages();
|
|
90
|
+
|
|
91
|
+
assert.equal(result.success, false);
|
|
92
|
+
assert.equal(result.assistantMessage, undefined);
|
|
93
|
+
assert.deepEqual(page.items.map((message) => message.type), [
|
|
94
|
+
"user",
|
|
95
|
+
"assistant",
|
|
96
|
+
"error",
|
|
97
|
+
]);
|
|
98
|
+
assert.equal(page.items[1].status, "failed");
|
|
99
|
+
assert.equal(page.items[1].parts[0].text, "partial response");
|
|
100
|
+
assert.equal(page.items[2].message, "stream interrupted");
|
|
101
|
+
});
|
|
@@ -601,10 +601,6 @@ export class CoreEngineRunner {
|
|
|
601
601
|
return {
|
|
602
602
|
success: false,
|
|
603
603
|
error: error_text,
|
|
604
|
-
assistantMessage: build_fallback_assistant_message(
|
|
605
|
-
session_id,
|
|
606
|
-
`Execution failed: ${error_text}`,
|
|
607
|
-
),
|
|
608
604
|
...(compact_required ? { compact_required: true } : {}),
|
|
609
605
|
deferredPersistedUserMessages: [
|
|
610
606
|
...input.run_context.deferredPersistedUserMessages,
|
|
@@ -635,7 +631,7 @@ function build_internal_user_message(input: {
|
|
|
635
631
|
};
|
|
636
632
|
}
|
|
637
633
|
|
|
638
|
-
/**
|
|
634
|
+
/** 构造成功执行但缺少最终消息时使用的临时 Assistant Message。 */
|
|
639
635
|
function build_fallback_assistant_message(
|
|
640
636
|
session_id: string,
|
|
641
637
|
text: string,
|
|
@@ -107,16 +107,15 @@ interface ExecutorRecoveryRunInput {
|
|
|
107
107
|
* 执行恢复与重试策略服务。
|
|
108
108
|
*/
|
|
109
109
|
export class ExecutorRecoveryPolicy {
|
|
110
|
-
private readonly session_id: string;
|
|
111
110
|
private readonly should_compact: ExecutorRecoveryPolicyOptions["should_compact"];
|
|
112
111
|
private readonly logger: Logger;
|
|
113
112
|
private retry_count = 0;
|
|
114
113
|
|
|
115
114
|
constructor(options: ExecutorRecoveryPolicyOptions) {
|
|
116
|
-
|
|
115
|
+
const session_id = String(options.session_id || "").trim();
|
|
117
116
|
this.should_compact = options.should_compact;
|
|
118
117
|
this.logger = options.logger;
|
|
119
|
-
if (!
|
|
118
|
+
if (!session_id) {
|
|
120
119
|
throw new Error("ExecutorRecoveryPolicy requires a non-empty session_id");
|
|
121
120
|
}
|
|
122
121
|
}
|
|
@@ -161,8 +160,6 @@ export class ExecutorRecoveryPolicy {
|
|
|
161
160
|
return this.build_failure_result({
|
|
162
161
|
error_text:
|
|
163
162
|
"Context length exceeded and retries failed. Please resend your question.",
|
|
164
|
-
fallback_text:
|
|
165
|
-
"Context length exceeded and retries failed. Please resend your question.",
|
|
166
163
|
run_context: input.run_context,
|
|
167
164
|
});
|
|
168
165
|
}
|
|
@@ -173,7 +170,6 @@ export class ExecutorRecoveryPolicy {
|
|
|
173
170
|
});
|
|
174
171
|
return this.build_failure_result({
|
|
175
172
|
error_text,
|
|
176
|
-
fallback_text: `Execution failed: ${error_text}`,
|
|
177
173
|
run_context: input.run_context,
|
|
178
174
|
});
|
|
179
175
|
}
|
|
@@ -185,11 +181,6 @@ export class ExecutorRecoveryPolicy {
|
|
|
185
181
|
*/
|
|
186
182
|
error_text: string;
|
|
187
183
|
|
|
188
|
-
/**
|
|
189
|
-
* fallback assistant 消息文本。
|
|
190
|
-
*/
|
|
191
|
-
fallback_text: string;
|
|
192
|
-
|
|
193
184
|
/**
|
|
194
185
|
* 当前显式运行上下文。
|
|
195
186
|
*/
|
|
@@ -198,18 +189,6 @@ export class ExecutorRecoveryPolicy {
|
|
|
198
189
|
return {
|
|
199
190
|
success: false,
|
|
200
191
|
error: input.error_text,
|
|
201
|
-
assistantMessage: {
|
|
202
|
-
id: `a:${this.session_id}:${Date.now()}`,
|
|
203
|
-
role: "assistant",
|
|
204
|
-
metadata: {
|
|
205
|
-
v: 1,
|
|
206
|
-
ts: Date.now(),
|
|
207
|
-
sessionId: this.session_id,
|
|
208
|
-
source: "egress",
|
|
209
|
-
kind: "normal",
|
|
210
|
-
},
|
|
211
|
-
parts: [{ type: "text", text: input.fallback_text }],
|
|
212
|
-
},
|
|
213
192
|
deferredPersistedUserMessages: [
|
|
214
193
|
...input.run_context.deferredPersistedUserMessages,
|
|
215
194
|
],
|