@klarkxy/dsh-fusion 0.1.0
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 +41 -0
- package/README.md +33 -0
- package/cordis.patch.yml +4 -0
- package/docs/README.zh-CN.md +13 -0
- package/lib/client.inner.cjs +959 -0
- package/lib/client.inner.cjs.map +1 -0
- package/lib/client.js +1129 -0
- package/lib/contracts.d.ts +173 -0
- package/lib/contracts.js +28 -0
- package/lib/contracts.js.map +1 -0
- package/lib/host-contracts.d.ts +38 -0
- package/lib/host-contracts.js +1 -0
- package/lib/index.d.ts +134 -0
- package/lib/index.js +1670 -0
- package/lib/index.js.map +1 -0
- package/package.json +128 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1670 @@
|
|
|
1
|
+
import { FUSION_PLUGIN, FUSION_PURPOSE, FUSION_RPC_CHANNEL, FUSION_TOOLS, emptyFusionState, isWorking } from "./contracts.js";
|
|
2
|
+
import { registerHostRpc } from "@klarkxy/dsh-ai-services";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { foldSubagentDescriptor } from "@deepseek-ai/dsh-subagent";
|
|
7
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
8
|
+
//#region src/validation.ts
|
|
9
|
+
var FusionError = class extends Error {
|
|
10
|
+
code;
|
|
11
|
+
constructor(code, message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "FusionError";
|
|
14
|
+
this.code = code;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
function requireFusion(condition, code, message) {
|
|
18
|
+
if (!condition) throw new FusionError(code, message);
|
|
19
|
+
}
|
|
20
|
+
function text(value, label, max, empty = false) {
|
|
21
|
+
requireFusion(typeof value === "string" && value.length <= max && (empty || value.trim().length > 0), "INVALID_INPUT", `${label} must be ${empty ? "0" : "1"}–${max} characters.`);
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
function integer(value, label) {
|
|
25
|
+
requireFusion(Number.isSafeInteger(value) && Number(value) >= 1, "INVALID_INPUT", `${label} must be a positive integer.`);
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
function object(value) {
|
|
29
|
+
requireFusion(value !== null && typeof value === "object" && !Array.isArray(value), "INVALID_INPUT", "Expected an object.");
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
function stringList(value, label, limit = 32) {
|
|
33
|
+
requireFusion(Array.isArray(value) && value.length <= limit, "INVALID_INPUT", `${label} must have at most ${limit} items.`);
|
|
34
|
+
return value.map((item) => text(item, label, 2e3));
|
|
35
|
+
}
|
|
36
|
+
function brief(value) {
|
|
37
|
+
const row = object(value);
|
|
38
|
+
return {
|
|
39
|
+
title: text(row.title, "title", 120),
|
|
40
|
+
goal: text(row.goal, "goal", 8e3),
|
|
41
|
+
context: text(row.context ?? "", "context", 48e3, true),
|
|
42
|
+
constraints: stringList(row.constraints ?? [], "constraints"),
|
|
43
|
+
acceptance: stringList(row.acceptance ?? [], "acceptance")
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function route(value) {
|
|
47
|
+
const row = object(value);
|
|
48
|
+
return {
|
|
49
|
+
provider: text(row.provider, "provider", 300),
|
|
50
|
+
model: text(row.model, "model", 300),
|
|
51
|
+
...row.reasoningEffort === void 0 ? {} : { reasoningEffort: text(row.reasoningEffort, "reasoningEffort", 100) }
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function json(value, depth = 0) {
|
|
55
|
+
requireFusion(depth <= 12, "INVALID_INPUT", "Target is nested too deeply.");
|
|
56
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") return;
|
|
57
|
+
if (typeof value === "number") {
|
|
58
|
+
requireFusion(Number.isFinite(value), "INVALID_INPUT", "Non-finite target value.");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (Array.isArray(value)) {
|
|
62
|
+
requireFusion(value.length <= 128, "INVALID_INPUT", "Target array too large.");
|
|
63
|
+
value.forEach((item) => json(item, depth + 1));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const row = object(value);
|
|
67
|
+
requireFusion(Object.keys(row).length <= 128, "INVALID_INPUT", "Target object too large.");
|
|
68
|
+
for (const [key, item] of Object.entries(row)) {
|
|
69
|
+
requireFusion(![
|
|
70
|
+
"__proto__",
|
|
71
|
+
"constructor",
|
|
72
|
+
"prototype"
|
|
73
|
+
].includes(key), "INVALID_INPUT", "Unsafe target key.");
|
|
74
|
+
json(item, depth + 1);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function target(value) {
|
|
78
|
+
if (value === void 0) return void 0;
|
|
79
|
+
const row = object(value);
|
|
80
|
+
const domain = text(row.domain, "target domain", 100);
|
|
81
|
+
const data = object(row.data);
|
|
82
|
+
json(data);
|
|
83
|
+
requireFusion(JSON.stringify(data).length <= 2e5, "INVALID_INPUT", "Target exceeds storage limit.");
|
|
84
|
+
return {
|
|
85
|
+
domain,
|
|
86
|
+
data: structuredClone(data)
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function timestamp(value) {
|
|
90
|
+
requireFusion(typeof value === "number" && Number.isSafeInteger(value) && value >= 0, "INVALID_STATE", "Invalid record timestamp.");
|
|
91
|
+
}
|
|
92
|
+
/** Validate persisted control records before allowing any native work to resume. */
|
|
93
|
+
function validateState(value) {
|
|
94
|
+
const row = object(value);
|
|
95
|
+
requireFusion(row.version === 1 && Number.isSafeInteger(row.revision) && Number(row.revision) >= 0, "INVALID_STATE", "Unsupported Fusion storage version.");
|
|
96
|
+
requireFusion(Array.isArray(row.pairs) && row.pairs.length <= 512, "INVALID_STATE", "Invalid Fusion pair table.");
|
|
97
|
+
const pairIds = /* @__PURE__ */ new Set(), leads = /* @__PURE__ */ new Set(), children = /* @__PURE__ */ new Set(), tasks = /* @__PURE__ */ new Set();
|
|
98
|
+
for (const value of row.pairs) {
|
|
99
|
+
const pair = object(value);
|
|
100
|
+
const pairId = text(pair.id, "pair id", 200);
|
|
101
|
+
requireFusion(!pairIds.has(pairId), "INVALID_STATE", "Duplicate pair identity.");
|
|
102
|
+
pairIds.add(pairId);
|
|
103
|
+
timestamp(pair.createdAt);
|
|
104
|
+
text(pair.project, "project identity", 8192);
|
|
105
|
+
const lead = text(pair.leadSessionId, "lead id", 200), child = text(pair.childSessionId, "child id", 200);
|
|
106
|
+
requireFusion(!leads.has(lead) && !children.has(child) && lead !== child, "INVALID_STATE", "Duplicate Fusion identity.");
|
|
107
|
+
leads.add(lead);
|
|
108
|
+
children.add(child);
|
|
109
|
+
requireFusion(pair.profile === "generic" || pair.profile === "writing", "INVALID_STATE", "Unknown Fusion profile.");
|
|
110
|
+
requireFusion(typeof pair.established === "boolean", "INVALID_STATE", "Missing admission state.");
|
|
111
|
+
route(pair.route);
|
|
112
|
+
requireFusion(Array.isArray(pair.tasks) && pair.tasks.length <= 128, "INVALID_STATE", "Invalid task table.");
|
|
113
|
+
for (const value of pair.tasks) {
|
|
114
|
+
const task = object(value), id = text(task.id, "task id", 200);
|
|
115
|
+
requireFusion(!tasks.has(id), "INVALID_STATE", "Duplicate task identity.");
|
|
116
|
+
tasks.add(id);
|
|
117
|
+
timestamp(task.createdAt);
|
|
118
|
+
timestamp(task.updatedAt);
|
|
119
|
+
if (task.error !== void 0) text(task.error, "task error", 16e3, true);
|
|
120
|
+
if (task.decision !== void 0) text(task.decision, "task decision", 16e3, true);
|
|
121
|
+
integer(task.revision, "task revision");
|
|
122
|
+
brief(task.brief);
|
|
123
|
+
target(task.target);
|
|
124
|
+
requireFusion([
|
|
125
|
+
"dispatching",
|
|
126
|
+
"working",
|
|
127
|
+
"decision",
|
|
128
|
+
"review",
|
|
129
|
+
"accepted",
|
|
130
|
+
"cancelled",
|
|
131
|
+
"failed",
|
|
132
|
+
"interrupted"
|
|
133
|
+
].includes(String(task.state)), "INVALID_STATE", "Unknown task state.");
|
|
134
|
+
requireFusion([
|
|
135
|
+
"pending",
|
|
136
|
+
"accepted",
|
|
137
|
+
"uncertain"
|
|
138
|
+
].includes(String(task.delivery)), "INVALID_STATE", "Unknown delivery state.");
|
|
139
|
+
text(task.dispatchId, "dispatch id", 200);
|
|
140
|
+
stringList(task.messageIds, "message ids", 64);
|
|
141
|
+
const reportIds = stringList(task.reportIds, "report ids", 64);
|
|
142
|
+
if (task.notifiedReportId !== void 0) requireFusion(reportIds.includes(text(task.notifiedReportId, "notified report id", 256)), "INVALID_STATE", "Notification references an unknown report.");
|
|
143
|
+
requireFusion(Array.isArray(task.candidates) && task.candidates.length <= 16 && Array.isArray(task.reviews) && task.reviews.length <= 32, "INVALID_STATE", "Invalid candidate history.");
|
|
144
|
+
const candidates = /* @__PURE__ */ new Map();
|
|
145
|
+
for (const [index, item] of task.candidates.entries()) {
|
|
146
|
+
const candidate = object(item);
|
|
147
|
+
timestamp(candidate.createdAt);
|
|
148
|
+
const candidateId = text(candidate.id, "candidate id", 200);
|
|
149
|
+
requireFusion(!candidates.has(candidateId), "INVALID_STATE", "Duplicate candidate identity.");
|
|
150
|
+
requireFusion(integer(candidate.revision, "candidate revision") === index + 1, "INVALID_STATE", "Invalid candidate order.");
|
|
151
|
+
requireFusion(integer(candidate.taskRevision, "candidate task revision") <= Number(task.revision), "INVALID_STATE", "Candidate belongs to a future task revision.");
|
|
152
|
+
const candidateText = text(candidate.text, "candidate", 2e5, true);
|
|
153
|
+
text(candidate.report, "report", 16e3, true);
|
|
154
|
+
requireFusion(typeof candidate.hash === "string" && /^[a-f0-9]{64}$/.test(candidate.hash), "INVALID_STATE", "Invalid candidate hash.");
|
|
155
|
+
requireFusion(createHash("sha256").update(candidateText, "utf8").digest("hex") === candidate.hash, "INVALID_STATE", "Candidate content does not match its stored hash.");
|
|
156
|
+
candidates.set(candidateId, candidate.hash);
|
|
157
|
+
}
|
|
158
|
+
if (task.cleanup !== void 0) requireFusion([
|
|
159
|
+
"pending",
|
|
160
|
+
"done",
|
|
161
|
+
"failed"
|
|
162
|
+
].includes(String(task.cleanup)), "INVALID_STATE", "Invalid cleanup state.");
|
|
163
|
+
if (task.adoption !== void 0) requireFusion([
|
|
164
|
+
"pending",
|
|
165
|
+
"applied",
|
|
166
|
+
"dismissed",
|
|
167
|
+
"conflict"
|
|
168
|
+
].includes(String(task.adoption)) && pair.profile === "writing" && task.target, "INVALID_STATE", "Invalid adoption state.");
|
|
169
|
+
if (task.application !== void 0) {
|
|
170
|
+
const application = object(task.application);
|
|
171
|
+
text(application.id, "application id", 200);
|
|
172
|
+
text(application.path, "application path", 8192);
|
|
173
|
+
text(application.beforeVersion, "application baseline", 8192, true);
|
|
174
|
+
const destination = target(task.target);
|
|
175
|
+
requireFusion(destination && (destination.data.path === void 0 || destination.data.path === application.path), "INVALID_STATE", "Application path differs from its captured target.");
|
|
176
|
+
const candidateId = text(application.candidateId, "application candidate", 200);
|
|
177
|
+
const latest = object(task.candidates.at(-1));
|
|
178
|
+
requireFusion(latest.id === candidateId && latest.taskRevision === task.revision && candidates.get(candidateId) === application.candidateHash, "INVALID_STATE", "Application references an unknown candidate.");
|
|
179
|
+
requireFusion(typeof application.afterHash === "string" && /^[a-f0-9]{64}$/.test(application.afterHash), "INVALID_STATE", "Invalid resulting file hash.");
|
|
180
|
+
requireFusion([
|
|
181
|
+
"pending",
|
|
182
|
+
"applied",
|
|
183
|
+
"conflict"
|
|
184
|
+
].includes(String(application.state)) && pair.profile === "writing" && task.target && task.state === "accepted", "INVALID_STATE", "Invalid application state.");
|
|
185
|
+
if (application.version !== void 0) text(application.version, "application receipt version", 8192);
|
|
186
|
+
if (application.state === "pending") requireFusion(task.adoption === "pending", "INVALID_STATE", "Pending intent requires pending adoption.");
|
|
187
|
+
if (application.state === "conflict") requireFusion(task.adoption === "conflict" || task.adoption === "dismissed", "INVALID_STATE", "Conflicting intent requires conflict or dismissal.");
|
|
188
|
+
if (application.state === "applied") requireFusion(task.adoption === "applied" && application.version, "INVALID_STATE", "Applied intent requires a receipt.");
|
|
189
|
+
}
|
|
190
|
+
for (const item of task.reviews) {
|
|
191
|
+
const review = object(item);
|
|
192
|
+
timestamp(review.createdAt);
|
|
193
|
+
const candidateId = text(review.candidateId, "review candidate", 200);
|
|
194
|
+
const hash = text(review.candidateHash, "review hash", 64);
|
|
195
|
+
requireFusion(candidates.get(candidateId) === hash, "INVALID_STATE", "Review references an unknown candidate revision.");
|
|
196
|
+
requireFusion([
|
|
197
|
+
"accept",
|
|
198
|
+
"revise",
|
|
199
|
+
"reject"
|
|
200
|
+
].includes(String(review.verdict)), "INVALID_STATE", "Invalid review verdict.");
|
|
201
|
+
text(review.feedback, "feedback", 16e3, true);
|
|
202
|
+
}
|
|
203
|
+
if (task.state === "accepted") {
|
|
204
|
+
const candidate = task.candidates.at(-1) && object(task.candidates.at(-1));
|
|
205
|
+
const review = task.reviews.at(-1) && object(task.reviews.at(-1));
|
|
206
|
+
requireFusion(candidate && candidate.taskRevision === task.revision && review?.verdict === "accept" && review.candidateId === candidate.id && review.candidateHash === candidate.hash, "INVALID_STATE", "Accepted task must reference the latest accepted candidate.");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
requireFusion([...children].every((id) => !leads.has(id)), "INVALID_STATE", "Recursive Fusion identity.");
|
|
211
|
+
requireFusion(JSON.stringify(row).length <= 16e6, "CAPACITY", "Fusion history capacity reached. Existing records were preserved.");
|
|
212
|
+
return structuredClone(row);
|
|
213
|
+
}
|
|
214
|
+
//#endregion
|
|
215
|
+
//#region src/storage.ts
|
|
216
|
+
/** One atomic business record; Sessions remain the transcript authority. */
|
|
217
|
+
const fusionStateSchema = z.unknown().transform((value, ctx) => {
|
|
218
|
+
try {
|
|
219
|
+
return validateState(value);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
ctx.addIssue({
|
|
222
|
+
code: "custom",
|
|
223
|
+
message: error instanceof Error ? error.message : "Invalid Fusion state"
|
|
224
|
+
});
|
|
225
|
+
return z.NEVER;
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
const fusionDomain = defineDomain({
|
|
229
|
+
name: "dsh_fusion",
|
|
230
|
+
version: 1,
|
|
231
|
+
tables: { state: domainTable(fusionStateSchema) }
|
|
232
|
+
});
|
|
233
|
+
const FUSION_STATE_KEY = "state";
|
|
234
|
+
function createFusionStore(table) {
|
|
235
|
+
return {
|
|
236
|
+
load: () => validateState(table.get("state") ?? emptyFusionState()),
|
|
237
|
+
save: (next) => table.put(FUSION_STATE_KEY, validateState(next))
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
//#endregion
|
|
241
|
+
//#region src/presentation.ts
|
|
242
|
+
/** Only a structured and matching source can suppress a cancelled child's late wakeup. */
|
|
243
|
+
function isOwnedChildNotice(source, childId) {
|
|
244
|
+
if (!source || typeof source !== "object") return false;
|
|
245
|
+
const row = source;
|
|
246
|
+
return (row.kind === "subagent-settled" || row.kind === "agent-message") && row.senderSessionId === childId;
|
|
247
|
+
}
|
|
248
|
+
//#endregion
|
|
249
|
+
//#region src/service.ts
|
|
250
|
+
const contentHash = (value) => createHash("sha256").update(value, "utf8").digest("hex");
|
|
251
|
+
const clone = (value) => structuredClone(value);
|
|
252
|
+
const currentTask = (pair) => pair.tasks.at(-1);
|
|
253
|
+
/** Business records only. The native continuation manager owns Agents and all message scheduling. */
|
|
254
|
+
var FusionService = class {
|
|
255
|
+
state;
|
|
256
|
+
pending = Promise.resolve();
|
|
257
|
+
enabled = true;
|
|
258
|
+
generation = 0;
|
|
259
|
+
storageFailed = false;
|
|
260
|
+
controller = new AbortController();
|
|
261
|
+
dispatches = /* @__PURE__ */ new Map();
|
|
262
|
+
applications = /* @__PURE__ */ new Map();
|
|
263
|
+
notifications = /* @__PURE__ */ new Map();
|
|
264
|
+
inFlight = /* @__PURE__ */ new Set();
|
|
265
|
+
ready;
|
|
266
|
+
store;
|
|
267
|
+
inspectAdmission;
|
|
268
|
+
native;
|
|
269
|
+
now;
|
|
270
|
+
id;
|
|
271
|
+
constructor(input) {
|
|
272
|
+
this.inspectAdmission = input.inspectAdmission;
|
|
273
|
+
this.store = input.store;
|
|
274
|
+
this.native = input.native;
|
|
275
|
+
this.now = input.now ?? Date.now;
|
|
276
|
+
this.id = input.id ?? randomUUID;
|
|
277
|
+
this.state = validateState(this.store.load() ?? emptyFusionState());
|
|
278
|
+
this.ready = this.change((next) => {
|
|
279
|
+
for (const pair of next.pairs) for (const task of pair.tasks) if (isWorking(task.state)) {
|
|
280
|
+
task.state = "interrupted";
|
|
281
|
+
task.delivery = "uncertain";
|
|
282
|
+
task.updatedAt = this.now();
|
|
283
|
+
task.error = "进程已重启。请核对执行记录后明确恢复;未自动重发任务。";
|
|
284
|
+
}
|
|
285
|
+
}, true);
|
|
286
|
+
}
|
|
287
|
+
get active() {
|
|
288
|
+
return this.enabled && !this.storageFailed;
|
|
289
|
+
}
|
|
290
|
+
async initialized() {
|
|
291
|
+
await this.ready;
|
|
292
|
+
}
|
|
293
|
+
snapshot() {
|
|
294
|
+
return clone(this.state);
|
|
295
|
+
}
|
|
296
|
+
pairFor(sessionId) {
|
|
297
|
+
const pair = this.state.pairs.find((pair) => pair.leadSessionId === sessionId || pair.childSessionId === sessionId);
|
|
298
|
+
return pair && clone(pair);
|
|
299
|
+
}
|
|
300
|
+
role(sessionId) {
|
|
301
|
+
const pair = this.state.pairs.find((pair) => pair.leadSessionId === sessionId || pair.childSessionId === sessionId);
|
|
302
|
+
return pair && (pair.leadSessionId === sessionId ? "lead" : "sidekick");
|
|
303
|
+
}
|
|
304
|
+
async persist(next) {
|
|
305
|
+
next.revision++;
|
|
306
|
+
validateState(next);
|
|
307
|
+
try {
|
|
308
|
+
await this.store.save(clone(next));
|
|
309
|
+
} catch (error) {
|
|
310
|
+
this.storageFailed = true;
|
|
311
|
+
this.generation++;
|
|
312
|
+
this.controller.abort();
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
this.state = clone(next);
|
|
316
|
+
}
|
|
317
|
+
change(fn, allowInactive = false) {
|
|
318
|
+
const result = this.pending.then(async () => {
|
|
319
|
+
requireFusion(allowInactive || this.enabled, "DISABLED", "Fusion is disabled.");
|
|
320
|
+
requireFusion(allowInactive || !this.storageFailed, "STORAGE_FAILED", "Fusion storage failed. Reload after repairing storage; no new work was admitted.");
|
|
321
|
+
const next = clone(this.state), value = await fn(next);
|
|
322
|
+
await this.persist(next);
|
|
323
|
+
return clone(value);
|
|
324
|
+
});
|
|
325
|
+
this.pending = result.then(() => void 0, () => void 0);
|
|
326
|
+
return result;
|
|
327
|
+
}
|
|
328
|
+
owned(next, actor, side) {
|
|
329
|
+
const pair = next.pairs.find((pair) => (side === "lead" ? pair.leadSessionId : pair.childSessionId) === actor.sessionId);
|
|
330
|
+
requireFusion(pair && pair.project === actor.project && (side !== "sidekick" || actor.parentSessionId === pair.leadSessionId), "UNAUTHORIZED", "This session does not own the Fusion task.");
|
|
331
|
+
if (side === "lead") requireFusion(!actor.parentSessionId, "UNAUTHORIZED", "A child cannot become a Fusion Lead.");
|
|
332
|
+
return pair;
|
|
333
|
+
}
|
|
334
|
+
task(pair, taskId, revision) {
|
|
335
|
+
const task = currentTask(pair);
|
|
336
|
+
requireFusion(task?.id === taskId && task.revision === revision, "STALE", "This task revision is no longer current.");
|
|
337
|
+
return task;
|
|
338
|
+
}
|
|
339
|
+
isCurrent(pairId, taskId, revision, generation) {
|
|
340
|
+
if (!this.enabled || this.storageFailed || generation !== this.generation) return false;
|
|
341
|
+
const pair = this.state.pairs.find((pair) => pair.id === pairId), task = pair && currentTask(pair);
|
|
342
|
+
return task?.id === taskId && task.revision === revision && isWorking(task.state);
|
|
343
|
+
}
|
|
344
|
+
track(operation) {
|
|
345
|
+
this.inFlight.add(operation);
|
|
346
|
+
operation.finally(() => this.inFlight.delete(operation)).catch(() => {});
|
|
347
|
+
return operation;
|
|
348
|
+
}
|
|
349
|
+
abortNotifications(taskId) {
|
|
350
|
+
for (const controller of this.notifications.get(taskId) ?? []) controller.abort();
|
|
351
|
+
}
|
|
352
|
+
async delegate(actor, input) {
|
|
353
|
+
await this.ready;
|
|
354
|
+
input.signal.throwIfAborted();
|
|
355
|
+
requireFusion(!actor.parentSessionId && !this.roleIsChild(actor.sessionId), "UNAUTHORIZED", "Fusion only delegates from a user root session.");
|
|
356
|
+
text(actor.sessionId, "session id", 200);
|
|
357
|
+
text(actor.project, "project identity", 8192);
|
|
358
|
+
const brief$1 = brief(input.brief), route$1 = route(input.route), target$1 = target(input.target);
|
|
359
|
+
requireFusion(input.profile === "generic" || input.profile === "writing", "INVALID_INPUT", "Unknown Fusion profile.");
|
|
360
|
+
const reserved = await this.change(async (next) => {
|
|
361
|
+
input.signal.throwIfAborted();
|
|
362
|
+
let pair = next.pairs.find((pair) => pair.leadSessionId === actor.sessionId);
|
|
363
|
+
if (pair) {
|
|
364
|
+
requireFusion(pair.project === actor.project && pair.profile === input.profile, "CONTEXT_CHANGED", "Project or profile changed. Start a new root conversation.");
|
|
365
|
+
requireFusion(!currentTask(pair) || !isWorking(currentTask(pair).state), "BUSY", "The previous Fusion task needs review, cancellation or a decision first.");
|
|
366
|
+
requireFusion(!currentTask(pair) || !this.dispatches.has(currentTask(pair).id), "ADMISSION_PENDING", "The previous native admission is still settling.");
|
|
367
|
+
requireFusion(!["pending", "conflict"].includes(currentTask(pair)?.adoption ?? "") && currentTask(pair)?.application?.state !== "pending", "ADOPTION_PENDING", "Adopt or dismiss the previous candidate before delegating again.");
|
|
368
|
+
requireFusion(currentTask(pair)?.cleanup !== "pending" && currentTask(pair)?.cleanup !== "failed", "STOP_INCOMPLETE", "Previous child cleanup must finish before delegation.");
|
|
369
|
+
requireFusion(JSON.stringify(pair.route) === JSON.stringify(route$1), "ROUTE_CHANGED", "The persistent partner uses another route. Start a new conversation to change it.");
|
|
370
|
+
if (!pair.established && pair.tasks.length && this.inspectAdmission) {
|
|
371
|
+
await this.native.stop(clone(pair), false);
|
|
372
|
+
pair.established = await this.inspectAdmission(clone(pair), input.signal) === "present";
|
|
373
|
+
input.signal.throwIfAborted();
|
|
374
|
+
}
|
|
375
|
+
requireFusion(pair.established || pair.tasks.length === 0 || this.inspectAdmission, "UNCERTAIN_ADMISSION", "Initial child admission is uncertain. Start a new conversation after inspecting the old child.");
|
|
376
|
+
} else {
|
|
377
|
+
pair = {
|
|
378
|
+
id: this.id(),
|
|
379
|
+
leadSessionId: actor.sessionId,
|
|
380
|
+
childSessionId: this.id(),
|
|
381
|
+
project: actor.project,
|
|
382
|
+
profile: input.profile,
|
|
383
|
+
route: route$1,
|
|
384
|
+
established: false,
|
|
385
|
+
tasks: [],
|
|
386
|
+
createdAt: this.now()
|
|
387
|
+
};
|
|
388
|
+
next.pairs.push(pair);
|
|
389
|
+
}
|
|
390
|
+
const task = {
|
|
391
|
+
id: this.id(),
|
|
392
|
+
revision: 1,
|
|
393
|
+
state: "dispatching",
|
|
394
|
+
brief: brief$1,
|
|
395
|
+
...target$1 ? { target: target$1 } : {},
|
|
396
|
+
candidates: [],
|
|
397
|
+
reviews: [],
|
|
398
|
+
messageIds: [],
|
|
399
|
+
dispatchId: this.id(),
|
|
400
|
+
reportIds: [],
|
|
401
|
+
delivery: "pending",
|
|
402
|
+
createdAt: this.now(),
|
|
403
|
+
updatedAt: this.now()
|
|
404
|
+
};
|
|
405
|
+
pair.tasks.push(task);
|
|
406
|
+
return {
|
|
407
|
+
pair,
|
|
408
|
+
task
|
|
409
|
+
};
|
|
410
|
+
});
|
|
411
|
+
return this.dispatch(reserved.pair, reserved.task, input.signal);
|
|
412
|
+
}
|
|
413
|
+
roleIsChild(id) {
|
|
414
|
+
return this.state.pairs.some((pair) => pair.childSessionId === id);
|
|
415
|
+
}
|
|
416
|
+
dispatch(pair, task, outerSignal) {
|
|
417
|
+
const controller = new AbortController(), generation = this.generation;
|
|
418
|
+
const controllers = this.dispatches.get(task.id) ?? /* @__PURE__ */ new Set();
|
|
419
|
+
controllers.add(controller);
|
|
420
|
+
this.dispatches.set(task.id, controllers);
|
|
421
|
+
const signal = AbortSignal.any([
|
|
422
|
+
outerSignal,
|
|
423
|
+
controller.signal,
|
|
424
|
+
this.controller.signal
|
|
425
|
+
]);
|
|
426
|
+
return this.track((async () => {
|
|
427
|
+
try {
|
|
428
|
+
signal.throwIfAborted();
|
|
429
|
+
const message = await this.native.dispatch({
|
|
430
|
+
pair: clone(pair),
|
|
431
|
+
task: clone(task),
|
|
432
|
+
prompt: this.prompt(pair, task),
|
|
433
|
+
signal
|
|
434
|
+
});
|
|
435
|
+
const admitted = await this.change((next) => {
|
|
436
|
+
const current = next.pairs.find((row) => row.id === pair.id);
|
|
437
|
+
current.established = true;
|
|
438
|
+
const row = current.tasks.find((row) => row.id === task.id);
|
|
439
|
+
const messageId = text(message.messageId, "message id", 200);
|
|
440
|
+
if (!row.messageIds.includes(messageId)) row.messageIds.push(messageId);
|
|
441
|
+
if (row.revision === task.revision) {
|
|
442
|
+
row.delivery = "accepted";
|
|
443
|
+
if (this.enabled && !this.storageFailed && generation === this.generation && row.state === "dispatching") row.state = "working";
|
|
444
|
+
row.updatedAt = this.now();
|
|
445
|
+
}
|
|
446
|
+
return {
|
|
447
|
+
task: row,
|
|
448
|
+
valid: this.enabled && !this.storageFailed && generation === this.generation && ![
|
|
449
|
+
"cancelled",
|
|
450
|
+
"failed",
|
|
451
|
+
"interrupted"
|
|
452
|
+
].includes(row.state)
|
|
453
|
+
};
|
|
454
|
+
}, true);
|
|
455
|
+
requireFusion(admitted.valid, "STALE", "Task was invalidated while its message was being admitted.");
|
|
456
|
+
return admitted.task;
|
|
457
|
+
} catch (error) {
|
|
458
|
+
await this.change((next) => {
|
|
459
|
+
const row = next.pairs.find((row) => row.id === pair.id)?.tasks.find((row) => row.id === task.id);
|
|
460
|
+
if (row && row.revision === task.revision && isWorking(row.state)) {
|
|
461
|
+
row.state = signal.aborted ? "cancelled" : "failed";
|
|
462
|
+
row.delivery = "uncertain";
|
|
463
|
+
row.updatedAt = this.now();
|
|
464
|
+
row.error = signal.aborted ? "任务已取消,未自动重试。" : "委派未确认完成,请查看执行记录后再处理。";
|
|
465
|
+
}
|
|
466
|
+
}, true);
|
|
467
|
+
const latest = this.pairFor(pair.leadSessionId)?.tasks.at(-1);
|
|
468
|
+
if (!this.enabled || latest?.id === task.id && latest.revision === task.revision && [
|
|
469
|
+
"cancelled",
|
|
470
|
+
"failed",
|
|
471
|
+
"interrupted"
|
|
472
|
+
].includes(latest.state)) try {
|
|
473
|
+
await this.native.stop(pair, false);
|
|
474
|
+
} catch {}
|
|
475
|
+
throw error;
|
|
476
|
+
} finally {
|
|
477
|
+
controllers.delete(controller);
|
|
478
|
+
if (!controllers.size) this.dispatches.delete(task.id);
|
|
479
|
+
}
|
|
480
|
+
})());
|
|
481
|
+
}
|
|
482
|
+
prompt(pair, task) {
|
|
483
|
+
return `Fusion task ${task.id}; revision ${task.revision}; dispatch ${task.dispatchId}.\nGoal: ${task.brief.goal}\nContext:\n${task.brief.context}\nConstraints:\n${task.brief.constraints.join("\n")}\nAcceptance:\n${task.brief.acceptance.join("\n")}\n` + (task.target ? `Versioned destination (do not change): ${JSON.stringify(task.target)}\n` : "") + (task.decision ? `Lead feedback: ${task.decision}\n` : "") + `Use fusion_report with this taskId and taskRevision, and a unique reportId. Submit ${pair.profile === "writing" ? "the exact prose candidate" : "a concise result with verifiable evidence"}, or request a decision. Do not claim the task is complete without reporting. Stop after reporting and wait for the Lead.`;
|
|
484
|
+
}
|
|
485
|
+
async report(actor, input) {
|
|
486
|
+
await this.ready;
|
|
487
|
+
input.signal.throwIfAborted();
|
|
488
|
+
integer(input.taskRevision, "task revision");
|
|
489
|
+
text(input.reportId, "report id", 200);
|
|
490
|
+
text(input.text, "report text", input.kind === "candidate" ? 2e5 : 16e3, input.kind === "candidate");
|
|
491
|
+
text(input.report ?? "", "report summary", 16e3, true);
|
|
492
|
+
requireFusion(input.kind === "candidate" || input.kind === "decision", "INVALID_INPUT", "Unknown report kind.");
|
|
493
|
+
const generation = this.generation;
|
|
494
|
+
const result = await this.change((next) => {
|
|
495
|
+
input.signal.throwIfAborted();
|
|
496
|
+
const pair = this.owned(next, actor, "sidekick"), task = this.task(pair, input.taskId, input.taskRevision);
|
|
497
|
+
pair.established = true;
|
|
498
|
+
const reportKey = `${task.revision}:${input.reportId}`;
|
|
499
|
+
if (task.reportIds.includes(reportKey)) {
|
|
500
|
+
const candidate = task.candidates.at(-1);
|
|
501
|
+
requireFusion(input.kind === "decision" ? task.decision === input.text : candidate?.text === input.text && candidate.report === (input.report ?? ""), "DUPLICATE_CONFLICT", "A report id cannot be reused for different content.");
|
|
502
|
+
return {
|
|
503
|
+
pair,
|
|
504
|
+
task,
|
|
505
|
+
reportKey,
|
|
506
|
+
duplicate: true
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
requireFusion(task.state === "dispatching" || task.state === "working", "STALE", "The task is no longer accepting reports.");
|
|
510
|
+
task.reportIds.push(reportKey);
|
|
511
|
+
if (input.kind === "decision") {
|
|
512
|
+
task.state = "decision";
|
|
513
|
+
task.decision = input.text;
|
|
514
|
+
} else {
|
|
515
|
+
task.candidates.push({
|
|
516
|
+
id: this.id(),
|
|
517
|
+
taskRevision: task.revision,
|
|
518
|
+
revision: task.candidates.length + 1,
|
|
519
|
+
text: input.text,
|
|
520
|
+
hash: contentHash(input.text),
|
|
521
|
+
report: input.report ?? "",
|
|
522
|
+
createdAt: this.now()
|
|
523
|
+
});
|
|
524
|
+
task.state = "review";
|
|
525
|
+
}
|
|
526
|
+
task.updatedAt = this.now();
|
|
527
|
+
return {
|
|
528
|
+
pair,
|
|
529
|
+
task,
|
|
530
|
+
reportKey,
|
|
531
|
+
duplicate: false
|
|
532
|
+
};
|
|
533
|
+
});
|
|
534
|
+
if (result.duplicate) {
|
|
535
|
+
requireFusion(result.task.notifiedReportId === result.reportKey, "NOTIFICATION_UNCERTAIN", "The report was saved, but Lead notification is not confirmed. Inspect the exact task record before any explicit recovery.");
|
|
536
|
+
return result.task;
|
|
537
|
+
}
|
|
538
|
+
const controller = new AbortController();
|
|
539
|
+
const controllers = this.notifications.get(result.task.id) ?? /* @__PURE__ */ new Set();
|
|
540
|
+
controllers.add(controller);
|
|
541
|
+
this.notifications.set(result.task.id, controllers);
|
|
542
|
+
try {
|
|
543
|
+
if (this.isCurrent(result.pair.id, result.task.id, result.task.revision, generation)) {
|
|
544
|
+
const candidate = result.task.candidates.at(-1);
|
|
545
|
+
const body = result.task.state === "decision" ? `Decision requested for ${result.task.id} revision ${result.task.revision}: ${result.task.decision}` : `Candidate ready for ${result.task.id} revision ${result.task.revision}: ${candidate.id}, sha256 ${candidate.hash}. Read the exact candidate using fusion_read before fusion_review. Report: ${candidate.report}`;
|
|
546
|
+
const signal = AbortSignal.any([controller.signal, this.controller.signal]);
|
|
547
|
+
signal.throwIfAborted();
|
|
548
|
+
await this.track(this.native.notify({
|
|
549
|
+
pair: result.pair,
|
|
550
|
+
task: result.task,
|
|
551
|
+
actor,
|
|
552
|
+
text: body,
|
|
553
|
+
signal
|
|
554
|
+
}));
|
|
555
|
+
await this.change((next) => {
|
|
556
|
+
const row = next.pairs.find((pair) => pair.id === result.pair.id)?.tasks.find((task) => task.id === result.task.id);
|
|
557
|
+
if (row?.revision === result.task.revision && isWorking(row.state)) row.notifiedReportId = result.reportKey;
|
|
558
|
+
}, true);
|
|
559
|
+
}
|
|
560
|
+
} finally {
|
|
561
|
+
controllers.delete(controller);
|
|
562
|
+
if (controllers.size === 0) this.notifications.delete(result.task.id);
|
|
563
|
+
}
|
|
564
|
+
return clone(this.state.pairs.find((pair) => pair.id === result.pair.id)?.tasks.find((task) => task.id === result.task.id) ?? result.task);
|
|
565
|
+
}
|
|
566
|
+
read(actor, taskId, candidateId) {
|
|
567
|
+
const task = this.owned(this.state, actor, "lead").tasks.find((row) => row.id === taskId);
|
|
568
|
+
requireFusion(task, "NOT_FOUND", "Unknown Fusion task.");
|
|
569
|
+
const candidate = candidateId ? task.candidates.find((row) => row.id === candidateId) : task.candidates.at(-1);
|
|
570
|
+
requireFusion(!candidateId || candidate, "NOT_FOUND", "Unknown candidate.");
|
|
571
|
+
return clone({
|
|
572
|
+
task,
|
|
573
|
+
...candidate ? { candidate } : {}
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
async review(actor, input) {
|
|
577
|
+
await this.ready;
|
|
578
|
+
input.signal.throwIfAborted();
|
|
579
|
+
text(input.feedback, "feedback", 16e3, true);
|
|
580
|
+
requireFusion([
|
|
581
|
+
"accept",
|
|
582
|
+
"revise",
|
|
583
|
+
"reject"
|
|
584
|
+
].includes(input.verdict), "INVALID_INPUT", "Unknown review verdict.");
|
|
585
|
+
const result = await this.change((next) => {
|
|
586
|
+
input.signal.throwIfAborted();
|
|
587
|
+
const pair = this.owned(next, actor, "lead"), task = this.task(pair, input.taskId, integer(input.taskRevision, "task revision"));
|
|
588
|
+
const candidate = task.candidates.at(-1);
|
|
589
|
+
requireFusion(candidate?.id === input.candidateId && candidate.hash === input.hash && candidate.taskRevision === task.revision, "STALE", "Review must reference the current exact candidate.");
|
|
590
|
+
requireFusion(task.state === "review", "STALE", "The task is not awaiting review.");
|
|
591
|
+
requireFusion(contentHash(candidate.text) === candidate.hash, "INVALID_STATE", "Candidate content does not match its stored hash.");
|
|
592
|
+
task.reviews.push({
|
|
593
|
+
candidateId: candidate.id,
|
|
594
|
+
candidateHash: candidate.hash,
|
|
595
|
+
verdict: input.verdict,
|
|
596
|
+
feedback: input.feedback,
|
|
597
|
+
createdAt: this.now()
|
|
598
|
+
});
|
|
599
|
+
task.updatedAt = this.now();
|
|
600
|
+
if (input.verdict === "accept") {
|
|
601
|
+
task.state = "accepted";
|
|
602
|
+
if (pair.profile === "writing" && task.target) task.adoption = "pending";
|
|
603
|
+
} else if (input.verdict === "reject") task.state = "cancelled";
|
|
604
|
+
else {
|
|
605
|
+
requireFusion(task.candidates.length < 16, "RETRY_LIMIT", "Revision limit reached. Keep the candidate and ask the author for direction.");
|
|
606
|
+
requireFusion(input.feedback.trim(), "INVALID_INPUT", "Revision requires actionable feedback.");
|
|
607
|
+
task.revision++;
|
|
608
|
+
task.state = "dispatching";
|
|
609
|
+
task.dispatchId = this.id();
|
|
610
|
+
task.delivery = "pending";
|
|
611
|
+
task.decision = input.feedback;
|
|
612
|
+
delete task.notifiedReportId;
|
|
613
|
+
}
|
|
614
|
+
return {
|
|
615
|
+
pair,
|
|
616
|
+
task
|
|
617
|
+
};
|
|
618
|
+
});
|
|
619
|
+
this.abortNotifications(result.task.id);
|
|
620
|
+
if (input.verdict === "revise") return this.dispatch(result.pair, result.task, input.signal);
|
|
621
|
+
return result.task;
|
|
622
|
+
}
|
|
623
|
+
async decide(actor, input) {
|
|
624
|
+
await this.ready;
|
|
625
|
+
input.signal.throwIfAborted();
|
|
626
|
+
text(input.feedback, "decision", 16e3);
|
|
627
|
+
const result = await this.change(async (next) => {
|
|
628
|
+
input.signal.throwIfAborted();
|
|
629
|
+
const pair = this.owned(next, actor, "lead"), task = this.task(pair, input.taskId, integer(input.taskRevision, "task revision"));
|
|
630
|
+
requireFusion([
|
|
631
|
+
"decision",
|
|
632
|
+
"interrupted",
|
|
633
|
+
"failed"
|
|
634
|
+
].includes(task.state), "STALE", "Only a blocked or explicitly interrupted task can resume.");
|
|
635
|
+
requireFusion(task.delivery !== "pending", "UNCERTAIN_ADMISSION", "Initial admission is still pending.");
|
|
636
|
+
if (this.inspectAdmission) {
|
|
637
|
+
await this.native.stop(clone(pair), false);
|
|
638
|
+
pair.established = await this.inspectAdmission(clone(pair), input.signal) === "present";
|
|
639
|
+
input.signal.throwIfAborted();
|
|
640
|
+
task.cleanup = "done";
|
|
641
|
+
} else requireFusion(pair.established, "UNCERTAIN_ADMISSION", "Inspect uncertain initial admission before resuming.");
|
|
642
|
+
requireFusion(task.revision < 32, "RETRY_LIMIT", "Task revision limit reached.");
|
|
643
|
+
task.revision++;
|
|
644
|
+
task.state = "dispatching";
|
|
645
|
+
task.delivery = "pending";
|
|
646
|
+
task.dispatchId = this.id();
|
|
647
|
+
task.decision = input.feedback;
|
|
648
|
+
delete task.error;
|
|
649
|
+
delete task.notifiedReportId;
|
|
650
|
+
task.updatedAt = this.now();
|
|
651
|
+
return {
|
|
652
|
+
pair,
|
|
653
|
+
task
|
|
654
|
+
};
|
|
655
|
+
});
|
|
656
|
+
this.abortNotifications(result.task.id);
|
|
657
|
+
return this.dispatch(result.pair, result.task, input.signal);
|
|
658
|
+
}
|
|
659
|
+
async cancel(actor, taskId, taskRevision, stopLead = false) {
|
|
660
|
+
await this.ready;
|
|
661
|
+
const present = this.task(this.owned(this.state, actor, "lead"), taskId, integer(taskRevision, "task revision"));
|
|
662
|
+
for (const controller of this.applications.get(present.id) ?? []) controller.abort();
|
|
663
|
+
const pair = await this.change((next) => {
|
|
664
|
+
const pair = this.owned(next, actor, "lead"), task = this.task(pair, taskId, integer(taskRevision, "task revision"));
|
|
665
|
+
requireFusion(task.application?.state !== "pending", "APPLICATION_UNCERTAIN", "Inspect the pending application before stopping this task.");
|
|
666
|
+
requireFusion(task.adoption !== "applied", "ALREADY_APPLIED", "Stopping does not undo an applied change.");
|
|
667
|
+
if (task.state === "accepted" && pair.profile === "writing" && task.target) task.adoption = "dismissed";
|
|
668
|
+
else task.state = "cancelled";
|
|
669
|
+
task.cleanup = "pending";
|
|
670
|
+
task.updatedAt = this.now();
|
|
671
|
+
return pair;
|
|
672
|
+
});
|
|
673
|
+
for (const controller of this.dispatches.get(taskId) ?? []) controller.abort();
|
|
674
|
+
this.abortNotifications(taskId);
|
|
675
|
+
try {
|
|
676
|
+
await this.track(this.native.stop(pair, stopLead));
|
|
677
|
+
await this.change((next) => {
|
|
678
|
+
this.task(this.owned(next, actor, "lead"), taskId, taskRevision).cleanup = "done";
|
|
679
|
+
}, true);
|
|
680
|
+
} catch (error) {
|
|
681
|
+
await this.change((next) => {
|
|
682
|
+
this.task(this.owned(next, actor, "lead"), taskId, taskRevision).cleanup = "failed";
|
|
683
|
+
}, true);
|
|
684
|
+
throw error;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
async executionInterrupted(actor, reason, expected) {
|
|
688
|
+
await this.ready;
|
|
689
|
+
await this.change((next) => {
|
|
690
|
+
const pair = this.owned(next, actor, "sidekick"), task = currentTask(pair);
|
|
691
|
+
if (task && (!expected || task.id === expected.taskId && task.revision === expected.revision) && ["dispatching", "working"].includes(task.state)) {
|
|
692
|
+
task.state = "interrupted";
|
|
693
|
+
task.error = text(reason, "interruption reason", 16e3);
|
|
694
|
+
task.delivery = "uncertain";
|
|
695
|
+
task.updatedAt = this.now();
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
/** Explicit recovery only: saved content is re-notified, never dispatched to the Writer. */
|
|
700
|
+
async recover(actor, taskId, taskRevision, outerSignal) {
|
|
701
|
+
await this.ready;
|
|
702
|
+
outerSignal.throwIfAborted();
|
|
703
|
+
const result = await this.change((next) => {
|
|
704
|
+
const pair = this.owned(next, actor, "lead"), task = this.task(pair, taskId, integer(taskRevision, "task revision"));
|
|
705
|
+
requireFusion([
|
|
706
|
+
"interrupted",
|
|
707
|
+
"review",
|
|
708
|
+
"decision"
|
|
709
|
+
].includes(task.state), "STALE", "This task has no recoverable report.");
|
|
710
|
+
const reportKey = task.reportIds.at(-1);
|
|
711
|
+
requireFusion(reportKey?.startsWith(`${task.revision}:`), "NO_REPORT", "There is no saved report for this revision. Resume with feedback instead.");
|
|
712
|
+
task.state = task.candidates.at(-1)?.taskRevision === task.revision ? "review" : "decision";
|
|
713
|
+
requireFusion(task.state === "review" || task.decision, "NO_REPORT", "No saved report is available.");
|
|
714
|
+
task.updatedAt = this.now();
|
|
715
|
+
return {
|
|
716
|
+
pair,
|
|
717
|
+
task,
|
|
718
|
+
reportKey
|
|
719
|
+
};
|
|
720
|
+
});
|
|
721
|
+
const controller = new AbortController(), controllers = this.notifications.get(taskId) ?? /* @__PURE__ */ new Set();
|
|
722
|
+
controllers.add(controller);
|
|
723
|
+
this.notifications.set(taskId, controllers);
|
|
724
|
+
const signal = AbortSignal.any([
|
|
725
|
+
outerSignal,
|
|
726
|
+
controller.signal,
|
|
727
|
+
this.controller.signal
|
|
728
|
+
]);
|
|
729
|
+
try {
|
|
730
|
+
signal.throwIfAborted();
|
|
731
|
+
const candidate = result.task.candidates.at(-1);
|
|
732
|
+
const body = result.task.state === "review" ? `Recovered saved candidate for ${taskId} revision ${taskRevision}: ${candidate.id}, sha256 ${candidate.hash}. Read with fusion_read, then fusion_review. This is a repeated notification, not a new Writer result.` : `Recovered saved decision for ${taskId} revision ${taskRevision}: ${result.task.decision}`;
|
|
733
|
+
await this.track(this.native.notify({
|
|
734
|
+
pair: result.pair,
|
|
735
|
+
task: result.task,
|
|
736
|
+
actor,
|
|
737
|
+
text: body,
|
|
738
|
+
signal
|
|
739
|
+
}));
|
|
740
|
+
await this.change((next) => {
|
|
741
|
+
const task = this.task(this.owned(next, actor, "lead"), taskId, taskRevision);
|
|
742
|
+
requireFusion(["review", "decision"].includes(task.state), "STALE", "Recovery was cancelled.");
|
|
743
|
+
task.notifiedReportId = result.reportKey;
|
|
744
|
+
});
|
|
745
|
+
return this.read(actor, taskId).task;
|
|
746
|
+
} finally {
|
|
747
|
+
controllers.delete(controller);
|
|
748
|
+
if (!controllers.size) this.notifications.delete(taskId);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
candidateAction(next, actor, input) {
|
|
752
|
+
requireFusion(input.sessionId === actor.sessionId, "UNAUTHORIZED", "Session identity mismatch.");
|
|
753
|
+
const pair = this.owned(next, actor, "lead"), task = this.task(pair, input.taskId, integer(input.taskRevision, "task revision"));
|
|
754
|
+
const candidate = task.candidates.at(-1);
|
|
755
|
+
requireFusion(candidate?.id === input.candidateId && candidate.hash === input.hash && candidate.taskRevision === task.revision, "STALE", "The exact current candidate is required.");
|
|
756
|
+
const review = task.reviews.at(-1);
|
|
757
|
+
requireFusion(review?.verdict === "accept" && review.candidateId === candidate.id && review.candidateHash === candidate.hash, "NOT_ACCEPTED", "The exact candidate has not been accepted by the Lead.");
|
|
758
|
+
requireFusion(pair.profile === "writing" && task.target && task.state === "accepted", "NOT_ACCEPTED", "The Lead must accept this writing candidate first.");
|
|
759
|
+
return {
|
|
760
|
+
pair,
|
|
761
|
+
task,
|
|
762
|
+
candidate,
|
|
763
|
+
target: task.target
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
/** Also used by status. An uncertain filesystem mutation is inspected, never replayed. */
|
|
767
|
+
async reconcile(actor, host, signal) {
|
|
768
|
+
await this.ready;
|
|
769
|
+
if (!this.pairFor(actor.sessionId)?.tasks.some((task) => task.application?.state === "pending")) return;
|
|
770
|
+
await this.change(async (next) => {
|
|
771
|
+
const pair = this.owned(next, actor, "lead");
|
|
772
|
+
for (const task of pair.tasks) {
|
|
773
|
+
const application = task.application;
|
|
774
|
+
if (application?.state !== "pending") continue;
|
|
775
|
+
const candidate = task.candidates.find((row) => row.id === application.candidateId);
|
|
776
|
+
requireFusion(task.target && candidate, "INVALID_STATE", "Incomplete application intent.");
|
|
777
|
+
await host.transact(actor, task.target, candidate, signal, async (access) => {
|
|
778
|
+
const current = await access.inspect();
|
|
779
|
+
if (current && contentHash(current.text) === application.afterHash) {
|
|
780
|
+
application.state = "applied";
|
|
781
|
+
application.version = text(current.version, "application receipt version", 8192);
|
|
782
|
+
task.adoption = "applied";
|
|
783
|
+
} else {
|
|
784
|
+
application.state = "conflict";
|
|
785
|
+
task.adoption = "conflict";
|
|
786
|
+
}
|
|
787
|
+
task.updatedAt = this.now();
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
async preview(actor, input, host, signal) {
|
|
793
|
+
await this.ready;
|
|
794
|
+
return this.change(async (next) => {
|
|
795
|
+
const { task, candidate, target } = this.candidateAction(next, actor, input);
|
|
796
|
+
requireFusion(!task.application || task.application.state === "conflict", "ALREADY_APPLIED", "This application has already started or completed.");
|
|
797
|
+
requireFusion(task.adoption !== "dismissed" && task.adoption !== "applied", "STALE", "This candidate is no longer awaiting adoption.");
|
|
798
|
+
return host.transact(actor, target, candidate, signal, async (access) => ({
|
|
799
|
+
...await access.prepare(),
|
|
800
|
+
candidateId: candidate.id,
|
|
801
|
+
hash: candidate.hash
|
|
802
|
+
}));
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
async applyCandidate(actor, input, host, outerSignal) {
|
|
806
|
+
await this.ready;
|
|
807
|
+
text(input.expectedVersion, "expected version", 8192, true);
|
|
808
|
+
const controller = new AbortController(), controllers = this.applications.get(input.taskId) ?? /* @__PURE__ */ new Set();
|
|
809
|
+
controllers.add(controller);
|
|
810
|
+
this.applications.set(input.taskId, controllers);
|
|
811
|
+
try {
|
|
812
|
+
return await this.change(async (next) => {
|
|
813
|
+
const { task, candidate, target } = this.candidateAction(next, actor, input);
|
|
814
|
+
requireFusion(task.adoption !== "dismissed", "STALE", "This candidate was dismissed.");
|
|
815
|
+
requireFusion(!task.application || task.application.state === "conflict", "ALREADY_APPLIED", "This application has already started or completed.");
|
|
816
|
+
const signal = AbortSignal.any([
|
|
817
|
+
outerSignal,
|
|
818
|
+
controller.signal,
|
|
819
|
+
this.controller.signal
|
|
820
|
+
]);
|
|
821
|
+
return host.transact(actor, target, candidate, signal, async (access) => {
|
|
822
|
+
const preview = await access.prepare();
|
|
823
|
+
requireFusion(preview.version === input.expectedVersion, "CONFLICT", "The preview version changed. Preview the current file again.");
|
|
824
|
+
signal.throwIfAborted();
|
|
825
|
+
task.application = {
|
|
826
|
+
id: this.id(),
|
|
827
|
+
candidateId: candidate.id,
|
|
828
|
+
candidateHash: candidate.hash,
|
|
829
|
+
path: preview.path,
|
|
830
|
+
beforeVersion: preview.version,
|
|
831
|
+
afterHash: contentHash(preview.after),
|
|
832
|
+
state: "pending"
|
|
833
|
+
};
|
|
834
|
+
task.adoption = "pending";
|
|
835
|
+
task.updatedAt = this.now();
|
|
836
|
+
requireFusion(JSON.stringify(next).length <= 15934464, "CAPACITY", "Fusion history has no room for the application receipt. The file was not changed.");
|
|
837
|
+
await this.persist(next);
|
|
838
|
+
signal.throwIfAborted();
|
|
839
|
+
try {
|
|
840
|
+
const receipt = await access.commit(preview.version);
|
|
841
|
+
requireFusion(receipt.path === preview.path, "INVALID_RECEIPT", "Host committed a different destination.");
|
|
842
|
+
task.application.state = "applied";
|
|
843
|
+
task.application.version = text(receipt.version, "application receipt version", 8192);
|
|
844
|
+
task.adoption = "applied";
|
|
845
|
+
task.updatedAt = this.now();
|
|
846
|
+
return task;
|
|
847
|
+
} catch (error) {
|
|
848
|
+
throw error;
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
});
|
|
852
|
+
} catch (error) {
|
|
853
|
+
if (this.active) await this.change((next) => {
|
|
854
|
+
const task = next.pairs.find((row) => row.leadSessionId === actor.sessionId && row.project === actor.project)?.tasks.at(-1), candidate = task?.candidates.at(-1);
|
|
855
|
+
if (task?.id === input.taskId && task.revision === input.taskRevision && candidate?.id === input.candidateId && candidate.hash === input.hash && task.state === "accepted" && task.adoption !== "applied" && task.adoption !== "dismissed") {
|
|
856
|
+
if (!task.application || task.application.state === "conflict") task.adoption = "conflict";
|
|
857
|
+
task.error = (error instanceof Error ? error.message : String(error)).slice(0, 16e3);
|
|
858
|
+
}
|
|
859
|
+
}).catch(() => {});
|
|
860
|
+
throw error;
|
|
861
|
+
} finally {
|
|
862
|
+
controllers.delete(controller);
|
|
863
|
+
if (!controllers.size) this.applications.delete(input.taskId);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
async dismiss(actor, input) {
|
|
867
|
+
await this.ready;
|
|
868
|
+
return this.change((next) => {
|
|
869
|
+
const { task } = this.candidateAction(next, actor, input);
|
|
870
|
+
requireFusion(task.application?.state !== "pending" && task.adoption !== "applied", "ALREADY_APPLIED", "Inspect the application before dismissing it.");
|
|
871
|
+
task.adoption = "dismissed";
|
|
872
|
+
task.updatedAt = this.now();
|
|
873
|
+
return task;
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
/** Domain Host only: never expose an RPC that lets a browser claim a file was applied. */
|
|
877
|
+
async adoption(leadSessionId, taskId, candidateId, state) {
|
|
878
|
+
await this.ready;
|
|
879
|
+
await this.change((next) => {
|
|
880
|
+
const task = next.pairs.find((row) => row.leadSessionId === leadSessionId)?.tasks.find((row) => row.id === taskId);
|
|
881
|
+
requireFusion(task && task.state === "accepted" && task.candidates.at(-1)?.id === candidateId, "STALE", "No accepted candidate matches the Host receipt.");
|
|
882
|
+
requireFusion(task.adoption !== "applied" || state === "applied", "ALREADY_APPLIED", "An applied receipt cannot be overwritten.");
|
|
883
|
+
task.adoption = state;
|
|
884
|
+
task.updatedAt = this.now();
|
|
885
|
+
}, true);
|
|
886
|
+
}
|
|
887
|
+
/** Stops owned work only. Invalidate synchronously, then await admissions and cleanup. */
|
|
888
|
+
async dispose() {
|
|
889
|
+
if (!this.enabled) return;
|
|
890
|
+
this.enabled = false;
|
|
891
|
+
this.generation++;
|
|
892
|
+
this.controller.abort();
|
|
893
|
+
for (const controllers of this.dispatches.values()) for (const controller of controllers) controller.abort();
|
|
894
|
+
for (const taskId of this.notifications.keys()) this.abortNotifications(taskId);
|
|
895
|
+
const errors = [];
|
|
896
|
+
try {
|
|
897
|
+
await this.ready;
|
|
898
|
+
await this.change((next) => {
|
|
899
|
+
for (const pair of next.pairs) for (const task of pair.tasks) if (isWorking(task.state)) {
|
|
900
|
+
task.state = "cancelled";
|
|
901
|
+
task.cleanup = "pending";
|
|
902
|
+
task.updatedAt = this.now();
|
|
903
|
+
}
|
|
904
|
+
}, true);
|
|
905
|
+
} catch (error) {
|
|
906
|
+
errors.push(error);
|
|
907
|
+
}
|
|
908
|
+
await Promise.allSettled([...this.inFlight]);
|
|
909
|
+
const results = await Promise.allSettled(this.state.pairs.map((pair) => this.native.stop(clone(pair), false)));
|
|
910
|
+
try {
|
|
911
|
+
await this.change((next) => {
|
|
912
|
+
next.pairs.forEach((pair, index) => {
|
|
913
|
+
for (const task of pair.tasks) if (task.cleanup === "pending") task.cleanup = results[index].status === "fulfilled" ? "done" : "failed";
|
|
914
|
+
});
|
|
915
|
+
}, true);
|
|
916
|
+
} catch (error) {
|
|
917
|
+
errors.push(error);
|
|
918
|
+
}
|
|
919
|
+
for (const result of results) if (result.status === "rejected") errors.push(result.reason);
|
|
920
|
+
if (errors.length) throw new AggregateError(errors, "Fusion teardown did not complete cleanly.");
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
//#endregion
|
|
924
|
+
//#region src/native.ts
|
|
925
|
+
const sid = (id) => id;
|
|
926
|
+
/** Identity is derived from the exact live Agent, never a model-supplied role or label. */
|
|
927
|
+
function nativeActor(bindings, agent) {
|
|
928
|
+
requireFusion(agent && bindings.agents.get(agent.id) === agent, "UNAUTHORIZED", "A current live Agent is required.");
|
|
929
|
+
const header = agent.session.header;
|
|
930
|
+
requireFusion(typeof header.cwd === "string" && header.cwd.length > 0, "NO_WORKSPACE", "Fusion requires a session workspace.");
|
|
931
|
+
return {
|
|
932
|
+
sessionId: String(agent.id),
|
|
933
|
+
project: header.cwd,
|
|
934
|
+
...header.parentSession ? { parentSessionId: String(header.parentSession) } : {}
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
function leadOf(bindings, pair) {
|
|
938
|
+
const lead = bindings.agents.get(sid(pair.leadSessionId));
|
|
939
|
+
const actor = nativeActor(bindings, lead);
|
|
940
|
+
requireFusion(actor.project === pair.project && !actor.parentSessionId, "UNAUTHORIZED", "The stored Fusion parent does not match this live workspace.");
|
|
941
|
+
return lead;
|
|
942
|
+
}
|
|
943
|
+
function ownedChild(bindings, pair) {
|
|
944
|
+
const child = bindings.agents.get(sid(pair.childSessionId));
|
|
945
|
+
if (!child) return void 0;
|
|
946
|
+
const actor = nativeActor(bindings, child);
|
|
947
|
+
requireFusion(actor.parentSessionId === pair.leadSessionId && actor.project === pair.project, "UNAUTHORIZED", "The live child does not belong to this Fusion pair.");
|
|
948
|
+
return child;
|
|
949
|
+
}
|
|
950
|
+
/** Adapter only: the pinned native continuation manager owns creation, inboxes and cold resume. */
|
|
951
|
+
function createNativeBridge(bindings, composition) {
|
|
952
|
+
return {
|
|
953
|
+
async dispatch({ pair, prompt, signal }) {
|
|
954
|
+
signal.throwIfAborted();
|
|
955
|
+
const lead = leadOf(bindings, pair);
|
|
956
|
+
if (pair.established) {
|
|
957
|
+
await composition.verifyContinuation?.(pair, signal);
|
|
958
|
+
signal.throwIfAborted();
|
|
959
|
+
const messageId = await bindings.subagents.sendMessage(lead, sid(pair.childSessionId), [{
|
|
960
|
+
type: "text",
|
|
961
|
+
text: prompt
|
|
962
|
+
}], { signal });
|
|
963
|
+
return { messageId: String(messageId) };
|
|
964
|
+
}
|
|
965
|
+
const provider = bindings.subagents.getProvider("spawn");
|
|
966
|
+
requireFusion(provider?.prepareContinuable && provider.capabilities.agentOptions && provider.capabilities.persona && provider.capabilities.toolFilter, "UNSUPPORTED_CAPABILITY", "Fusion requires the native spawn provider with continuable, model, persona and tool-filter support.");
|
|
967
|
+
requireFusion(!provider.inheritsParentContext, "CONTEXT_NOT_ISOLATED", "The Fusion provider must not copy the parent transcript.");
|
|
968
|
+
requireFusion(!bindings.agents.get(sid(pair.childSessionId)), "IDENTITY_CONFLICT", "The reserved child identity is already live.");
|
|
969
|
+
const tools = [...new Set(composition.tools(lead, pair))];
|
|
970
|
+
requireFusion(tools.includes("fusion_report"), "INVALID_COMPOSITION", "The child must have its report tool.");
|
|
971
|
+
requireFusion(!tools.includes("fusion_delegate") && !tools.includes("fusion_review"), "INVALID_COMPOSITION", "The child cannot own Lead controls.");
|
|
972
|
+
const toolFilter = pair.profile === "generic" && composition.scopedReport ? void 0 : { allow: composition.scopedReport ? tools.filter((name) => name !== "fusion_report") : tools };
|
|
973
|
+
const agentOptions = {
|
|
974
|
+
provider: pair.route.provider,
|
|
975
|
+
model: pair.route.model,
|
|
976
|
+
reasoningEffort: pair.route.reasoningEffort
|
|
977
|
+
};
|
|
978
|
+
const started = await bindings.subagents.startContinuable({
|
|
979
|
+
provider: "spawn",
|
|
980
|
+
label: pair.profile === "writing" ? "执笔" : "Fusion Sidekick",
|
|
981
|
+
childId: sid(pair.childSessionId),
|
|
982
|
+
request: {
|
|
983
|
+
parent: lead,
|
|
984
|
+
prompt: [{
|
|
985
|
+
type: "text",
|
|
986
|
+
text: prompt
|
|
987
|
+
}],
|
|
988
|
+
agentOptions,
|
|
989
|
+
persona: composition.persona(pair),
|
|
990
|
+
...toolFilter ? { toolFilter } : {},
|
|
991
|
+
maxDepth: 1
|
|
992
|
+
},
|
|
993
|
+
signal
|
|
994
|
+
});
|
|
995
|
+
requireFusion(String(started.childId) === pair.childSessionId, "IDENTITY_CONFLICT", "The native provider returned another child identity.");
|
|
996
|
+
return { messageId: String(started.messageId) };
|
|
997
|
+
},
|
|
998
|
+
async notify({ pair, task, actor, text, signal }) {
|
|
999
|
+
signal.throwIfAborted();
|
|
1000
|
+
const lead = leadOf(bindings, pair);
|
|
1001
|
+
const content = [{
|
|
1002
|
+
type: "text",
|
|
1003
|
+
text: `${`[fusion:${pair.id}:${task.id}:${task.revision}]`}\n${text}`
|
|
1004
|
+
}];
|
|
1005
|
+
let messageId;
|
|
1006
|
+
if (actor.sessionId === pair.leadSessionId && !actor.parentSessionId && actor.project === pair.project) {
|
|
1007
|
+
messageId = randomUUID();
|
|
1008
|
+
lead.followup({
|
|
1009
|
+
id: messageId,
|
|
1010
|
+
role: "user",
|
|
1011
|
+
source: {
|
|
1012
|
+
kind: "plugin:@klarkxy/dsh-fusion",
|
|
1013
|
+
plugin: "@klarkxy/dsh-fusion"
|
|
1014
|
+
},
|
|
1015
|
+
content
|
|
1016
|
+
});
|
|
1017
|
+
} else {
|
|
1018
|
+
const child = ownedChild(bindings, pair);
|
|
1019
|
+
requireFusion(child && String(child.id) === actor.sessionId && actor.parentSessionId === pair.leadSessionId, "UNAUTHORIZED", "Only the current bound child can report to the Lead.");
|
|
1020
|
+
messageId = await bindings.subagents.sendMessage(child, sid(pair.leadSessionId), content, { signal });
|
|
1021
|
+
}
|
|
1022
|
+
if (signal.aborted) {
|
|
1023
|
+
lead.inbox.remove?.(messageId);
|
|
1024
|
+
signal.throwIfAborted();
|
|
1025
|
+
}
|
|
1026
|
+
},
|
|
1027
|
+
async stop(pair, stopLead) {
|
|
1028
|
+
const child = ownedChild(bindings, pair);
|
|
1029
|
+
const lead = bindings.agents.get(sid(pair.leadSessionId));
|
|
1030
|
+
if (lead) {
|
|
1031
|
+
leadOf(bindings, pair);
|
|
1032
|
+
const marker = `[fusion:${pair.id}:`;
|
|
1033
|
+
for (const message of [...lead.inbox.nextStep ?? [], ...lead.inbox.nextTurn ?? []]) if (isOwnedChildNotice(message.source, pair.childSessionId) || message.source.kind === "plugin:@klarkxy/dsh-fusion" && message.content.some((block) => block.type === "text" && block.text.includes(marker))) lead.inbox.remove(message.id);
|
|
1034
|
+
}
|
|
1035
|
+
child?.inbox.clear();
|
|
1036
|
+
if (stopLead && lead) lead.cancel({ kind: "user" }, { keepInbox: true });
|
|
1037
|
+
if (lead) await bindings.subagents.drainContinuableChildren(lead, [sid(pair.childSessionId)]);
|
|
1038
|
+
else if (child) {
|
|
1039
|
+
bindings.subagents.interrupt(sid(pair.childSessionId), {
|
|
1040
|
+
kind: "user",
|
|
1041
|
+
parentSessionId: sid(pair.leadSessionId)
|
|
1042
|
+
});
|
|
1043
|
+
await child.whenIdle();
|
|
1044
|
+
requireFusion(false, "PARENT_UNAVAILABLE", "Child execution stopped, but its parent must be restored before ownership cleanup can be confirmed.");
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
//#endregion
|
|
1050
|
+
//#region src/tools.ts
|
|
1051
|
+
const taskFields = {
|
|
1052
|
+
taskId: {
|
|
1053
|
+
type: "string",
|
|
1054
|
+
required: true
|
|
1055
|
+
},
|
|
1056
|
+
taskRevision: {
|
|
1057
|
+
type: "integer",
|
|
1058
|
+
required: true
|
|
1059
|
+
}
|
|
1060
|
+
};
|
|
1061
|
+
const candidateFields = {
|
|
1062
|
+
...taskFields,
|
|
1063
|
+
candidateId: {
|
|
1064
|
+
type: "string",
|
|
1065
|
+
required: true
|
|
1066
|
+
},
|
|
1067
|
+
hash: {
|
|
1068
|
+
type: "string",
|
|
1069
|
+
required: true
|
|
1070
|
+
}
|
|
1071
|
+
};
|
|
1072
|
+
const output = {
|
|
1073
|
+
schema: { type: "string" },
|
|
1074
|
+
render: (_args, value) => [{
|
|
1075
|
+
type: "text",
|
|
1076
|
+
text: String(value)
|
|
1077
|
+
}]
|
|
1078
|
+
};
|
|
1079
|
+
/** Exact execution Agent identity is bound at registration and checked again on every call. */
|
|
1080
|
+
function fusionTools(runtime, agent, sidekick) {
|
|
1081
|
+
const bound = (exec) => {
|
|
1082
|
+
requireFusion(exec.agent === agent, "UNAUTHORIZED", "Fusion tools cannot be borrowed by another Agent.");
|
|
1083
|
+
return runtime.actor(agent);
|
|
1084
|
+
};
|
|
1085
|
+
if (sidekick) return [defineTool({
|
|
1086
|
+
name: "fusion_report",
|
|
1087
|
+
description: "Report the exact candidate or a decision request for your current Fusion task. Never write the manuscript. Use a unique reportId; do not regenerate or resend after acknowledgement.",
|
|
1088
|
+
parameters: {
|
|
1089
|
+
...taskFields,
|
|
1090
|
+
reportId: {
|
|
1091
|
+
type: "string",
|
|
1092
|
+
required: true
|
|
1093
|
+
},
|
|
1094
|
+
kind: {
|
|
1095
|
+
type: "string",
|
|
1096
|
+
enum: ["candidate", "decision"],
|
|
1097
|
+
required: true
|
|
1098
|
+
},
|
|
1099
|
+
text: {
|
|
1100
|
+
type: "string",
|
|
1101
|
+
required: true
|
|
1102
|
+
},
|
|
1103
|
+
report: { type: "string" }
|
|
1104
|
+
},
|
|
1105
|
+
output,
|
|
1106
|
+
async execute(args, exec) {
|
|
1107
|
+
return JSON.stringify(await runtime.service.report(bound(exec), {
|
|
1108
|
+
...args,
|
|
1109
|
+
signal: exec.signal
|
|
1110
|
+
}));
|
|
1111
|
+
}
|
|
1112
|
+
})];
|
|
1113
|
+
return [
|
|
1114
|
+
defineTool({
|
|
1115
|
+
name: "fusion_delegate",
|
|
1116
|
+
description: "Delegate one bounded task to the same persistent Sidekick. In writing sessions the Writer authors exact prose; provide the original versioned target from read. Wait for its report, then read and review. Generic tasks retain ordinary execution tools; explicitly cancel before takeover.",
|
|
1117
|
+
parameters: {
|
|
1118
|
+
title: {
|
|
1119
|
+
type: "string",
|
|
1120
|
+
required: true
|
|
1121
|
+
},
|
|
1122
|
+
goal: {
|
|
1123
|
+
type: "string",
|
|
1124
|
+
required: true
|
|
1125
|
+
},
|
|
1126
|
+
context: { type: "string" },
|
|
1127
|
+
constraints: {
|
|
1128
|
+
type: "array",
|
|
1129
|
+
items: { type: "string" }
|
|
1130
|
+
},
|
|
1131
|
+
acceptance: {
|
|
1132
|
+
type: "array",
|
|
1133
|
+
items: { type: "string" }
|
|
1134
|
+
},
|
|
1135
|
+
target: {
|
|
1136
|
+
type: "object",
|
|
1137
|
+
additionalProperties: false,
|
|
1138
|
+
properties: {
|
|
1139
|
+
kind: {
|
|
1140
|
+
type: "string",
|
|
1141
|
+
enum: ["edit", "create"],
|
|
1142
|
+
required: true
|
|
1143
|
+
},
|
|
1144
|
+
path: {
|
|
1145
|
+
type: "string",
|
|
1146
|
+
required: true
|
|
1147
|
+
},
|
|
1148
|
+
oldText: { type: "string" },
|
|
1149
|
+
targetVersion: { type: "string" },
|
|
1150
|
+
basis: {
|
|
1151
|
+
type: "array",
|
|
1152
|
+
items: {
|
|
1153
|
+
type: "object",
|
|
1154
|
+
properties: {
|
|
1155
|
+
path: {
|
|
1156
|
+
type: "string",
|
|
1157
|
+
required: true
|
|
1158
|
+
},
|
|
1159
|
+
version: {
|
|
1160
|
+
type: "string",
|
|
1161
|
+
required: true
|
|
1162
|
+
},
|
|
1163
|
+
label: { type: "string" }
|
|
1164
|
+
},
|
|
1165
|
+
additionalProperties: false
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
},
|
|
1169
|
+
description: "Writing target: edit requires original targetVersion and unique exact oldText; Writer text replaces that fragment. create requires a nonexistent .md/.txt path; Writer text becomes full content. basis lists original context read versions. No replacement text is accepted here."
|
|
1170
|
+
}
|
|
1171
|
+
},
|
|
1172
|
+
output,
|
|
1173
|
+
async execute(args, exec) {
|
|
1174
|
+
bound(exec);
|
|
1175
|
+
return JSON.stringify(await runtime.delegate(agent, args, exec.signal));
|
|
1176
|
+
}
|
|
1177
|
+
}),
|
|
1178
|
+
defineTool({
|
|
1179
|
+
name: "fusion_read",
|
|
1180
|
+
description: "Read the saved exact Sidekick candidate and its hash. Review this content without rewriting it.",
|
|
1181
|
+
parameters: {
|
|
1182
|
+
taskId: {
|
|
1183
|
+
type: "string",
|
|
1184
|
+
required: true
|
|
1185
|
+
},
|
|
1186
|
+
candidateId: { type: "string" }
|
|
1187
|
+
},
|
|
1188
|
+
output,
|
|
1189
|
+
async execute(args, exec) {
|
|
1190
|
+
return JSON.stringify(runtime.service.read(bound(exec), args.taskId, args.candidateId));
|
|
1191
|
+
}
|
|
1192
|
+
}),
|
|
1193
|
+
defineTool({
|
|
1194
|
+
name: "fusion_review",
|
|
1195
|
+
description: "Review the exact candidate id and hash. accept is model review only; the author still decides manuscript adoption. revise requires concrete feedback. reject ends the task.",
|
|
1196
|
+
parameters: {
|
|
1197
|
+
...candidateFields,
|
|
1198
|
+
verdict: {
|
|
1199
|
+
type: "string",
|
|
1200
|
+
enum: [
|
|
1201
|
+
"accept",
|
|
1202
|
+
"revise",
|
|
1203
|
+
"reject"
|
|
1204
|
+
],
|
|
1205
|
+
required: true
|
|
1206
|
+
},
|
|
1207
|
+
feedback: {
|
|
1208
|
+
type: "string",
|
|
1209
|
+
required: true
|
|
1210
|
+
}
|
|
1211
|
+
},
|
|
1212
|
+
output,
|
|
1213
|
+
async execute(args, exec) {
|
|
1214
|
+
return JSON.stringify(await runtime.service.review(bound(exec), {
|
|
1215
|
+
...args,
|
|
1216
|
+
signal: exec.signal
|
|
1217
|
+
}));
|
|
1218
|
+
}
|
|
1219
|
+
}),
|
|
1220
|
+
defineTool({
|
|
1221
|
+
name: "fusion_decide",
|
|
1222
|
+
description: "Resolve a Sidekick decision or explicitly resume an interrupted task with feedback, reusing its persistent session.",
|
|
1223
|
+
parameters: {
|
|
1224
|
+
...taskFields,
|
|
1225
|
+
feedback: {
|
|
1226
|
+
type: "string",
|
|
1227
|
+
required: true
|
|
1228
|
+
}
|
|
1229
|
+
},
|
|
1230
|
+
output,
|
|
1231
|
+
async execute(args, exec) {
|
|
1232
|
+
return JSON.stringify(await runtime.service.decide(bound(exec), {
|
|
1233
|
+
...args,
|
|
1234
|
+
signal: exec.signal
|
|
1235
|
+
}));
|
|
1236
|
+
}
|
|
1237
|
+
}),
|
|
1238
|
+
defineTool({
|
|
1239
|
+
name: "fusion_cancel",
|
|
1240
|
+
description: "Cancel the owned Sidekick task before explicit takeover. This preserves unrelated child sessions and does not undo files already applied.",
|
|
1241
|
+
parameters: taskFields,
|
|
1242
|
+
output,
|
|
1243
|
+
async execute(args, exec) {
|
|
1244
|
+
const actor = bound(exec);
|
|
1245
|
+
await runtime.service.cancel(actor, args.taskId, args.taskRevision);
|
|
1246
|
+
return "Fusion task cancelled; takeover is now explicit.";
|
|
1247
|
+
}
|
|
1248
|
+
})
|
|
1249
|
+
];
|
|
1250
|
+
}
|
|
1251
|
+
//#endregion
|
|
1252
|
+
//#region src/runtime.ts
|
|
1253
|
+
var FusionRuntime = class {
|
|
1254
|
+
service;
|
|
1255
|
+
ctx;
|
|
1256
|
+
ai;
|
|
1257
|
+
disposers = [];
|
|
1258
|
+
installed = /* @__PURE__ */ new Map();
|
|
1259
|
+
pendingNoticeClaims = /* @__PURE__ */ new Set();
|
|
1260
|
+
closing;
|
|
1261
|
+
constructor(ctx, store, ai) {
|
|
1262
|
+
this.ctx = ctx;
|
|
1263
|
+
this.ai = ai;
|
|
1264
|
+
const native = createNativeBridge({
|
|
1265
|
+
agents: ctx.agents,
|
|
1266
|
+
subagents: ctx.subagents
|
|
1267
|
+
}, {
|
|
1268
|
+
scopedReport: true,
|
|
1269
|
+
verifyContinuation: async (pair, signal) => {
|
|
1270
|
+
requireFusion(await this.inspectAdmission(pair, signal) === "present", "CHILD_MISSING", "The persistent Sidekick is missing. Explicitly resume to inspect recovery.");
|
|
1271
|
+
},
|
|
1272
|
+
tools: (parent, pair) => this.childTools(parent, pair),
|
|
1273
|
+
persona: (pair) => pair.profile === "writing" ? "You are the persistent Fusion Writer. Author exact prose candidates for the assigned brief and captured destination. You may inspect allowed context, but must never mutate manuscript files. Report via fusion_report; ask a decision when blocked. Do not delegate, contact the user directly, or treat rejected drafts as story facts. Stop after reporting." : "You are the persistent Fusion Sidekick. Execute only the assigned bounded task using native permissions. Report exact outcomes and verifiable evidence via fusion_report; report decisions when blocked. Never recursively enable Fusion or take over the Lead. Stop after reporting."
|
|
1274
|
+
});
|
|
1275
|
+
this.service = new FusionService({
|
|
1276
|
+
store,
|
|
1277
|
+
native,
|
|
1278
|
+
inspectAdmission: (pair, signal) => this.inspectAdmission(pair, signal)
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
async inspectAdmission(pair, signal) {
|
|
1282
|
+
signal.throwIfAborted();
|
|
1283
|
+
const entry = (await this.ctx.subagents.listChildren(pair.leadSessionId, signal)).find((row) => String(row.id) === pair.childSessionId);
|
|
1284
|
+
let observation;
|
|
1285
|
+
try {
|
|
1286
|
+
observation = await this.ctx.sessionQuery.observeSession(pair.childSessionId, {
|
|
1287
|
+
signal,
|
|
1288
|
+
projectionMode: "all"
|
|
1289
|
+
});
|
|
1290
|
+
} catch (error) {
|
|
1291
|
+
if (error && typeof error === "object" && "code" in error && error.code === "SESSION_QUERY_SESSION_NOT_FOUND" && !entry && !this.ctx.agents.get(pair.childSessionId)) return "absent";
|
|
1292
|
+
throw error;
|
|
1293
|
+
}
|
|
1294
|
+
try {
|
|
1295
|
+
const header = observation.header, descriptor = foldSubagentDescriptor(observation.events);
|
|
1296
|
+
requireFusion(String(header.id) === pair.childSessionId && String(header.parentSession) === pair.leadSessionId && header.cwd === pair.project, "IDENTITY_CONFLICT", "The native Sidekick does not match this pair and workspace.");
|
|
1297
|
+
requireFusion(descriptor?.mode === "continuable" && descriptor.provider === "spawn" && (!entry || entry.mode === "continuable"), "UNCERTAIN_ADMISSION", "The saved child has no supported native continuation descriptor.");
|
|
1298
|
+
requireFusion(descriptor.agentProvider === pair.route.provider && descriptor.agentModel === pair.route.model && descriptor.agentReasoningEffort === pair.route.reasoningEffort, "ROUTE_CHANGED", "The native Sidekick route differs from its pinned Fusion route.");
|
|
1299
|
+
const selected = observation.projections?.values;
|
|
1300
|
+
if (selected?.modelSelection?.next) this.checkRoute(pair, selected.modelSelection.next);
|
|
1301
|
+
const live = this.ctx.agents.get(pair.childSessionId);
|
|
1302
|
+
if (live) this.checkRoute(pair, live.options);
|
|
1303
|
+
return "present";
|
|
1304
|
+
} finally {
|
|
1305
|
+
observation[Symbol.dispose]();
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
checkRoute(pair, route) {
|
|
1309
|
+
requireFusion(route.provider === pair.route.provider && route.model === pair.route.model && route.reasoningEffort === pair.route.reasoningEffort, "ROUTE_CHANGED", "The Sidekick model changed outside Fusion. Restore its pinned route before continuing.");
|
|
1310
|
+
}
|
|
1311
|
+
actor(agent) {
|
|
1312
|
+
requireFusion(this.service.active, "DISABLED", "Fusion is disabled.");
|
|
1313
|
+
return nativeActor({
|
|
1314
|
+
agents: this.ctx.agents,
|
|
1315
|
+
subagents: this.ctx.subagents
|
|
1316
|
+
}, agent);
|
|
1317
|
+
}
|
|
1318
|
+
writing() {
|
|
1319
|
+
return this.ctx.get("fusionWriting");
|
|
1320
|
+
}
|
|
1321
|
+
profile(agent, pair) {
|
|
1322
|
+
const domain = this.writing();
|
|
1323
|
+
requireFusion(domain || !String(agent.session.header.agentPreset ?? "").startsWith("dsh-editor"), "DOMAIN_UNAVAILABLE", "The Editor writing domain is unavailable; delegation is blocked until it is restored.");
|
|
1324
|
+
if (pair?.profile === "writing") {
|
|
1325
|
+
requireFusion(domain && domain.matches(agent.session.header), "DOMAIN_UNAVAILABLE", "The writing domain is unavailable or this conversation changed writing mode.");
|
|
1326
|
+
return "writing";
|
|
1327
|
+
}
|
|
1328
|
+
return domain?.matches(agent.session.header) ? "writing" : "generic";
|
|
1329
|
+
}
|
|
1330
|
+
childTools(parent, pair) {
|
|
1331
|
+
if (pair.profile !== "writing") return ["fusion_report"];
|
|
1332
|
+
const available = parent.ctx.tools.schemas(parent).map((tool) => tool.name).filter((name) => name !== "run_code" && !FUSION_TOOLS.includes(name));
|
|
1333
|
+
this.profile(parent, pair);
|
|
1334
|
+
const allow = new Set(this.writing().writerTools);
|
|
1335
|
+
return [...available.filter((name) => allow.has(name)), "fusion_report"];
|
|
1336
|
+
}
|
|
1337
|
+
async start() {
|
|
1338
|
+
await this.service.initialized();
|
|
1339
|
+
const scope = this.ai.activate(FUSION_PLUGIN);
|
|
1340
|
+
this.disposers.push(() => scope.dispose());
|
|
1341
|
+
this.disposers.push(scope.registerPurpose({
|
|
1342
|
+
id: FUSION_PURPOSE,
|
|
1343
|
+
label: "Fusion 持久搭档",
|
|
1344
|
+
defaultTarget: {
|
|
1345
|
+
kind: "role",
|
|
1346
|
+
role: "normal"
|
|
1347
|
+
}
|
|
1348
|
+
}));
|
|
1349
|
+
this.disposers.push(this.ctx.on("agent/created", async ({ agent }) => {
|
|
1350
|
+
await this.install(agent);
|
|
1351
|
+
}, { global: true }));
|
|
1352
|
+
this.disposers.push(this.ctx.on("agent/disposed", ({ agent }) => {
|
|
1353
|
+
this.uninstall(agent);
|
|
1354
|
+
}, { global: true }));
|
|
1355
|
+
for (const agent of this.ctx.agents.list()) await this.install(agent);
|
|
1356
|
+
}
|
|
1357
|
+
async install(agent) {
|
|
1358
|
+
if (this.installed.has(agent) || !this.service.active) return;
|
|
1359
|
+
const header = agent.session.header, pair = this.service.pairFor(String(agent.id));
|
|
1360
|
+
const sidekick = pair?.childSessionId === String(agent.id);
|
|
1361
|
+
const root = !header.parentSession;
|
|
1362
|
+
const disposers = [];
|
|
1363
|
+
this.installed.set(agent, disposers);
|
|
1364
|
+
try {
|
|
1365
|
+
disposers.push(agent.ctx.tools.guard((exec) => {
|
|
1366
|
+
if (exec.agent !== agent) return "Fusion execution identity mismatch.";
|
|
1367
|
+
const livePair = this.service.pairFor(String(agent.id));
|
|
1368
|
+
if (!this.service.active) return sidekick || FUSION_TOOLS.includes(exec.name) ? "Fusion is disabled." : void 0;
|
|
1369
|
+
if (!root && !sidekick) return FUSION_TOOLS.includes(exec.name) ? "Only the owned Fusion Sidekick may report." : void 0;
|
|
1370
|
+
if (sidekick) {
|
|
1371
|
+
if (!livePair || livePair.childSessionId !== String(agent.id) || String(header.parentSession) !== livePair.leadSessionId || header.cwd !== livePair.project) return "Fusion child identity changed.";
|
|
1372
|
+
const task = livePair.tasks.at(-1);
|
|
1373
|
+
if (!task || !["working", "dispatching"].includes(task.state)) return "The Fusion task is no longer accepting execution.";
|
|
1374
|
+
if (exec.name === "fusion_report" || exec.name === "run_code") return void 0;
|
|
1375
|
+
if (FUSION_TOOLS.includes(exec.name)) return "Sidekick cannot control the Lead.";
|
|
1376
|
+
if (livePair.profile === "writing" && !this.writing()?.writerTools.includes(exec.name)) return "Writer tools are restricted to the writing domain read surface.";
|
|
1377
|
+
if (livePair.profile === "generic") {
|
|
1378
|
+
const lead = this.ctx.agents.get(livePair.leadSessionId);
|
|
1379
|
+
if (!lead || lead.session.header.parentSession || lead.session.header.cwd !== livePair.project) return "The Fusion Lead workspace is unavailable or changed.";
|
|
1380
|
+
if (!lead.ctx.tools.get(exec.name, lead)) return "This capability is not available to the Fusion Lead.";
|
|
1381
|
+
}
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
if (exec.name === "fusion_report") return "Only the owned Sidekick may report.";
|
|
1385
|
+
if (exec.name === "run_code") return void 0;
|
|
1386
|
+
try {
|
|
1387
|
+
if (this.profile(agent, livePair) === "writing" && !FUSION_TOOLS.includes(exec.name) && !this.writing().allowLeadTool(exec.name, exec.arguments)) return "Delegate prose to the Fusion Writer; the author adopts the exact candidate.";
|
|
1388
|
+
} catch {
|
|
1389
|
+
return "The Fusion writing domain is unavailable; execution is blocked until restored.";
|
|
1390
|
+
}
|
|
1391
|
+
}));
|
|
1392
|
+
if (root) {
|
|
1393
|
+
const claims = /* @__PURE__ */ new Map();
|
|
1394
|
+
disposers.push(agent.ctx.on("agent/inbox/claimed", ({ message, turn }) => {
|
|
1395
|
+
const pair = this.service.pairFor(String(agent.id));
|
|
1396
|
+
if (!pair || !isOwnedChildNotice(message.source, pair.childSessionId) || claims.has(turn)) return;
|
|
1397
|
+
let resolve;
|
|
1398
|
+
const pending = new Promise((done) => {
|
|
1399
|
+
resolve = done;
|
|
1400
|
+
});
|
|
1401
|
+
this.pendingNoticeClaims.add(pending);
|
|
1402
|
+
claims.set(turn, () => {
|
|
1403
|
+
claims.delete(turn);
|
|
1404
|
+
this.pendingNoticeClaims.delete(pending);
|
|
1405
|
+
resolve();
|
|
1406
|
+
});
|
|
1407
|
+
}));
|
|
1408
|
+
disposers.push(agent.ctx.on("session/event", (_session, event) => {
|
|
1409
|
+
if (event.type === "turn/end") claims.get(event.data.turn)?.();
|
|
1410
|
+
}));
|
|
1411
|
+
disposers.push(() => {
|
|
1412
|
+
for (const finish of [...claims.values()]) finish();
|
|
1413
|
+
});
|
|
1414
|
+
const discard = (message) => {
|
|
1415
|
+
const pair = this.service.pairFor(String(agent.id));
|
|
1416
|
+
if (!pair || !isOwnedChildNotice(message.source, pair.childSessionId)) return false;
|
|
1417
|
+
if (message.source.kind === "subagent-settled") return true;
|
|
1418
|
+
const task = pair.tasks.at(-1);
|
|
1419
|
+
return !this.service.active || !task || !["review", "decision"].includes(task.state) || !message.content.some((block) => block.type === "text" && block.text.includes(`[fusion:${pair.id}:${task.id}:${task.revision}]`));
|
|
1420
|
+
};
|
|
1421
|
+
disposers.push(agent.ctx.on("agent/pre-step", async (payload, next) => {
|
|
1422
|
+
const newTurn = payload.step === 1;
|
|
1423
|
+
const rejectOwnedWake = newTurn && payload.messages.length > 0 && payload.messages.every(discard);
|
|
1424
|
+
claims.get(payload.turn)?.();
|
|
1425
|
+
if (rejectOwnedWake) return { kind: "reject" };
|
|
1426
|
+
const decision = await next();
|
|
1427
|
+
if (decision.kind === "reject") return decision;
|
|
1428
|
+
const messages = decision.messages.filter((message) => !discard(message));
|
|
1429
|
+
return newTurn && decision.messages.length > 0 && messages.length === 0 ? { kind: "reject" } : {
|
|
1430
|
+
...decision,
|
|
1431
|
+
messages
|
|
1432
|
+
};
|
|
1433
|
+
}, { prepend: true }));
|
|
1434
|
+
}
|
|
1435
|
+
if (!root && !sidekick) return;
|
|
1436
|
+
if (sidekick) {
|
|
1437
|
+
requireFusion(pair && header.cwd === pair.project && String(header.parentSession) === pair.leadSessionId, "UNAUTHORIZED", "Stored Writer identity does not match the native child.");
|
|
1438
|
+
if (pair.profile === "writing") {
|
|
1439
|
+
const registry = this.ctx.get("workspaceRegistry");
|
|
1440
|
+
const parent = this.ctx.sessions.get(pair.leadSessionId);
|
|
1441
|
+
requireFusion(registry && parent && parent.header.cwd === pair.project && !parent.header.parentSession, "WORKSPACE_UNAVAILABLE", "The Writer requires its real Lead workspace.");
|
|
1442
|
+
const workspace = await registry.resolveByPath(pair.project);
|
|
1443
|
+
requireFusion(workspace && workspace.sessionIds.some((id) => String(id) === pair.leadSessionId), "WORKSPACE_MISMATCH", "The Lead is not attached to this workspace.");
|
|
1444
|
+
await workspace.attachSession(agent.id);
|
|
1445
|
+
this.actor(agent);
|
|
1446
|
+
}
|
|
1447
|
+
const turns = /* @__PURE__ */ new Map();
|
|
1448
|
+
disposers.push(agent.ctx.on("session/event", (_session, event) => {
|
|
1449
|
+
if (event.type === "turn/start") {
|
|
1450
|
+
const task = this.service.pairFor(String(agent.id))?.tasks.at(-1);
|
|
1451
|
+
if (task) turns.set(event.data.turn, {
|
|
1452
|
+
taskId: task.id,
|
|
1453
|
+
revision: task.revision
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
if (event.type === "turn/end") {
|
|
1457
|
+
const expected = turns.get(event.data.turn);
|
|
1458
|
+
turns.delete(event.data.turn);
|
|
1459
|
+
if (!expected || !this.service.active) return;
|
|
1460
|
+
const reason = event.data.reason.kind === "error" ? event.data.reason.error.message : `The Sidekick turn ended (${event.data.reason.kind}) without a saved report. Inspect its session before resuming.`;
|
|
1461
|
+
this.service.executionInterrupted(this.actor(agent), reason, expected).catch((error) => this.ctx.logger.warn("Fusion could not save child settlement", error));
|
|
1462
|
+
}
|
|
1463
|
+
}));
|
|
1464
|
+
this.checkRoute(pair, agent.options);
|
|
1465
|
+
disposers.push(agent.ctx.on("agent/request", async (_payload, next) => {
|
|
1466
|
+
const config = await next();
|
|
1467
|
+
try {
|
|
1468
|
+
this.checkRoute(pair, config);
|
|
1469
|
+
} catch (error) {
|
|
1470
|
+
await this.service.executionInterrupted(this.actor(agent), error instanceof Error ? error.message : String(error));
|
|
1471
|
+
throw error;
|
|
1472
|
+
}
|
|
1473
|
+
return config;
|
|
1474
|
+
}, { prepend: true }));
|
|
1475
|
+
if (pair.profile === "writing") requireFusion(this.writing(), "DOMAIN_UNAVAILABLE", "Writing domain required for the existing Writer.");
|
|
1476
|
+
}
|
|
1477
|
+
for (const tool of fusionTools(this, agent, sidekick)) disposers.push(agent.ctx.tools.register(tool));
|
|
1478
|
+
if (root) disposers.push(agent.ctx.systemPrompt.section({
|
|
1479
|
+
name: "fusion:lead",
|
|
1480
|
+
order: 95,
|
|
1481
|
+
text: "Fusion collaboration is enabled. You are the Lead in the original user conversation. Discuss, plan and review; use fusion_delegate for bounded Sidekick work. Only one task may be active. Read its exact saved candidate with fusion_read before fusion_review. Accept means model review, never author file adoption. In writing modes, delegate prose editing/creation with its original read versions; do not rewrite the Writer candidate while forwarding it. Resolve decisions with fusion_decide. For explicit takeover cancel the task first with fusion_cancel. Ordinary generic native tools remain available. Existing model route stays pinned to this pair. Never create another Fusion pair from child sessions."
|
|
1482
|
+
}));
|
|
1483
|
+
} catch (error) {
|
|
1484
|
+
this.uninstall(agent);
|
|
1485
|
+
throw error;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
uninstall(agent) {
|
|
1489
|
+
const disposers = this.installed.get(agent);
|
|
1490
|
+
this.installed.delete(agent);
|
|
1491
|
+
for (const dispose of disposers?.reverse() ?? []) dispose();
|
|
1492
|
+
}
|
|
1493
|
+
async delegate(agent, input, signal) {
|
|
1494
|
+
const actor = this.actor(agent), row = object(input), pair = this.service.pairFor(actor.sessionId);
|
|
1495
|
+
requireFusion(!actor.parentSessionId, "UNAUTHORIZED", "Only root sessions delegate.");
|
|
1496
|
+
const profile = this.profile(agent, pair);
|
|
1497
|
+
const route = pair?.route ?? await this.ai.resolve("fusion.sidekick", actor.sessionId);
|
|
1498
|
+
signal.throwIfAborted();
|
|
1499
|
+
this.actor(agent);
|
|
1500
|
+
const target = profile === "writing" ? await this.writing().capture(actor, row.target, signal) : void 0;
|
|
1501
|
+
signal.throwIfAborted();
|
|
1502
|
+
this.actor(agent);
|
|
1503
|
+
return this.service.delegate(actor, {
|
|
1504
|
+
profile,
|
|
1505
|
+
route: {
|
|
1506
|
+
provider: route.provider,
|
|
1507
|
+
model: route.model,
|
|
1508
|
+
...route.reasoningEffort ? { reasoningEffort: route.reasoningEffort } : {}
|
|
1509
|
+
},
|
|
1510
|
+
brief: brief(row),
|
|
1511
|
+
...target ? { target } : {},
|
|
1512
|
+
signal
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
rpcActor(sessionId) {
|
|
1516
|
+
const session = this.ctx.sessions.get(sessionId);
|
|
1517
|
+
requireFusion(session && String(session.id) === sessionId, "SESSION_NOT_FOUND", "The native session is not loaded.");
|
|
1518
|
+
requireFusion(!session.header.parentSession && this.service.role(sessionId) !== "sidekick", "UNAUTHORIZED", "A child session cannot act as the Lead.");
|
|
1519
|
+
const project = text(session.header.cwd, "session workspace", 8192);
|
|
1520
|
+
const pair = this.service.pairFor(sessionId);
|
|
1521
|
+
requireFusion(!pair || pair.project === project, "CONTEXT_CHANGED", "The session workspace differs from the Fusion pair.");
|
|
1522
|
+
return {
|
|
1523
|
+
actor: {
|
|
1524
|
+
sessionId,
|
|
1525
|
+
project
|
|
1526
|
+
},
|
|
1527
|
+
session
|
|
1528
|
+
};
|
|
1529
|
+
}
|
|
1530
|
+
async rpc(endpoint, payload, signal) {
|
|
1531
|
+
await this.service.initialized();
|
|
1532
|
+
signal.throwIfAborted();
|
|
1533
|
+
requireFusion(this.service.active, "DISABLED", "Fusion is disabled.");
|
|
1534
|
+
const row = object(payload), sessionId = text(row.sessionId, "session id", 200);
|
|
1535
|
+
const { actor, session } = this.rpcActor(sessionId);
|
|
1536
|
+
const pair = this.service.pairFor(sessionId), profile = this.profile({ session }, pair);
|
|
1537
|
+
const domain = profile === "writing" ? this.writing() : void 0;
|
|
1538
|
+
if (domain) await this.service.reconcile(actor, domain, signal);
|
|
1539
|
+
signal.throwIfAborted();
|
|
1540
|
+
this.rpcActor(sessionId);
|
|
1541
|
+
if (endpoint === "status") {
|
|
1542
|
+
let error;
|
|
1543
|
+
if (!pair) try {
|
|
1544
|
+
await this.ai.resolve(FUSION_PURPOSE, sessionId);
|
|
1545
|
+
} catch (cause) {
|
|
1546
|
+
error = cause instanceof Error ? cause.message : String(cause);
|
|
1547
|
+
}
|
|
1548
|
+
const current = this.service.pairFor(sessionId);
|
|
1549
|
+
return {
|
|
1550
|
+
available: true,
|
|
1551
|
+
configured: !error,
|
|
1552
|
+
profile,
|
|
1553
|
+
revision: this.service.snapshot().revision,
|
|
1554
|
+
...error ? { error } : {},
|
|
1555
|
+
...current ? { pair: current } : {},
|
|
1556
|
+
usage: {
|
|
1557
|
+
leadTokens: null,
|
|
1558
|
+
sidekickTokens: null,
|
|
1559
|
+
cost: null
|
|
1560
|
+
},
|
|
1561
|
+
activity: {
|
|
1562
|
+
lead: this.ctx.agents.get(sessionId)?.status ?? "idle",
|
|
1563
|
+
sidekick: current ? this.ctx.agents.get(current.childSessionId)?.status ?? "idle" : "idle"
|
|
1564
|
+
}
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
const taskId = text(row.taskId, "task id", 200), taskRevision = integer(row.taskRevision, "task revision");
|
|
1568
|
+
if (endpoint === "cancel") {
|
|
1569
|
+
await this.service.cancel(actor, taskId, taskRevision, true);
|
|
1570
|
+
return null;
|
|
1571
|
+
}
|
|
1572
|
+
if (endpoint === "resume") return this.service.decide(actor, {
|
|
1573
|
+
taskId,
|
|
1574
|
+
taskRevision,
|
|
1575
|
+
feedback: text(row.feedback, "feedback", 16e3),
|
|
1576
|
+
signal
|
|
1577
|
+
});
|
|
1578
|
+
if (endpoint === "recover") return this.service.recover(actor, taskId, taskRevision, signal);
|
|
1579
|
+
requireFusion(domain, "DOMAIN_UNAVAILABLE", "File adoption requires a writing domain.");
|
|
1580
|
+
const action = {
|
|
1581
|
+
sessionId,
|
|
1582
|
+
taskId,
|
|
1583
|
+
taskRevision,
|
|
1584
|
+
candidateId: text(row.candidateId, "candidate id", 200),
|
|
1585
|
+
hash: text(row.hash, "candidate hash", 64)
|
|
1586
|
+
};
|
|
1587
|
+
if (endpoint === "preview") return this.service.preview(actor, action, domain, signal);
|
|
1588
|
+
if (endpoint === "apply") return this.service.applyCandidate(actor, {
|
|
1589
|
+
...action,
|
|
1590
|
+
expectedVersion: text(row.expectedVersion, "expected version", 8192, true)
|
|
1591
|
+
}, domain, signal);
|
|
1592
|
+
if (endpoint === "dismiss") return this.service.dismiss(actor, action);
|
|
1593
|
+
requireFusion(false, "NOT_FOUND", "Unknown Fusion endpoint.");
|
|
1594
|
+
}
|
|
1595
|
+
dispose() {
|
|
1596
|
+
if (this.closing) return this.closing;
|
|
1597
|
+
this.closing = (async () => {
|
|
1598
|
+
try {
|
|
1599
|
+
await this.service.dispose();
|
|
1600
|
+
} finally {
|
|
1601
|
+
for (const agent of this.installed.keys()) {
|
|
1602
|
+
const pair = this.service.pairFor(String(agent.id));
|
|
1603
|
+
if (!pair || pair.leadSessionId !== String(agent.id) || this.ctx.agents.get(agent.id) !== agent) continue;
|
|
1604
|
+
for (const message of [...agent.inbox.nextStep, ...agent.inbox.nextTurn]) if (isOwnedChildNotice(message.source, pair.childSessionId)) agent.inbox.remove(message.id);
|
|
1605
|
+
}
|
|
1606
|
+
while (this.pendingNoticeClaims.size) await Promise.all([...this.pendingNoticeClaims]);
|
|
1607
|
+
for (const agent of [...this.installed.keys()]) this.uninstall(agent);
|
|
1608
|
+
for (const dispose of this.disposers.reverse()) dispose();
|
|
1609
|
+
}
|
|
1610
|
+
})();
|
|
1611
|
+
return this.closing;
|
|
1612
|
+
}
|
|
1613
|
+
};
|
|
1614
|
+
//#endregion
|
|
1615
|
+
//#region src/index.ts
|
|
1616
|
+
const name = FUSION_PLUGIN;
|
|
1617
|
+
const inject = [
|
|
1618
|
+
"agents",
|
|
1619
|
+
"subagents",
|
|
1620
|
+
"tools",
|
|
1621
|
+
"systemPrompt",
|
|
1622
|
+
"sessions",
|
|
1623
|
+
"sessionQuery",
|
|
1624
|
+
"sessionProjections",
|
|
1625
|
+
"storageDomain",
|
|
1626
|
+
"aiServices",
|
|
1627
|
+
"connection",
|
|
1628
|
+
"webServer"
|
|
1629
|
+
];
|
|
1630
|
+
async function apply(ctx) {
|
|
1631
|
+
const domain = await ctx.storageDomain.open(fusionDomain);
|
|
1632
|
+
let runtime;
|
|
1633
|
+
try {
|
|
1634
|
+
runtime = new FusionRuntime(ctx, createFusionStore(domain.table("state")), ctx.get("aiServices"));
|
|
1635
|
+
await runtime.start();
|
|
1636
|
+
} catch (error) {
|
|
1637
|
+
if (runtime) await runtime.dispose().catch(() => {});
|
|
1638
|
+
await domain.close();
|
|
1639
|
+
throw error;
|
|
1640
|
+
}
|
|
1641
|
+
const activeRuntime = runtime;
|
|
1642
|
+
ctx.effect(() => async () => {
|
|
1643
|
+
try {
|
|
1644
|
+
await activeRuntime.dispose();
|
|
1645
|
+
} finally {
|
|
1646
|
+
await domain.close();
|
|
1647
|
+
}
|
|
1648
|
+
}, "fusion.dispose");
|
|
1649
|
+
ctx.provide("fusion", activeRuntime);
|
|
1650
|
+
ctx.effect(() => registerHostRpc(ctx, FUSION_RPC_CHANNEL, async (endpoint, payload, signal) => {
|
|
1651
|
+
try {
|
|
1652
|
+
return {
|
|
1653
|
+
ok: true,
|
|
1654
|
+
value: await activeRuntime.rpc(endpoint, payload, signal)
|
|
1655
|
+
};
|
|
1656
|
+
} catch (error) {
|
|
1657
|
+
return {
|
|
1658
|
+
ok: false,
|
|
1659
|
+
error: {
|
|
1660
|
+
code: error instanceof FusionError ? error.code : "FAILED",
|
|
1661
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1662
|
+
}
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
1665
|
+
}), "fusion.rpc");
|
|
1666
|
+
}
|
|
1667
|
+
//#endregion
|
|
1668
|
+
export { FusionRuntime, apply, inject, name };
|
|
1669
|
+
|
|
1670
|
+
//# sourceMappingURL=index.js.map
|