@downcity/agent 1.1.400 → 1.1.408
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +183 -0
- 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 +15 -17
- 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
package/city-test/group.test.mjs
CHANGED
|
@@ -116,6 +116,8 @@ test("Group.model uses AI dispatch to select only the returned members", async (
|
|
|
116
116
|
await group_session.prompt({ query: "请审查这个变更" });
|
|
117
117
|
await wait_for_group_idle(group_session);
|
|
118
118
|
assert.deepEqual(RecordingSession.created.map((entry) => entry.agent_id), ["reviewer"]);
|
|
119
|
+
assert.match(RecordingSession.created[0].query, /你已被 Group 调度选中,必须直接回复当前消息/);
|
|
120
|
+
assert.doesNotMatch(RecordingSession.created[0].query, /判断是否需要回复|如果不需要你发言/);
|
|
119
121
|
await city.close();
|
|
120
122
|
});
|
|
121
123
|
|
|
@@ -208,6 +210,50 @@ test("AI Dispatch 未调用 dispatch_group 时记录协议错误", async () => {
|
|
|
208
210
|
await city.close();
|
|
209
211
|
});
|
|
210
212
|
|
|
213
|
+
test("AI Dispatch 在同一调度 Turn 内纠正未调用工具的响应", async () => {
|
|
214
|
+
RecordingSession.created = [];
|
|
215
|
+
let dispatch_calls = 0;
|
|
216
|
+
const dispatch_model = new MockModelClient({
|
|
217
|
+
modelId: "recovering-dispatch-model",
|
|
218
|
+
doGenerate: async (call) => {
|
|
219
|
+
dispatch_calls += 1;
|
|
220
|
+
if (dispatch_calls === 1) {
|
|
221
|
+
return {
|
|
222
|
+
content: [{ type: "text", text: "我建议让 responder 回复。" }],
|
|
223
|
+
finishReason: { unified: "stop", raw: "stop" },
|
|
224
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
225
|
+
warnings: [],
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
assert.equal(call.messages.some((message) => (
|
|
229
|
+
message.role === "user" && message.content.some((part) => (
|
|
230
|
+
part.type === "text" && part.text.includes("上一响应不符合调度协议")
|
|
231
|
+
))
|
|
232
|
+
)), true);
|
|
233
|
+
return {
|
|
234
|
+
content: [create_dispatch_tool_call({ steps: [["responder"]], next: "stop" })],
|
|
235
|
+
finishReason: { unified: "tool-calls", raw: "tool_calls" },
|
|
236
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
237
|
+
warnings: [],
|
|
238
|
+
};
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
const city = new City();
|
|
242
|
+
const agent = new Agent({ id: "responder", session_class: RecordingSession });
|
|
243
|
+
city.agents.add(agent);
|
|
244
|
+
const group = new Group({ id: "recovering-dispatch-group", model: dispatch_model, members: [agent] });
|
|
245
|
+
city.groups.add(group);
|
|
246
|
+
const group_session = await group.sessions.create();
|
|
247
|
+
|
|
248
|
+
await group_session.prompt({ query: "请回答" });
|
|
249
|
+
await wait_for_group_idle(group_session);
|
|
250
|
+
|
|
251
|
+
assert.equal(dispatch_calls, 2);
|
|
252
|
+
assert.deepEqual(RecordingSession.created.map((entry) => entry.agent_id), ["responder"]);
|
|
253
|
+
assert.equal((await group_session.messages()).some((message) => message.sender_type === "system"), false);
|
|
254
|
+
await city.close();
|
|
255
|
+
});
|
|
256
|
+
|
|
211
257
|
test("AI Dispatch 拒绝未知成员并允许跨阶段重复投递", async () => {
|
|
212
258
|
const inputs = [
|
|
213
259
|
{ input: { steps: [["unknown"]], next: "stop" }, expected_agent_ids: [] },
|
|
@@ -414,6 +460,157 @@ test("GroupSession 使用 City Storage 持久化并可恢复", async () => {
|
|
|
414
460
|
await fs.rm(root_path, { recursive: true, force: true });
|
|
415
461
|
});
|
|
416
462
|
|
|
463
|
+
test("GroupSession 持久化独享 Dispatch Session 的调度 Turn", async () => {
|
|
464
|
+
RecordingSession.created = [];
|
|
465
|
+
const root_path = await fs.mkdtemp(path.join(os.tmpdir(), "downcity-group-dispatch-session-"));
|
|
466
|
+
const city = new City({ storage: new LocalStorageProvider(root_path) });
|
|
467
|
+
const agent = new Agent({ id: "dispatch-member", session_class: RecordingSession });
|
|
468
|
+
city.agents.add(agent);
|
|
469
|
+
const group = new Group({ id: "dispatch-session-group", members: [agent], dispatch_strategy: test_dispatch_strategy });
|
|
470
|
+
city.groups.add(group);
|
|
471
|
+
const session = await group.sessions.create();
|
|
472
|
+
|
|
473
|
+
await session.prompt({ query: "persist dispatch" });
|
|
474
|
+
await wait_for_group_idle(session);
|
|
475
|
+
|
|
476
|
+
const turns_path = path.join(
|
|
477
|
+
root_path,
|
|
478
|
+
"groups",
|
|
479
|
+
"dispatch-session-group",
|
|
480
|
+
"sessions",
|
|
481
|
+
encodeURIComponent(session.id),
|
|
482
|
+
"dispatch",
|
|
483
|
+
"turns.jsonl",
|
|
484
|
+
);
|
|
485
|
+
const records = (await fs.readFile(turns_path, "utf8"))
|
|
486
|
+
.trim()
|
|
487
|
+
.split("\n")
|
|
488
|
+
.map((line) => JSON.parse(line));
|
|
489
|
+
const user_records = records.filter((record) => record.trigger === "user");
|
|
490
|
+
assert.deepEqual(user_records.map((record) => record.status), ["queued", "running", "completed"]);
|
|
491
|
+
assert.deepEqual(user_records.at(-1).decision.nodes[0].member_ids, ["dispatch-member"]);
|
|
492
|
+
assert.equal(
|
|
493
|
+
(await session.messages()).find((message) => message.sender_type === "agent").dispatch_id,
|
|
494
|
+
user_records.at(-1).dispatch_id,
|
|
495
|
+
);
|
|
496
|
+
await city.close();
|
|
497
|
+
await fs.rm(root_path, { recursive: true, force: true });
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("GroupSession stop 会中断 Dispatch Session 且不记录失败消息", async () => {
|
|
501
|
+
const root_path = await fs.mkdtemp(path.join(os.tmpdir(), "downcity-group-dispatch-stop-"));
|
|
502
|
+
let dispatch_started = false;
|
|
503
|
+
let dispatch_aborted = false;
|
|
504
|
+
let dispatch_invocations = 0;
|
|
505
|
+
const strategy = {
|
|
506
|
+
async decide_dispatch({ abort_signal }) {
|
|
507
|
+
dispatch_invocations += 1;
|
|
508
|
+
dispatch_started = true;
|
|
509
|
+
await new Promise((resolve, reject) => {
|
|
510
|
+
const abort = () => {
|
|
511
|
+
dispatch_aborted = true;
|
|
512
|
+
reject(abort_signal.reason);
|
|
513
|
+
};
|
|
514
|
+
if (abort_signal.aborted) abort();
|
|
515
|
+
else abort_signal.addEventListener("abort", abort, { once: true });
|
|
516
|
+
});
|
|
517
|
+
return { nodes: [], terminal: true };
|
|
518
|
+
},
|
|
519
|
+
};
|
|
520
|
+
const city = new City({ storage: new LocalStorageProvider(root_path) });
|
|
521
|
+
const agent = new Agent({ id: "unused-member", session_class: RecordingSession });
|
|
522
|
+
city.agents.add(agent);
|
|
523
|
+
const group = new Group({ id: "dispatch-stop-group", members: [agent], dispatch_strategy: strategy });
|
|
524
|
+
city.groups.add(group);
|
|
525
|
+
const session = await group.sessions.create();
|
|
526
|
+
await session.prompt({ query: "stop dispatch" });
|
|
527
|
+
await session.prompt({ query: "cancel queued dispatch" });
|
|
528
|
+
for (let attempt = 0; attempt < 200 && !dispatch_started; attempt += 1) {
|
|
529
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
await session.stop();
|
|
533
|
+
|
|
534
|
+
assert.equal(dispatch_aborted, true);
|
|
535
|
+
assert.equal(dispatch_invocations, 1);
|
|
536
|
+
assert.deepEqual((await session.messages()).map((message) => message.text), [
|
|
537
|
+
"stop dispatch",
|
|
538
|
+
"cancel queued dispatch",
|
|
539
|
+
]);
|
|
540
|
+
const turns_path = path.join(
|
|
541
|
+
root_path,
|
|
542
|
+
"groups",
|
|
543
|
+
"dispatch-stop-group",
|
|
544
|
+
"sessions",
|
|
545
|
+
encodeURIComponent(session.id),
|
|
546
|
+
"dispatch",
|
|
547
|
+
"turns.jsonl",
|
|
548
|
+
);
|
|
549
|
+
const records = (await fs.readFile(turns_path, "utf8"))
|
|
550
|
+
.trim()
|
|
551
|
+
.split("\n")
|
|
552
|
+
.map((line) => JSON.parse(line));
|
|
553
|
+
const latest_records = new Map(records.map((record) => [record.dispatch_id, record]));
|
|
554
|
+
assert.equal(latest_records.size, 2);
|
|
555
|
+
assert.deepEqual([...latest_records.values()].map((record) => record.status), ["stopped", "stopped"]);
|
|
556
|
+
await city.close();
|
|
557
|
+
await fs.rm(root_path, { recursive: true, force: true });
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("GroupSession 恢复时重新执行中断的用户调度", async () => {
|
|
561
|
+
RecordingSession.created = [];
|
|
562
|
+
const root_path = await fs.mkdtemp(path.join(os.tmpdir(), "downcity-group-dispatch-recovery-"));
|
|
563
|
+
let original_abort_signal;
|
|
564
|
+
let original_dispatch_started = false;
|
|
565
|
+
const original_city = new City({ storage: new LocalStorageProvider(root_path) });
|
|
566
|
+
const original_agent = new Agent({ id: "recovery-member", session_class: RecordingSession });
|
|
567
|
+
original_city.agents.add(original_agent);
|
|
568
|
+
const original_group = new Group({
|
|
569
|
+
id: "dispatch-recovery-group",
|
|
570
|
+
members: [original_agent],
|
|
571
|
+
dispatch_strategy: {
|
|
572
|
+
async decide_dispatch({ abort_signal }) {
|
|
573
|
+
original_abort_signal = abort_signal;
|
|
574
|
+
original_dispatch_started = true;
|
|
575
|
+
await new Promise((resolve, reject) => {
|
|
576
|
+
abort_signal.addEventListener("abort", () => reject(abort_signal.reason), { once: true });
|
|
577
|
+
});
|
|
578
|
+
return { nodes: [], terminal: true };
|
|
579
|
+
},
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
original_city.groups.add(original_group);
|
|
583
|
+
const original_session = await original_group.sessions.create();
|
|
584
|
+
await original_session.prompt({ query: "recover dispatch" });
|
|
585
|
+
for (let attempt = 0; attempt < 200 && !original_dispatch_started; attempt += 1) {
|
|
586
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const restored_city = new City({ storage: new LocalStorageProvider(root_path) });
|
|
590
|
+
const restored_agent = new Agent({ id: "recovery-member", session_class: RecordingSession });
|
|
591
|
+
restored_city.agents.add(restored_agent);
|
|
592
|
+
const restored_group = new Group({
|
|
593
|
+
id: "dispatch-recovery-group",
|
|
594
|
+
members: [restored_agent],
|
|
595
|
+
dispatch_strategy: test_dispatch_strategy,
|
|
596
|
+
});
|
|
597
|
+
restored_city.groups.add(restored_group);
|
|
598
|
+
const restored_session = await restored_group.sessions.get(original_session.id);
|
|
599
|
+
assert.ok(restored_session);
|
|
600
|
+
await wait_for_group_idle(restored_session);
|
|
601
|
+
|
|
602
|
+
assert.deepEqual(RecordingSession.created.map((entry) => entry.agent_id), ["recovery-member"]);
|
|
603
|
+
assert.deepEqual((await restored_session.messages()).map((message) => message.text), [
|
|
604
|
+
"recover dispatch",
|
|
605
|
+
"reply:recovery-member",
|
|
606
|
+
]);
|
|
607
|
+
await original_session.stop();
|
|
608
|
+
assert.equal(original_abort_signal.aborted, true);
|
|
609
|
+
await restored_city.close();
|
|
610
|
+
await original_city.close();
|
|
611
|
+
await fs.rm(root_path, { recursive: true, force: true });
|
|
612
|
+
});
|
|
613
|
+
|
|
417
614
|
test("GroupSession 持久化调度检查点并在收口后清理", async () => {
|
|
418
615
|
const root_path = await fs.mkdtemp(path.join(os.tmpdir(), "downcity-group-checkpoint-"));
|
|
419
616
|
const storage = new LocalStorageProvider(root_path);
|
|
@@ -450,6 +647,56 @@ test("GroupSession 持久化调度检查点并在收口后清理", async () => {
|
|
|
450
647
|
await fs.rm(root_path, { recursive: true, force: true });
|
|
451
648
|
});
|
|
452
649
|
|
|
650
|
+
test("GroupSession 列表首次读取时将 v1 metadata 原子迁移为 v2", async () => {
|
|
651
|
+
const root_path = await fs.mkdtemp(path.join(os.tmpdir(), "downcity-group-metadata-migration-"));
|
|
652
|
+
const group_id = "metadata-migration-group";
|
|
653
|
+
const session_id = "legacy-session";
|
|
654
|
+
const session_path = path.join(root_path, "groups", group_id, "sessions", session_id);
|
|
655
|
+
const meta_path = path.join(session_path, "meta.json");
|
|
656
|
+
await fs.mkdir(session_path, { recursive: true });
|
|
657
|
+
await fs.writeFile(meta_path, `${JSON.stringify({
|
|
658
|
+
v: 1,
|
|
659
|
+
session_id,
|
|
660
|
+
group_id,
|
|
661
|
+
created_at: 1,
|
|
662
|
+
updated_at: 2,
|
|
663
|
+
message_count: 1,
|
|
664
|
+
preview_text: "legacy message",
|
|
665
|
+
pending_turns: [{
|
|
666
|
+
turn_id: "legacy-turn",
|
|
667
|
+
root_message_id: "legacy-message",
|
|
668
|
+
context_message_ids: [],
|
|
669
|
+
}],
|
|
670
|
+
auto_frontier_message_ids: ["legacy-reply"],
|
|
671
|
+
}, null, 2)}\n`);
|
|
672
|
+
|
|
673
|
+
const city = new City({ storage: new LocalStorageProvider(root_path) });
|
|
674
|
+
const agent = new Agent({ id: "metadata-agent", session_class: RecordingSession });
|
|
675
|
+
city.agents.add(agent);
|
|
676
|
+
const group = new Group({ id: group_id, members: [agent], dispatch_strategy: test_dispatch_strategy });
|
|
677
|
+
city.groups.add(group);
|
|
678
|
+
|
|
679
|
+
assert.deepEqual(await group.sessions.list(), [{
|
|
680
|
+
id: session_id,
|
|
681
|
+
group_id,
|
|
682
|
+
created_at: 1,
|
|
683
|
+
updated_at: 2,
|
|
684
|
+
message_count: 1,
|
|
685
|
+
preview_text: "legacy message",
|
|
686
|
+
}]);
|
|
687
|
+
const migrated_metadata = JSON.parse(await fs.readFile(meta_path, "utf8"));
|
|
688
|
+
assert.equal(migrated_metadata.v, 2);
|
|
689
|
+
assert.deepEqual(migrated_metadata.pending_turns, [{
|
|
690
|
+
turn_id: "legacy-turn",
|
|
691
|
+
root_message_id: "legacy-message",
|
|
692
|
+
context_message_ids: [],
|
|
693
|
+
dispatch_stage: "auto",
|
|
694
|
+
}]);
|
|
695
|
+
assert.deepEqual(migrated_metadata.auto_frontier_message_ids, ["legacy-reply"]);
|
|
696
|
+
await city.close();
|
|
697
|
+
await fs.rm(root_path, { recursive: true, force: true });
|
|
698
|
+
});
|
|
699
|
+
|
|
453
700
|
test("GroupSession 恢复时修复尾部损坏的消息记录", async () => {
|
|
454
701
|
const root_path = await fs.mkdtemp(path.join(os.tmpdir(), "downcity-group-tail-"));
|
|
455
702
|
const storage = new LocalStorageProvider(root_path);
|
|
@@ -628,7 +875,7 @@ test("唯一 auto dispatch 等待并合并并发输入批次", async () => {
|
|
|
628
875
|
await city.close();
|
|
629
876
|
});
|
|
630
877
|
|
|
631
|
-
test("
|
|
878
|
+
test("成员返回空内容时记录失败且不继续投递下游节点", async () => {
|
|
632
879
|
RecordingSession.created = [];
|
|
633
880
|
class EmptySession extends Session {
|
|
634
881
|
async prompt() {
|
|
@@ -665,6 +912,10 @@ test("响应图依赖没有有效回复时不会继续投递下游节点", async
|
|
|
665
912
|
await group_session.prompt({ query: "请分析并审查" });
|
|
666
913
|
await wait_for_group_idle(group_session);
|
|
667
914
|
assert.deepEqual(RecordingSession.created.map((entry) => entry.agent_id), ["architect"]);
|
|
915
|
+
assert.deepEqual((await group_session.messages()).map((message) => [message.sender_type, message.text]), [
|
|
916
|
+
["user", "请分析并审查"],
|
|
917
|
+
["system", "architect 执行失败:未生成有效回复"],
|
|
918
|
+
]);
|
|
668
919
|
await city.close();
|
|
669
920
|
});
|
|
670
921
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@downcity/agent",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.408",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Downcity 单 Agent SDK 与执行运行时",
|
|
6
6
|
"main": "./bin/index.js",
|
|
@@ -27,19 +27,10 @@
|
|
|
27
27
|
"types": "./bin/internal/index.d.ts"
|
|
28
28
|
}
|
|
29
29
|
},
|
|
30
|
-
"scripts": {
|
|
31
|
-
"build": "node scripts/agent-compiler.mjs build",
|
|
32
|
-
"dev": "node scripts/agent-compiler.mjs dev",
|
|
33
|
-
"test": "pnpm build && pnpm --filter '@downcity/sandbox-*' build && pnpm -C ../plugins build && node --test scripts/*.test.mjs city-test/*.test.mjs",
|
|
34
|
-
"test:executor-runtime": "pnpm build && node --test scripts/session-prompt-runtime.test.mjs",
|
|
35
|
-
"typecheck": "node scripts/agent-compiler.mjs typecheck",
|
|
36
|
-
"prepack": "npm run build && node ../../scripts/rewrite-workspace-dependencies.mjs prepare ./package.json",
|
|
37
|
-
"postpack": "node ../../scripts/rewrite-workspace-dependencies.mjs restore ./package.json"
|
|
38
|
-
},
|
|
39
30
|
"dependencies": {
|
|
40
|
-
"@downcity/workspace": "^0.1.
|
|
41
|
-
"@downcity/type": "0.1.
|
|
42
|
-
"@downcity/federation": "^0.1.
|
|
31
|
+
"@downcity/workspace": "^0.1.7",
|
|
32
|
+
"@downcity/type": "0.1.202",
|
|
33
|
+
"@downcity/federation": "^0.1.15",
|
|
43
34
|
"commander": "^15.0.0",
|
|
44
35
|
"dotenv": "^17.4.2",
|
|
45
36
|
"execa": "^9.6.1",
|
|
@@ -53,9 +44,9 @@
|
|
|
53
44
|
"zod": "^4.4.3"
|
|
54
45
|
},
|
|
55
46
|
"devDependencies": {
|
|
56
|
-
"@downcity/sandbox-linux": "^0.1.
|
|
57
|
-
"@downcity/sandbox-macos": "^0.1.
|
|
58
|
-
"@downcity/sandbox-windows-mxc": "^0.1.
|
|
47
|
+
"@downcity/sandbox-linux": "^0.1.38",
|
|
48
|
+
"@downcity/sandbox-macos": "^0.1.38",
|
|
49
|
+
"@downcity/sandbox-windows-mxc": "^0.1.38",
|
|
59
50
|
"@types/fs-extra": "^11.0.4",
|
|
60
51
|
"@types/js-yaml": "^4.0.9",
|
|
61
52
|
"@types/node": "^25.9.1",
|
|
@@ -63,5 +54,12 @@
|
|
|
63
54
|
"@types/ws": "^8.18.1",
|
|
64
55
|
"tsc-alias": "^1.8.17",
|
|
65
56
|
"typescript": "^6.0.3"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"build": "node scripts/agent-compiler.mjs build",
|
|
60
|
+
"dev": "node scripts/agent-compiler.mjs dev",
|
|
61
|
+
"test": "pnpm build && pnpm --filter '@downcity/sandbox-*' build && pnpm -C ../plugins build && node --test scripts/*.test.mjs city-test/*.test.mjs",
|
|
62
|
+
"test:executor-runtime": "pnpm build && node --test scripts/session-prompt-runtime.test.mjs",
|
|
63
|
+
"typecheck": "node scripts/agent-compiler.mjs typecheck"
|
|
66
64
|
}
|
|
67
|
-
}
|
|
65
|
+
}
|
|
@@ -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
|
+
}
|