abelien-harness 0.9.0 → 0.11.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/README.md +6 -6
- package/package.json +2 -1
- package/skills/abelien-algorithm/SKILL.md +14 -6
- package/skills/abelien-algorithm/domain.json +59 -5
- package/skills/abelien-algorithm/references/research-policy.md +11 -3
- package/skills/abelien-algorithm/references/selection-service-contract.md +50 -0
- package/skills/abelien-algorithm/schemas/algorithm-bindings.v1.json +414 -0
- package/skills/abelien-algorithm/schemas/algorithm-candidates.v1.json +192 -0
- package/skills/abelien-algorithm/scripts/candidates.mjs +119 -0
- package/skills/abelien-algorithm/scripts/evidence.mjs +69 -0
- package/skills/abelien-algorithm/scripts/finalize-selection.mjs +64 -7
- package/skills/abelien-algorithm/scripts/lane-state.mjs +27 -0
- package/skills/abelien-algorithm/scripts/research.mjs +203 -0
- package/skills/abelien-guide/SKILL.md +2 -2
- package/skills/abelien-harness/references/workflow-spec.md +1 -3
- package/skills/abelien-harness/scripts/ab.mjs +42 -18
- package/skills/abelien-workspace/SKILL.md +9 -16
- package/skills/abelien-workspace/assets/journey-stage-plan.json +2 -2
- package/skills/abelien-workspace/assets/lane-plan.json +36 -0
- package/skills/abelien-workspace/domain.json +23 -2
- package/skills/abelien-workspace/references/hardware-journey-orchestration.md +3 -3
- package/skills/abelien-workspace/references/harness-binding.md +23 -17
- package/skills/abelien-workspace/references/journey-stage-plan.md +185 -0
- package/skills/abelien-workspace/references/user-journey.md +11 -7
- package/skills/abelien-workspace/scripts/lanes-next.mjs +38 -0
- package/skills/abelien-workspace/workflows/definitions/0852e4949f5a6246880c1a26bf5ccaa42f8cda06b960e7c7ce234a0a176878f9.json +0 -229
- package/skills/abelien-workspace/workflows/definitions/1179e62586cf6d88c6820022a933f719b3b4eb3c0df0856fb7a0cc6341a9d630.json +0 -1
- package/skills/abelien-workspace/workflows/definitions/1a9afef5463f53e58222b3476c0002aa9703b5ced16f531e3224beb14a28f9a3.json +0 -229
- package/skills/abelien-workspace/workflows/definitions/32ed16198839c06c36b43acbada5ba74403e37b2d351058bd7ad3d5269f76722.json +0 -1
- package/skills/abelien-workspace/workflows/definitions/399ef372790b7330d0ef88ce5096e7f69943d4793de97b2ed6c25165a332612f.json +0 -230
- package/skills/abelien-workspace/workflows/definitions/748a70482fb2f775f6e5fd942c7873534cf3e59614c77381f812a9710d479743.json +0 -1
- package/skills/abelien-workspace/workflows/definitions/b780bf280d61596b43eb6dbc6a417cf8df1ec9a7ae79a6de10f048da9ed58729.json +0 -229
- package/skills/abelien-workspace/workflows/definitions/c1b5c6c653f0c4761d51efee923aafbbeea05512e7f35c60f7f7d36ec49fc1d1.json +0 -229
- package/skills/abelien-workspace/workflows/definitions/df147960a3f7c4d631f66a6b91a7891f8a37d25a50322cf09bdc9304195963b0.json +0 -1
- package/skills/abelien-workspace/workflows/user-journey-hardware-algorithm.json +0 -230
- package/skills/abelien-workspace/workflows/user-journey.json +0 -391
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// algorithm.candidates(P2-03 算法并行车道):核验 Agent 读完 implementation 研究后为每个 reaction 提出的候选实现,
|
|
2
|
+
// 保存为 status=proposed 的 algorithm-research。这一步不依赖硬件,可在硬件选型进行中完成;与硬件的兼容核对留给
|
|
3
|
+
// algorithm.finalize 的 bind 操作。引用必须逐字出现在本次研究存下的正文里(规范化空白与全半角后比较);同一份实现资料
|
|
4
|
+
// 常常同时支撑几个 reaction,所以可以引用本次研究读到的任一页。只出现在检索摘要里的引用标为 search_snippet,可用于发现,
|
|
5
|
+
// 不能当作证据。一次提交里每个候选的问题一起报出,免得 Agent 一轮只改一处。
|
|
6
|
+
import { text, record, digest, failure } from "../../abelien-harness/scripts/web/common.mjs";
|
|
7
|
+
import { SCHEMA, IMPLEMENTATION_TYPES, EXECUTION_TARGETS, LIMITS, invalid, words, normalize, validateResearch } from "./evidence.mjs";
|
|
8
|
+
|
|
9
|
+
/** Field sets of schemas/algorithm-candidates.v1.json; a test keeps the two in step. */
|
|
10
|
+
export const CANDIDATE_FIELDS = Object.freeze(["reaction_id", "implementation_ref", "implementation_version", "implementation_type", "execution_target", "library_entry_id", "summary", "needs", "quotes"]);
|
|
11
|
+
export const NEEDS_FIELDS = Object.freeze(["compute", "memory", "storage", "runtime", "network", "interfaces", "sensors"]);
|
|
12
|
+
export const CANDIDATE_LIMITS = Object.freeze({ refChars: 80, versionChars: 60, interfaceChars: 120, issues: 8 });
|
|
13
|
+
const FLOATING = /^(latest|main|master|head|unknown|unversioned|stable|\*)$/i;
|
|
14
|
+
const LINK = /https?:\/\/|www\./i;
|
|
15
|
+
|
|
16
|
+
/** A stable name the binding and the PRD can repeat as is; where it came from and what it does belong in quotes and summary. */
|
|
17
|
+
function implementationRef(value, label) {
|
|
18
|
+
if (!text(value) || [...value.trim()].length > CANDIDATE_LIMITS.refChars || LINK.test(value) || /[\r\n]/.test(value))
|
|
19
|
+
invalid(`${label} must be a short name (1–${CANDIDATE_LIMITS.refChars} chars, no URL or explanation; put details in summary)`);
|
|
20
|
+
return value.trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The version the source itself states, or null when it states none; a floating label is not a version. */
|
|
24
|
+
function implementationVersion(value, label) {
|
|
25
|
+
if (value === undefined || value === null) return null;
|
|
26
|
+
if (!text(value) || [...value.trim()].length > CANDIDATE_LIMITS.versionChars || LINK.test(value) || FLOATING.test(value.trim()))
|
|
27
|
+
invalid(`${label} must be the definite version the source states (≤${CANDIDATE_LIMITS.versionChars} chars, not latest/main), or null`);
|
|
28
|
+
return value.trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function onlyKeys(value, keys, label) { if (!record(value) || Object.keys(value).some(key => !keys.includes(key))) invalid(`${label} has unsupported fields`); }
|
|
32
|
+
|
|
33
|
+
/** What the implementation needs from hardware, in the source's own words; unknown stays null, never guessed. */
|
|
34
|
+
function readNeeds(needs, label) {
|
|
35
|
+
onlyKeys(needs, NEEDS_FIELDS, label);
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const key of ["compute", "memory", "storage", "runtime", "network"]) {
|
|
38
|
+
const value = needs[key];
|
|
39
|
+
if (value !== undefined && value !== null && (!text(value) || [...value].length > LIMITS.summaryChars)) invalid(`${label}.${key} must be null or 1–${LIMITS.summaryChars} chars`);
|
|
40
|
+
out[key] = value === undefined || value === null ? null : value.trim();
|
|
41
|
+
}
|
|
42
|
+
for (const key of ["interfaces", "sensors"]) {
|
|
43
|
+
const value = needs[key] ?? [];
|
|
44
|
+
if (!Array.isArray(value) || value.length > 20 || value.some(item => !text(item) || [...item].length > CANDIDATE_LIMITS.interfaceChars)) invalid(`${label}.${key} must list up to 20 strings of 1–${CANDIDATE_LIMITS.interfaceChars} chars`);
|
|
45
|
+
out[key] = value.map(item => item.trim());
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function proposeAlgorithmCandidates({ input, readResource, readBlob }) {
|
|
51
|
+
onlyKeys(input, ["research_id", "candidates", "unresolved", "unknowns"], "input");
|
|
52
|
+
if (typeof readResource !== "function" || typeof readBlob !== "function") throw Object.assign(new Error("resource and content readers are required"), { code: "ALGORITHM_HOST" });
|
|
53
|
+
if (!text(input.research_id) || !input.research_id.startsWith("algorithm-research:")) invalid("research_id must be an algorithm-research resource ID");
|
|
54
|
+
const resource = await readResource(input.research_id);
|
|
55
|
+
if (!resource || resource.kind !== "algorithm-research") invalid("research_id does not name saved algorithm research");
|
|
56
|
+
const research = validateResearch(resource.body);
|
|
57
|
+
if (research.status !== "collected" || research.purpose !== "implementation") invalid("candidates are proposed from collected implementation research");
|
|
58
|
+
const reactionIds = new Set(research.reactions.map(item => item.id));
|
|
59
|
+
const libraryIds = new Set((research.library ?? []).map(item => item.entry_id));
|
|
60
|
+
const pages = new Map(research.pages.filter(page => page.status === "read").map(page => [page.id, page]));
|
|
61
|
+
const texts = new Map();
|
|
62
|
+
const pageText = pageId => {
|
|
63
|
+
if (!texts.has(pageId)) texts.set(pageId, normalize(String(readBlob(pages.get(pageId).text_blob_ref)?.text ?? "")));
|
|
64
|
+
return texts.get(pageId);
|
|
65
|
+
};
|
|
66
|
+
const candidates = Array.isArray(input.candidates) ? input.candidates : invalid("candidates must be an array");
|
|
67
|
+
if (candidates.length > LIMITS.candidates) invalid(`candidates must list up to ${LIMITS.candidates} entries`);
|
|
68
|
+
const covered = new Set(), issues = [];
|
|
69
|
+
// Each candidate reports its first problem; all of them come back together in one VALIDATION.
|
|
70
|
+
const collect = (fn, at) => {
|
|
71
|
+
try { return fn(); } catch (error) { if (error?.code !== "VALIDATION") throw error; issues.push(error.message.startsWith(at) ? error.message : `${at}: ${error.message}`); return null; }
|
|
72
|
+
};
|
|
73
|
+
const accepted = candidates.map((candidate, index) => collect(() => {
|
|
74
|
+
const at = `candidates[${index}]`;
|
|
75
|
+
onlyKeys(candidate, CANDIDATE_FIELDS, at);
|
|
76
|
+
if (!reactionIds.has(candidate.reaction_id)) invalid(`${at}.reaction_id must name a researched reaction`);
|
|
77
|
+
if (!IMPLEMENTATION_TYPES.includes(candidate.implementation_type)) invalid(`${at}.implementation_type must be one of ${IMPLEMENTATION_TYPES.join(", ")}`);
|
|
78
|
+
if (!EXECUTION_TARGETS.includes(candidate.execution_target)) invalid(`${at}.execution_target must be one of ${EXECUTION_TARGETS.join(", ")}`);
|
|
79
|
+
if (candidate.library_entry_id !== undefined && candidate.library_entry_id !== null && !libraryIds.has(candidate.library_entry_id)) invalid(`${at}.library_entry_id must name an in-library algorithm given to the research`);
|
|
80
|
+
const quotes = Array.isArray(candidate.quotes) ? candidate.quotes : [];
|
|
81
|
+
// An in-library algorithm may stand on the library entry alone; anything else needs source quotes.
|
|
82
|
+
if ((!quotes.length && !candidate.library_entry_id) || quotes.length > LIMITS.quotes) invalid(`${at}.quotes must list 1–${LIMITS.quotes} source quotes unless library_entry_id is given`);
|
|
83
|
+
const checked = quotes.map((quote, q) => {
|
|
84
|
+
const where = `${at}.quotes[${q}]`;
|
|
85
|
+
onlyKeys(quote, ["page_id", "text"], where);
|
|
86
|
+
const page = pages.get(quote.page_id);
|
|
87
|
+
if (!page) invalid(`${where}.page_id must name a read page of this research`);
|
|
88
|
+
const said = words(quote.text, `${where}.text`, LIMITS.quoteChars);
|
|
89
|
+
if ([...normalize(said)].length < LIMITS.quoteMinChars) invalid(`${where}.text must be at least ${LIMITS.quoteMinChars} characters`);
|
|
90
|
+
if (!pageText(quote.page_id).includes(normalize(said))) invalid(`${where}.text must appear verbatim in page ${quote.page_id}`);
|
|
91
|
+
return { page_id: page.id, url: page.final_url ?? page.url, text: said, text_source: page.text_source };
|
|
92
|
+
});
|
|
93
|
+
const out = { reaction_id: candidate.reaction_id, implementation_ref: implementationRef(candidate.implementation_ref, `${at}.implementation_ref`),
|
|
94
|
+
implementation_version: implementationVersion(candidate.implementation_version, `${at}.implementation_version`),
|
|
95
|
+
implementation_type: candidate.implementation_type, execution_target: candidate.execution_target, library_entry_id: candidate.library_entry_id ?? null,
|
|
96
|
+
summary: words(candidate.summary, `${at}.summary`, LIMITS.summaryChars), needs: readNeeds(candidate.needs ?? {}, `${at}.needs`), quotes: checked,
|
|
97
|
+
evidence: checked.some(quote => quote.text_source === "page") ? "page" : candidate.library_entry_id ? "library" : "search_snippet" };
|
|
98
|
+
covered.add(candidate.reaction_id);
|
|
99
|
+
return out;
|
|
100
|
+
}, `candidates[${index}]`));
|
|
101
|
+
const unresolved = Array.isArray(input.unresolved) ? input.unresolved : [];
|
|
102
|
+
const unresolvedIds = new Set();
|
|
103
|
+
const gaps = unresolved.map((item, index) => collect(() => {
|
|
104
|
+
onlyKeys(item, ["reaction_id", "reason"], `unresolved[${index}]`);
|
|
105
|
+
if (!reactionIds.has(item.reaction_id) || covered.has(item.reaction_id) || unresolvedIds.has(item.reaction_id)) invalid(`unresolved[${index}].reaction_id must name a researched reaction without candidates, once`);
|
|
106
|
+
unresolvedIds.add(item.reaction_id);
|
|
107
|
+
return { reaction_id: item.reaction_id, reason: words(item.reason, `unresolved[${index}].reason`, LIMITS.summaryChars) };
|
|
108
|
+
}, `unresolved[${index}]`));
|
|
109
|
+
// A reaction whose only candidate was rejected above is reported there, not again here.
|
|
110
|
+
const rejected = new Set(candidates.filter((_, index) => accepted[index] === null).map(candidate => candidate?.reaction_id));
|
|
111
|
+
for (const id of reactionIds) if (!covered.has(id) && !unresolvedIds.has(id) && !rejected.has(id)) issues.push(`reaction ${id} needs at least one candidate or an unresolved reason`);
|
|
112
|
+
if (issues.length) throw failure("VALIDATION", [...issues.slice(0, CANDIDATE_LIMITS.issues), ...(issues.length > CANDIDATE_LIMITS.issues ? [`and ${issues.length - CANDIDATE_LIMITS.issues} more`] : [])].join("; "));
|
|
113
|
+
const unknowns = Array.isArray(input.unknowns) ? input.unknowns.map((item, index) => words(item, `unknowns[${index}]`, LIMITS.summaryChars)) : [];
|
|
114
|
+
const stable = { collected_id: resource.id, candidates: accepted, unresolved: gaps, unknowns };
|
|
115
|
+
const id = `alg-prop-${digest(stable).slice(0, 16)}`;
|
|
116
|
+
return validateResearch({ id, schema: SCHEMA, status: "proposed", request_digest: research.request_digest, purpose: research.purpose, collected_id: resource.id,
|
|
117
|
+
context: research.context, reactions: research.reactions, limits: research.limits, library: research.library, hardware: null,
|
|
118
|
+
searches: [], pages: research.pages, candidates: accepted, unresolved: gaps, unknowns: [...research.unknowns, ...unknowns] });
|
|
119
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// algorithm-research v1 的字段定义与校验(P2-03 算法并行车道)。研究分两种:implementation 只凭需求检索实现
|
|
2
|
+
// (与硬件选型并行),hardware 在硬件登记后检索已选型号的厂商资料。页面正文由 Harness 自己读取并按 sha256 存证,
|
|
3
|
+
// algorithm.finalize 的 bind 操作只认这里存下的正文,不认 Agent 自述。
|
|
4
|
+
import { createPublicReader } from "../../abelien-harness/scripts/web/public-read.mjs";
|
|
5
|
+
import { createVolcSearch } from "../../abelien-harness/scripts/web/volc.mjs";
|
|
6
|
+
import { redact } from "../../abelien-harness/scripts/web/redact.mjs";
|
|
7
|
+
import { failure, text, record } from "../../abelien-harness/scripts/web/common.mjs";
|
|
8
|
+
|
|
9
|
+
export const SCHEMA = "abelien.algorithm-research.v1";
|
|
10
|
+
export const PURPOSES = Object.freeze(["implementation", "hardware"]);
|
|
11
|
+
export const STATUSES = Object.freeze(["in_progress", "collected", "proposed"]);
|
|
12
|
+
export const IMPLEMENTATION_TYPES = Object.freeze(["firmware", "library", "model", "cloud_service"]);
|
|
13
|
+
export const EXECUTION_TARGETS = Object.freeze(["edge", "cloud", "hybrid"]);
|
|
14
|
+
/** Version of the collected shape, part of every request digest: a shape change collects afresh under a new id. */
|
|
15
|
+
export const RESEARCH_SHAPE = 1;
|
|
16
|
+
export const LIMITS = Object.freeze({ queries: 8, count: 5, pages: 12, library: 50, reactions: 20, textChars: 2000, queryChars: 200,
|
|
17
|
+
candidates: 40, quotes: 6, quoteChars: 400, quoteMinChars: 8, summaryChars: 500,
|
|
18
|
+
pageBytes: 8 * 1024 * 1024, excerptChars: 1200, pageTimeoutMs: 25000, pageConnectTimeoutMs: 8000, pageRoundMs: 90000, searchTimeoutMs: 30000, concurrency: 4 });
|
|
19
|
+
|
|
20
|
+
export const CODES = Object.freeze({ transport: "ALGORITHM_WEB_TRANSPORT", dns: "ALGORITHM_WEB_DNS", access: "ALGORITHM_WEB_URL", redirect: "ALGORITHM_WEB_PAGE",
|
|
21
|
+
timeout: "ALGORITHM_WEB_TIMEOUT", url: "ALGORITHM_WEB_URL", config: "ALGORITHM_WEB_CONFIG", readTimeout: "ALGORITHM_PUBLIC_READ_TIMEOUT",
|
|
22
|
+
capability: "ALGORITHM_WEB_CAPABILITY", provider: "ALGORITHM_WEB_PROVIDER", auth: "ALGORITHM_WEB_AUTH", rateLimit: "ALGORITHM_WEB_RATE_LIMIT", response: "ALGORITHM_WEB_RESPONSE" });
|
|
23
|
+
|
|
24
|
+
export const reader = createPublicReader({ codes: CODES, userAgent: "Abelien-Algorithm-Evidence/1", accept: "text/html,text/plain,text/markdown,application/xhtml+xml", decodeCharset: true });
|
|
25
|
+
export const volc = createVolcSearch({ codes: CODES, ownsError: error => Boolean(error?.code?.startsWith("ALGORITHM_WEB_")),
|
|
26
|
+
redact: (raw, env = {}) => redact(raw, [env.WEB_SEARCH_API_KEY].filter(text)), timeoutMessage: "algorithm network request timed out" });
|
|
27
|
+
|
|
28
|
+
export const invalid = message => { throw failure("VALIDATION", message); };
|
|
29
|
+
export const words = (value, label, max = LIMITS.textChars) => {
|
|
30
|
+
if (!text(value) || [...value].length > max) invalid(`${label} must be 1–${max} chars`);
|
|
31
|
+
return value.trim();
|
|
32
|
+
};
|
|
33
|
+
// NFKC folds full-width forms before whitespace collapses, as reference.finalize does for its quotes.
|
|
34
|
+
export const normalize = value => value.normalize("NFKC").replace(/\s+/g, " ").trim();
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The research reads clarify's requirements as they are: reaction[] {id, text} and limits[] with strength; only the
|
|
38
|
+
* fields the research needs are kept, so the request digest does not move with unrelated requirement edits.
|
|
39
|
+
*/
|
|
40
|
+
export function readRequirements(requirements) {
|
|
41
|
+
if (!record(requirements)) invalid("requirements must be clarify's Requirements object");
|
|
42
|
+
const reactions = Array.isArray(requirements.reaction) ? requirements.reaction : [];
|
|
43
|
+
if (!reactions.length || reactions.length > LIMITS.reactions) invalid(`requirements.reaction must list 1–${LIMITS.reactions} reactions`);
|
|
44
|
+
const ids = new Set();
|
|
45
|
+
const out = reactions.map((reaction, index) => {
|
|
46
|
+
if (!record(reaction) || !text(reaction.id) || ids.has(reaction.id)) invalid(`requirements.reaction[${index}] needs a unique id`);
|
|
47
|
+
ids.add(reaction.id);
|
|
48
|
+
return { id: reaction.id, text: words(reaction.text, `requirements.reaction[${index}].text`) };
|
|
49
|
+
});
|
|
50
|
+
const limits = Array.isArray(requirements.limits) ? requirements.limits : [];
|
|
51
|
+
const context = record(requirements.context) ? requirements.context.text ?? null : typeof requirements.context === "string" ? requirements.context : null;
|
|
52
|
+
return { context: text(context) ? context.trim() : null, reactions: out,
|
|
53
|
+
limits: limits.filter(record).map(limit => ({ id: limit.id ?? null, text: limit.text ?? null, strength: limit.strength ?? null })) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function validateResearch(body) {
|
|
57
|
+
if (!record(body) || body.schema !== SCHEMA || !STATUSES.includes(body.status) || !text(body.id) || !PURPOSES.includes(body.purpose)) invalid("algorithm research has an invalid schema, id, status or purpose");
|
|
58
|
+
for (const key of ["reactions", "searches", "pages", "unknowns"]) if (!Array.isArray(body[key])) invalid(`algorithm research ${key} must be an array`);
|
|
59
|
+
for (const [index, page] of body.pages.entries()) {
|
|
60
|
+
const at = `pages[${index}]`;
|
|
61
|
+
if (!record(page) || page.id !== `p${index + 1}` || !text(page.url) || !["read", "unavailable"].includes(page.status)) invalid(`${at} has an invalid id, url or status`);
|
|
62
|
+
if (page.status === "read" && !(/^sha256:[0-9a-f]{64}$/.test(page.text_blob_ref ?? "") && /^[0-9a-f]{64}$/.test(page.text_sha256 ?? "") && ["page", "search_snippet"].includes(page.text_source)))
|
|
63
|
+
invalid(`${at} is read but lacks its saved text, hash or text_source`);
|
|
64
|
+
}
|
|
65
|
+
if (body.status === "proposed") {
|
|
66
|
+
if (!text(body.collected_id) || !Array.isArray(body.candidates) || !Array.isArray(body.unresolved)) invalid("proposed research needs collected_id, candidates and unresolved");
|
|
67
|
+
}
|
|
68
|
+
return body;
|
|
69
|
+
}
|
|
@@ -27,22 +27,75 @@ function array(value, name) {
|
|
|
27
27
|
return value;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
/** Field sets of schemas/algorithm-bindings.v1.json (what the Agent submits as `research` for operation=bind); a test keeps them in step. */
|
|
31
|
+
export const PRIMARY_SOURCE_TYPES = Object.freeze(["hardware_vendor", "implementation_official", "huggingface", "cloud_vendor"]);
|
|
32
|
+
export const COMPATIBILITY_KEYS = Object.freeze(["io", "runtime", "dependencies", "network", "license", "resources"]);
|
|
33
|
+
export const CHECK_STATUSES = Object.freeze(["pass", "conflict", "unknown"]);
|
|
34
|
+
export const BIND_FIELDS = Object.freeze({
|
|
35
|
+
research: ["bindings", "sources", "unknowns", "blocking_unknowns", "unavailable_sources"],
|
|
36
|
+
binding: ["reaction_id", "implementation_ref", "implementation_version", "implementation_type", "execution_target", "hardware_component_ids", "source_ids", "compatibility", "constraint_checks"],
|
|
37
|
+
check: ["status", "summary", "source_ids"],
|
|
38
|
+
constraint: ["constraint_id", "status", "summary", "source_ids"],
|
|
39
|
+
source: ["id", "research_id", "page_id", "source_type", "title", "publisher", "version", "excerpt", "hardware_component_ids", "implementation_ref"],
|
|
40
|
+
});
|
|
41
|
+
const RECEIPT_FIELDS = ["url", "accessed_at", "content_sha256", "evidence_ref"];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* operation=bind(P2-03 算法并行车道):候选来自 algorithm.candidates 保存的 proposed 研究。来源只写 research_id 与
|
|
45
|
+
* page_id,回执字段(url、accessed_at、content_sha256、evidence_ref)由 Harness 从自己存下的研究正文填写,提交里的同名字段
|
|
46
|
+
* 一律丢弃(检索摘要不算);宿主注入的 readEvidence 仍然优先用于核对。每条绑定须选该 reaction 已提议的候选之一。
|
|
47
|
+
* 硬件基线可以尚未 verified:此时照常核对,但结果固定为 needs_confirmation,并写明原因。
|
|
48
|
+
*/
|
|
49
|
+
async function prepareBind(input, { readResource, readBlob, readEvidence }) {
|
|
50
|
+
if (!text(input.proposed_id) || !input.proposed_id.startsWith("algorithm-research:")) throw error("bind needs proposed_id of saved algorithm candidates");
|
|
51
|
+
if (typeof readBlob !== "function") throw error("bind needs the store content reader");
|
|
52
|
+
const proposed = await readResource(input.proposed_id);
|
|
53
|
+
if (!proposed || proposed.kind !== "algorithm-research" || proposed.body?.status !== "proposed" || !text(proposed.body.collected_id)) throw error("proposed_id does not name proposed algorithm candidates");
|
|
54
|
+
const allowed = async id => {
|
|
55
|
+
if (id === proposed.body.collected_id) return true;
|
|
56
|
+
const resource = await readResource(id);
|
|
57
|
+
return resource?.kind === "algorithm-research" && resource.body?.status === "collected" && resource.body.purpose === "hardware"
|
|
58
|
+
&& resource.body.hardware?.selection_id === input.hardware_selection_id && resource.body.hardware?.revision === input.hardware_selection_revision;
|
|
59
|
+
};
|
|
60
|
+
const storeEvidence = async ref => {
|
|
61
|
+
const match = /^(algorithm-research:[A-Za-z0-9][A-Za-z0-9_-]{0,127})#(p\d{1,3})$/.exec(ref ?? "");
|
|
62
|
+
if (!match || !await allowed(match[1])) return null;
|
|
63
|
+
const page = (await readResource(match[1]))?.body?.pages?.find(item => item?.id === match[2]);
|
|
64
|
+
if (!page || page.status !== "read" || page.text_source !== "page" || !text(page.fetched_at)) return null;
|
|
65
|
+
return { url: page.final_url ?? page.url, content_sha256: page.text_sha256, fetched_at: page.fetched_at, content: String(readBlob(page.text_blob_ref)?.text ?? "") };
|
|
66
|
+
};
|
|
67
|
+
/** The source as submitted, minus any receipt fields, plus the receipt of the saved page it names (none when it names no read page). */
|
|
68
|
+
const withReceipt = async source => {
|
|
69
|
+
if (!record(source)) return source;
|
|
70
|
+
const rest = Object.fromEntries(Object.entries(source).filter(([key]) => !RECEIPT_FIELDS.includes(key)));
|
|
71
|
+
const ref = text(source.research_id) && text(source.page_id) ? `${source.research_id}#${source.page_id}` : null;
|
|
72
|
+
const page = ref ? await storeEvidence(ref) : null;
|
|
73
|
+
return page ? { ...rest, url: page.url, accessed_at: page.fetched_at, content_sha256: page.content_sha256, evidence_ref: ref } : rest;
|
|
74
|
+
};
|
|
75
|
+
return { readEvidence: readEvidence ?? storeEvidence, allowUnverified: true, proposed, withReceipt };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function finalizeAlgorithmSelection({ input, readResource, readEvidence, readBlob }) {
|
|
79
|
+
if (input?.operation !== undefined && !["select", "bind"].includes(input.operation)) throw error("operation must be select or bind");
|
|
33
80
|
if (typeof readResource !== "function") throw error("store-backed hardware baseline reader is required");
|
|
81
|
+
const bind = input?.operation === "bind" ? await prepareBind(input, { readResource, readBlob, readEvidence }) : null;
|
|
82
|
+
if (bind) readEvidence = bind.readEvidence;
|
|
83
|
+
const research = bind && record(input?.research) && !Array.isArray(input.research.candidates)
|
|
84
|
+
? { ...input.research, candidates: bind.proposed.body.candidates } : input?.research;
|
|
85
|
+
if (!text(input?.hardware_selection_id) || !text(input?.hardware_selection_revision)) throw error("hardware selection id and revision are required");
|
|
34
86
|
const baseline = await readResource(input.hardware_selection_id);
|
|
35
|
-
if (!baseline || baseline.kind !== "hardware-selection" || baseline.body?.status !== "verified") throw error("hardware baseline must be an existing verified hardware-selection resource");
|
|
87
|
+
if (!baseline || baseline.kind !== "hardware-selection" || (baseline.body?.status !== "verified" && !bind?.allowUnverified)) throw error("hardware baseline must be an existing verified hardware-selection resource");
|
|
36
88
|
if (baseline.body.revision !== input.hardware_selection_revision) throw error("hardware selection revision is stale");
|
|
37
89
|
const models = baseline.body.selected_models;
|
|
38
90
|
if (!Array.isArray(models) || !models.length || models.some(item => !text(item?.component_id) || !text(item?.manufacturer) || !text(item?.model))) throw error("hardware baseline needs exact component IDs, manufacturers and models");
|
|
39
91
|
if (!record(research)) throw error("research is required");
|
|
40
92
|
const bindings = array(research.bindings, "bindings");
|
|
41
93
|
const candidates = array(research.candidates, "candidates");
|
|
42
|
-
const sources = array(research.sources, "sources");
|
|
94
|
+
const sources = bind ? await Promise.all(array(research.sources, "sources").map(bind.withReceipt)) : array(research.sources, "sources");
|
|
43
95
|
const unavailableSources = array(research.unavailable_sources, "unavailable_sources");
|
|
44
96
|
const unknowns = array(research.unknowns, "unknowns");
|
|
45
97
|
const blockingUnknowns = [...array(research.blocking_unknowns, "blocking_unknowns")];
|
|
98
|
+
if (bind && baseline.body.status !== "verified") blockingUnknowns.push(`hardware baseline is ${baseline.body.status ?? "unverified"}; the binding stays to be confirmed`);
|
|
46
99
|
const issues = [];
|
|
47
100
|
const check = (condition, path, message) => { if (!condition) issues.push({ path, message }); };
|
|
48
101
|
const hardwareIds = new Set(models.map(item => item.component_id));
|
|
@@ -69,7 +122,7 @@ export async function finalizeAlgorithmSelection({ input, readResource, readEvid
|
|
|
69
122
|
if (text(limit?.id)) constraints.add(limit.id);
|
|
70
123
|
}
|
|
71
124
|
const sourceMap = new Map();
|
|
72
|
-
const primaryTypes = new Set(
|
|
125
|
+
const primaryTypes = new Set(PRIMARY_SOURCE_TYPES);
|
|
73
126
|
let retrievalVerified = sources.length > 0;
|
|
74
127
|
for (const [index, source] of sources.entries()) {
|
|
75
128
|
const at = `sources[${index}]`;
|
|
@@ -94,6 +147,8 @@ export async function finalizeAlgorithmSelection({ input, readResource, readEvid
|
|
|
94
147
|
if (!record(binding)) continue;
|
|
95
148
|
seen.add(binding.reaction_id);
|
|
96
149
|
check(text(binding.implementation_ref) && pinned(binding.implementation_version), at, "implementation needs a stable reference and pinned version");
|
|
150
|
+
if (bind) check(values(bind.proposed.body.candidates).some(item => item?.reaction_id === binding.reaction_id && item.implementation_ref === binding.implementation_ref),
|
|
151
|
+
at, "binding must pick one of the proposed candidates of its reaction (same implementation_ref)");
|
|
97
152
|
check(["firmware", "library", "model", "cloud_service"].includes(binding.implementation_type), at, "implementation_type is invalid");
|
|
98
153
|
check(["edge", "cloud", "hybrid"].includes(binding.execution_target), at, "execution_target is invalid");
|
|
99
154
|
const ids = values(binding.hardware_component_ids);
|
|
@@ -102,7 +157,7 @@ export async function finalizeAlgorithmSelection({ input, readResource, readEvid
|
|
|
102
157
|
const cited = values(binding.source_ids).map(id => sourceMap.get(id)).filter(Boolean);
|
|
103
158
|
for (const id of ids) check(cited.some(source => source.source_type === "hardware_vendor" && values(source.hardware_component_ids).includes(id)), at, `missing exact-device manufacturer evidence: ${id}`);
|
|
104
159
|
check(cited.some(source => ["implementation_official", "huggingface", "cloud_vendor"].includes(source.source_type) && source.implementation_ref === binding.implementation_ref && source.version === binding.implementation_version), at, "missing matching implementation-version evidence");
|
|
105
|
-
for (const key of
|
|
160
|
+
for (const key of COMPATIBILITY_KEYS) {
|
|
106
161
|
const evidence = binding.compatibility?.[key];
|
|
107
162
|
check(evidence?.status === "pass" && text(evidence.summary) && sourceRefs(evidence.source_ids), `${at}.compatibility.${key}`, "compatibility requires a passing evidence-backed assessment");
|
|
108
163
|
}
|
|
@@ -127,6 +182,7 @@ export async function finalizeAlgorithmSelection({ input, readResource, readEvid
|
|
|
127
182
|
hardware_selection_revision: input.hardware_selection_revision,
|
|
128
183
|
hardware_selection_digest: baseline.meta?.digest ?? `sha256:${digest(baseline.body)}`,
|
|
129
184
|
requirements: input.requirements ?? {}, reactions, research, quality_issues: issues,
|
|
185
|
+
...(bind ? { operation: "bind", proposed_id: input.proposed_id } : {}),
|
|
130
186
|
};
|
|
131
187
|
const hash = digest(stable);
|
|
132
188
|
return {
|
|
@@ -135,6 +191,7 @@ export async function finalizeAlgorithmSelection({ input, readResource, readEvid
|
|
|
135
191
|
hardware_selection_id: input.hardware_selection_id,
|
|
136
192
|
hardware_selection_revision: input.hardware_selection_revision,
|
|
137
193
|
hardware_selection_digest: stable.hardware_selection_digest,
|
|
194
|
+
...(bind ? { operation: "bind", proposed_id: input.proposed_id } : {}),
|
|
138
195
|
source_snapshot: `research:${hash.slice(0, 16)}`,
|
|
139
196
|
revision: `r-${hash.slice(0, 12)}`,
|
|
140
197
|
bindings, candidates, sources,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// algorithm.lane-state(P2-03 算法并行车道):只读。宿主在每个自动阶段开跑前调用,按本草稿工作目录里已存的资源回答
|
|
2
|
+
// 「当前需求有没有已提议的候选」「这些候选有没有绑定到当前硬件」。需求是否相同按 algorithm.research 的同一规范化规则
|
|
3
|
+
// 判断(落点、reaction、limits),宿主不必复制这条规则;结果映射成 workspace.lanes-next 的 artifacts。
|
|
4
|
+
import { text, record, digest } from "../../abelien-harness/scripts/web/common.mjs";
|
|
5
|
+
import { readRequirements, invalid } from "./evidence.mjs";
|
|
6
|
+
|
|
7
|
+
const sameRequirements = (body, wanted) => digest({ context: body.context ?? null, reactions: body.reactions ?? [], limits: body.limits ?? [] }) === digest(wanted);
|
|
8
|
+
const newest = list => [...list].sort((a, b) => String(b.meta?.updatedAt ?? "").localeCompare(String(a.meta?.updatedAt ?? "")))[0] ?? null;
|
|
9
|
+
|
|
10
|
+
export async function algorithmLaneState({ input, listResources }) {
|
|
11
|
+
if (!record(input) || Object.keys(input).some(key => !["requirements", "hardware_selection_id", "hardware_selection_revision"].includes(key))) invalid("input accepts requirements, hardware_selection_id and hardware_selection_revision only");
|
|
12
|
+
if (typeof listResources !== "function") throw Object.assign(new Error("store listing is required"), { code: "ALGORITHM_HOST" });
|
|
13
|
+
const hasHardware = input.hardware_selection_id !== undefined && input.hardware_selection_id !== null;
|
|
14
|
+
if (hasHardware && (!text(input.hardware_selection_id) || !input.hardware_selection_id.startsWith("hardware-selection:") || !text(input.hardware_selection_revision)))
|
|
15
|
+
invalid("hardware_selection_id must be a hardware-selection resource ID with its revision");
|
|
16
|
+
const normalized = readRequirements(input.requirements);
|
|
17
|
+
const wanted = { context: normalized.context, reactions: normalized.reactions, limits: normalized.limits };
|
|
18
|
+
const proposed = newest(listResources("algorithm-research").filter(item => item.body?.status === "proposed" && sameRequirements(item.body, wanted)));
|
|
19
|
+
const selection = proposed && hasHardware ? newest(listResources("algorithm-selection").filter(item => item.body?.operation === "bind" && item.body.proposed_id === proposed.id
|
|
20
|
+
&& item.body.hardware_selection_id === input.hardware_selection_id && item.body.hardware_selection_revision === input.hardware_selection_revision)) : null;
|
|
21
|
+
return {
|
|
22
|
+
requirements_digest: digest(wanted),
|
|
23
|
+
proposed_id: proposed?.id ?? null,
|
|
24
|
+
selection_id: selection?.id ?? null,
|
|
25
|
+
selection_status: selection?.body.status ?? null,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// algorithm.research(P2-03 算法并行车道):按 Agent 的检索计划执行确定性的收集——Volc 检索与公开页面安全直读——
|
|
2
|
+
// 全部落为证据,不做选择。两种用途:implementation 只凭需求检索实现资料,可与硬件选型同时开始;hardware 在硬件
|
|
3
|
+
// 登记后检索已选型号的厂商资料,查询必须指向选型资源里的 component_id。
|
|
4
|
+
// 付费检索逐条「先存 sent 再发送」,与 reference.research 同一规则:重跑复用已完成的检索,停在 sent 而没有结果的
|
|
5
|
+
// 检索要求对账,绝不自动重发;同一请求完成后重复运行直接返回已有结果。
|
|
6
|
+
import { Buffer } from "node:buffer";
|
|
7
|
+
import { text, record, digest, sha256, failure } from "../../abelien-harness/scripts/web/common.mjs";
|
|
8
|
+
import { decodeBody } from "../../abelien-harness/scripts/web/public-read.mjs";
|
|
9
|
+
import { SCHEMA, PURPOSES, RESEARCH_SHAPE, LIMITS, reader, volc, invalid, words, readRequirements, validateResearch } from "./evidence.mjs";
|
|
10
|
+
|
|
11
|
+
const reconcile = message => failure("ALGORITHM_WEB_RECONCILIATION_REQUIRED", message);
|
|
12
|
+
|
|
13
|
+
function readLibrary(library) {
|
|
14
|
+
if (library === undefined || library === null) return [];
|
|
15
|
+
if (!Array.isArray(library) || library.length > LIMITS.library) invalid(`library must list up to ${LIMITS.library} in-library algorithms`);
|
|
16
|
+
const ids = new Set();
|
|
17
|
+
return library.map((entry, index) => {
|
|
18
|
+
if (!record(entry) || !text(entry.entry_id) || ids.has(entry.entry_id)) invalid(`library[${index}] needs a unique entry_id`);
|
|
19
|
+
ids.add(entry.entry_id);
|
|
20
|
+
return { entry_id: entry.entry_id, name: words(entry.name, `library[${index}].name`, 200), category: text(entry.category) ? entry.category.trim().slice(0, 200) : null };
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The hardware the vendor queries may name: exact models of a hardware-selection resource, verified or not. */
|
|
25
|
+
async function readHardware(input, readResource) {
|
|
26
|
+
if (!text(input.hardware_selection_id) || !text(input.hardware_selection_revision)) invalid("hardware research needs hardware_selection_id and hardware_selection_revision");
|
|
27
|
+
const resource = await readResource(input.hardware_selection_id);
|
|
28
|
+
if (!resource || resource.kind !== "hardware-selection" || resource.body?.revision !== input.hardware_selection_revision) invalid("hardware selection is missing or its revision is stale");
|
|
29
|
+
const models = Array.isArray(resource.body.selected_models) ? resource.body.selected_models : [];
|
|
30
|
+
if (!models.length || models.some(item => !text(item?.component_id) || !text(item?.manufacturer) || !text(item?.model))) invalid("hardware selection needs exact component IDs, manufacturers and models");
|
|
31
|
+
return { selection_id: input.hardware_selection_id, revision: input.hardware_selection_revision, status: resource.body.status ?? null,
|
|
32
|
+
models: models.map(item => ({ component_id: item.component_id, manufacturer: item.manufacturer, model: item.model })) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function validateResearchInput(input) {
|
|
36
|
+
if (!record(input)) invalid("input must be an object");
|
|
37
|
+
const allowed = ["purpose", "requirements", "library", "hardware_selection_id", "hardware_selection_revision", "plan"];
|
|
38
|
+
if (Object.keys(input).some(key => !allowed.includes(key))) invalid("input has unsupported fields");
|
|
39
|
+
if (!PURPOSES.includes(input.purpose)) invalid(`purpose must be one of ${PURPOSES.join(", ")}`);
|
|
40
|
+
const plan = input.plan ?? {};
|
|
41
|
+
if (!record(plan) || Object.keys(plan).some(key => !["queries", "max_pages"].includes(key))) invalid("plan has unsupported fields");
|
|
42
|
+
const queries = plan.queries ?? [];
|
|
43
|
+
if (!Array.isArray(queries) || !queries.length || queries.length > LIMITS.queries) invalid(`plan.queries must list 1–${LIMITS.queries} queries`);
|
|
44
|
+
const maxPages = plan.max_pages === undefined ? 8 : plan.max_pages;
|
|
45
|
+
if (!Number.isSafeInteger(maxPages) || maxPages < 0 || maxPages > LIMITS.pages) invalid(`plan.max_pages must be 0–${LIMITS.pages}`);
|
|
46
|
+
return { purpose: input.purpose, requirements: readRequirements(input.requirements), library: input.purpose === "implementation" ? readLibrary(input.library) : [],
|
|
47
|
+
queries, maxPages };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Interleave search results so one query cannot use the whole page budget; counts what the cap left unread. */
|
|
51
|
+
function pageQueue(searches, max) {
|
|
52
|
+
const queue = [], seen = new Set();
|
|
53
|
+
let skipped = 0;
|
|
54
|
+
for (let rank = 0; ; rank++) {
|
|
55
|
+
let added = false;
|
|
56
|
+
for (const search of searches) {
|
|
57
|
+
const result = search.results[rank];
|
|
58
|
+
if (!result) continue;
|
|
59
|
+
added = true;
|
|
60
|
+
let url;
|
|
61
|
+
try { url = reader.normalizePublicSourceUrl(result.url).href; } catch { continue; }
|
|
62
|
+
if (seen.has(url)) continue;
|
|
63
|
+
seen.add(url);
|
|
64
|
+
if (queue.length < max) queue.push({ url, search, title: result.title, snippet: result.snippet ?? "" }); else skipped++;
|
|
65
|
+
}
|
|
66
|
+
if (!added) break;
|
|
67
|
+
}
|
|
68
|
+
return Object.assign(queue, { skipped });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function pool(list, task, limit = LIMITS.concurrency) {
|
|
72
|
+
const out = new Array(list.length); let next = 0, failed = false;
|
|
73
|
+
await Promise.all(Array.from({ length: Math.min(limit, list.length) }, async () => {
|
|
74
|
+
while (!failed && next < list.length) {
|
|
75
|
+
const index = next++;
|
|
76
|
+
try { out[index] = await task(list[index], index); } catch (error) { failed = true; throw error; }
|
|
77
|
+
}
|
|
78
|
+
}));
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function researchAlgorithms({ input, env = {}, fetchImpl, readResource, writeBlob, ports = {}, checkpoint }) {
|
|
83
|
+
const request = validateResearchInput(input);
|
|
84
|
+
if (typeof writeBlob !== "function" || typeof readResource !== "function") throw failure("ALGORITHM_HOST", "trusted content writer and resource reader are required");
|
|
85
|
+
const hardware = request.purpose === "hardware" ? await readHardware(input, readResource) : null;
|
|
86
|
+
const reactionIds = new Set(request.requirements.reactions.map(item => item.id));
|
|
87
|
+
const componentIds = new Set(hardware?.models.map(item => item.component_id) ?? []);
|
|
88
|
+
const queries = request.queries.map((q, index) => {
|
|
89
|
+
const at = `plan.queries[${index}]`;
|
|
90
|
+
if (!record(q) || Object.keys(q).some(key => !["reaction_id", "component_id", "query", "count"].includes(key))) invalid(`${at} has unsupported fields`);
|
|
91
|
+
const query = words(q.query, `${at}.query`, LIMITS.queryChars);
|
|
92
|
+
if (q.count !== undefined && (!Number.isSafeInteger(q.count) || q.count < 1 || q.count > LIMITS.count)) invalid(`${at}.count must be 1–${LIMITS.count}`);
|
|
93
|
+
// Implementation queries belong to one reaction; vendor queries to one selected model.
|
|
94
|
+
if (request.purpose === "implementation" && (!reactionIds.has(q.reaction_id) || q.component_id !== undefined)) invalid(`${at} must name one requirements reaction_id and no component_id`);
|
|
95
|
+
if (request.purpose === "hardware" && (!componentIds.has(q.component_id) || (q.reaction_id !== undefined && !reactionIds.has(q.reaction_id)))) invalid(`${at} must name a selected component_id`);
|
|
96
|
+
return { reaction_id: q.reaction_id ?? null, component_id: q.component_id ?? null, query, count: q.count ?? LIMITS.count };
|
|
97
|
+
});
|
|
98
|
+
const requestDigest = digest({ purpose: request.purpose, requirements: request.requirements, library: request.library, hardware, queries, max_pages: request.maxPages, shape: RESEARCH_SHAPE });
|
|
99
|
+
const id = `alg-res-${requestDigest.slice(0, 16)}`;
|
|
100
|
+
const existing = await readResource(`algorithm-research:${id}`);
|
|
101
|
+
if (existing?.body?.status === "collected" && existing.body.request_digest === requestDigest) {
|
|
102
|
+
try { return validateResearch(existing.body); } catch { throw reconcile("saved research fails the current contract; reconcile it instead of re-running the paid searches"); }
|
|
103
|
+
}
|
|
104
|
+
const sameQuery = (entry, q, index) => record(entry) && entry.id === `s${index + 1}` && entry.query === q.query && entry.count === q.count
|
|
105
|
+
&& entry.reaction_id === q.reaction_id && entry.component_id === q.component_id;
|
|
106
|
+
if (existing && (existing.body?.status !== "in_progress" || existing.body.request_digest !== requestDigest || !Array.isArray(existing.body.searches)
|
|
107
|
+
|| existing.body.searches.length > queries.length || existing.body.searches.some((entry, index) => !sameQuery(entry, queries[index], index))))
|
|
108
|
+
throw reconcile("saved research does not match this request; reconcile the original invocation instead of re-running it");
|
|
109
|
+
// Fail before anything is marked sent: a missing or malformed key cannot have produced a charge.
|
|
110
|
+
if (!text(env.WEB_SEARCH_API_KEY) || /[\r\n\0]/.test(env.WEB_SEARCH_API_KEY)) throw failure("ALGORITHM_WEB_CONFIG", "WEB_SEARCH_API_KEY is required from the trusted host");
|
|
111
|
+
if (typeof checkpoint !== "function") throw failure("ALGORITHM_WEB_CHECKPOINT_REQUIRED", "paid search IO requires the original resource store checkpoint");
|
|
112
|
+
let expectedDigest = existing?.meta?.digest ?? null;
|
|
113
|
+
const save = async body => { expectedDigest = (await checkpoint(body, { expectedDigest })).meta.digest; };
|
|
114
|
+
|
|
115
|
+
const unknowns = [];
|
|
116
|
+
const base = { id, schema: SCHEMA, request_digest: requestDigest, purpose: request.purpose, context: request.requirements.context,
|
|
117
|
+
reactions: request.requirements.reactions, limits: request.requirements.limits, library: request.library, hardware };
|
|
118
|
+
const progress = { ...base, status: "in_progress", searches: structuredClone(existing?.body.searches ?? []), pages: [], unknowns: [] };
|
|
119
|
+
const searches = progress.searches;
|
|
120
|
+
const keepReceipt = (entry, saved) => {
|
|
121
|
+
if (!saved || typeof saved.raw !== "string") return;
|
|
122
|
+
if (saved.raw.includes(env.WEB_SEARCH_API_KEY)) throw failure("ALGORITHM_WEB_RESPONSE", "search receipt still contains the key after redaction");
|
|
123
|
+
Object.assign(entry, { request_id: saved.request_id ?? null, receipt_sha256: saved.raw_sha256, receipt_blob_ref: writeBlob({ text: saved.raw }), response_sha256: saved.response_sha256 ?? null, http_status: saved.http_status ?? null, received_at: saved.received_at ?? null });
|
|
124
|
+
};
|
|
125
|
+
for (const [index, q] of queries.entries()) {
|
|
126
|
+
let entry = searches[index];
|
|
127
|
+
if (entry && ["completed", "failed"].includes(entry.status)) {
|
|
128
|
+
if (entry.status === "completed" && !entry.results.length) unknowns.push(`检索「${q.query}」没有结果`);
|
|
129
|
+
if (entry.status === "failed") unknowns.push(`检索「${q.query}」失败(${entry.error_code})`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (entry?.dispatch_state === "sent") throw reconcile(`search ${entry.id} was sent but its outcome was never saved; reconcile it with the provider, it is not resent`);
|
|
133
|
+
const body = volc.volcQueryRequest({ query: q.query, count: q.count });
|
|
134
|
+
volc.validateVolcCapability(body);
|
|
135
|
+
entry = { id: `s${index + 1}`, reaction_id: q.reaction_id, component_id: q.component_id, query: q.query, count: q.count, request: body, dispatch_state: "sent",
|
|
136
|
+
sent_at: new Date().toISOString(), status: "in_progress", error_code: null, outcome: null, request_id: null, receipt_sha256: null, receipt_blob_ref: null, results: [] };
|
|
137
|
+
searches[index] = entry;
|
|
138
|
+
await save(progress);
|
|
139
|
+
try {
|
|
140
|
+
const saved = await volc.volcWebSearch(body, { env, fetchImpl, timeoutMs: LIMITS.searchTimeoutMs });
|
|
141
|
+
keepReceipt(entry, saved);
|
|
142
|
+
entry.status = "completed"; entry.outcome = "received";
|
|
143
|
+
entry.results = (saved.response.Result.WebResults ?? []).filter(r => text(r?.Url)).map((r, rank) => ({ rank: rank + 1, url: r.Url, title: String(r.Title ?? ""), snippet: String(r.Snippet ?? r.Summary ?? "").slice(0, 300) }));
|
|
144
|
+
if (!entry.results.length) unknowns.push(`检索「${q.query}」没有结果`);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (!String(error?.code ?? "").startsWith("ALGORITHM_WEB_")) throw error;
|
|
147
|
+
keepReceipt(entry, error.providerEvidence);
|
|
148
|
+
entry.status = "failed"; entry.error_code = error.code; entry.outcome = error.ambiguous || !error.providerEvidence ? "unknown" : "rejected";
|
|
149
|
+
unknowns.push(`检索「${q.query}」失败(${entry.error_code})`);
|
|
150
|
+
}
|
|
151
|
+
await save(progress);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const readOptions = { lookup: ports.lookup, pageRequest: ports.pageRequest, dnsRequest: ports.dnsRequest };
|
|
155
|
+
const queue = pageQueue(searches, request.maxPages);
|
|
156
|
+
const now = ports.now ?? Date.now, roundEnds = now() + LIMITS.pageRoundMs;
|
|
157
|
+
const readPage = async url => {
|
|
158
|
+
for (let attempt = 0; ; attempt++) {
|
|
159
|
+
const left = roundEnds - now();
|
|
160
|
+
if (left < LIMITS.pageConnectTimeoutMs) throw Object.assign(new Error("page round budget spent"), { code: "round_budget" });
|
|
161
|
+
try {
|
|
162
|
+
return await reader.fetchPublicPage(url, { ...readOptions, timeoutMs: Math.min(LIMITS.pageTimeoutMs, left), connectTimeoutMs: LIMITS.pageConnectTimeoutMs, maxBytes: LIMITS.pageBytes });
|
|
163
|
+
} catch (error) {
|
|
164
|
+
const stage = reader.publicReadFailure(error)?.stage;
|
|
165
|
+
if (attempt || !["timeout", "transport"].includes(stage)) throw error;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
const pages = await pool(queue, async (entry, index) => {
|
|
170
|
+
const page = { id: `p${index + 1}`, url: entry.url, final_url: null, search_id: entry.search.id, reaction_id: entry.search.reaction_id, component_id: entry.search.component_id,
|
|
171
|
+
status: "unavailable", reason: null, title: entry.title ?? "", fetched_at: null, text_blob_ref: null, text_sha256: null, text_source: null, excerpt: "" };
|
|
172
|
+
try {
|
|
173
|
+
const read = await readPage(entry.url);
|
|
174
|
+
Object.assign(page, { status: read.status, reason: read.reason, final_url: read.final_url, fetched_at: read.fetched_at ?? null });
|
|
175
|
+
if (read.status === "read") {
|
|
176
|
+
const title = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(decodeBody(Buffer.from(read.raw_base64, "base64"), read.content_type))?.[1];
|
|
177
|
+
if (text(title)) page.title = title.replace(/\s+/g, " ").trim().slice(0, 200);
|
|
178
|
+
Object.assign(page, { text_blob_ref: writeBlob({ text: read.text }), text_sha256: read.text_sha256, text_source: "page", excerpt: read.text.slice(0, LIMITS.excerptChars) });
|
|
179
|
+
}
|
|
180
|
+
} catch (error) {
|
|
181
|
+
page.reason = reader.publicReadFailure(error)?.stage ?? error?.code ?? "read_failed";
|
|
182
|
+
if (!reader.publicReadFailure(error) && error?.code !== "round_budget" && !String(error?.code ?? "").startsWith("ALGORITHM_WEB_")) throw error;
|
|
183
|
+
}
|
|
184
|
+
// An unreadable page still has the search engine's summary for discovery, marked as such; finalize never accepts it as evidence.
|
|
185
|
+
const snippet = String(entry.snippet ?? "").trim();
|
|
186
|
+
if (page.status !== "read" && snippet) {
|
|
187
|
+
Object.assign(page, { status: "read", text_blob_ref: writeBlob({ text: snippet }), text_sha256: sha256(snippet), text_source: "search_snippet", excerpt: snippet.slice(0, LIMITS.excerptChars) });
|
|
188
|
+
}
|
|
189
|
+
return page;
|
|
190
|
+
});
|
|
191
|
+
for (const reaction of request.requirements.reactions) {
|
|
192
|
+
if (request.purpose === "implementation" && !pages.some(page => page.reaction_id === reaction.id && page.text_source === "page"))
|
|
193
|
+
unknowns.push(`reaction ${reaction.id} 没有读到实现资料正文`);
|
|
194
|
+
}
|
|
195
|
+
for (const model of hardware?.models ?? []) {
|
|
196
|
+
if (queries.some(q => q.component_id === model.component_id) && !pages.some(page => page.component_id === model.component_id && page.text_source === "page"))
|
|
197
|
+
unknowns.push(`${model.manufacturer} ${model.model} 没有读到厂商资料正文`);
|
|
198
|
+
}
|
|
199
|
+
const collected = validateResearch({ ...base, status: "collected", searches, pages, unknowns,
|
|
200
|
+
budget: { queries: searches.length, pages: pages.length, skipped_pages: queue.skipped } });
|
|
201
|
+
await save(collected);
|
|
202
|
+
return collected;
|
|
203
|
+
}
|
|
@@ -20,7 +20,7 @@ compatibility:
|
|
|
20
20
|
2. **取得当前上下文。** 按 workspace 的[领域模型](../abelien-workspace/references/domain-model.md)及[调用与恢复说明](../abelien-workspace/references/harness-binding.md)恢复作品、事件、任务和证据。已有运行先恢复,读取失败交回具体缺口。
|
|
21
21
|
3. **按需调用 clarify。** 新增、修改、撤回需求或影响需求的答复需要整理时,读取其入口,提供同版原话、当前完整需求、本次操作、相关方案、授权与会话约束;答卡附原题和真实答复事件;工具结果中的用户原文仍按 user 来源处理,只采明确子集,不用生成的聊天 message 冒充回答事件。纯确认或仅工具回传不机械重跑 clarify。
|
|
22
22
|
4. **承接需求建议。** clarify 返回 `result / questions / note`;按其契约复核语义与结构,再按 workspace 的采用规则交给实际服务。没有 result 时沿用当前需求。需求变更按 workspace 的 [PRD 契约](../abelien-workspace/references/prd-contract.md)在本轮登记/修改/撤回条目并回读,不等最终确认;只调度该契约,不在 guide 复制模板或登记逻辑。必要问题结合当前上下文呈现,不把问题提出或用户回答当作方案已修改。
|
|
23
|
-
5. **选择下一步。** 依据 workspace 的[完整旅程](../abelien-workspace/references/user-journey.md)
|
|
23
|
+
5. **选择下一步。** 依据 workspace 的[完整旅程](../abelien-workspace/references/user-journey.md)核对推进条件。A1 通过之后到交付首版这一段,下一阶段与该阶段的规则、允许动作一律取 `workspace.journey-next` 的返回,不凭记忆或旅程摘要自排阶段;再按能力绑定选择领域 skill、领域 workflow 或产品服务。目标旅程先复用符合需求的真实 CAD;没有现成 CAD 时按阶段计划生成概念图并自动选型、绑定原图、构建 CAD/审查;实际安装版本不支持时报告能力缺口,不调用未注册工具。必要问题只调用宿主已注册的 `publish_agent_card(kind=question)`,持久绑定原模型调用后结束执行,用户独立提交后以原调用的工具结果承接原任务;不等待 HTTP/Worker、不先把 card_id 返回为最终答案、不继续执行依赖答案的工具批次。可选交互邀请遵守宿主实际展示门(核对实际版本的 3D 加载与适用审查),不得因需求足够就抢先覆盖必要问题卡;按旅程 §4.1 的通用目标告知随时可收尾,不插入需求版 PRD 展示关卡。点击“随时收尾”后按 §4.2 调用宿主成果体验模式,核对仿真或 Three.js 行为演示能力,并保留 PRD 查看/导出入口。调用前读对应 skill;能力不存在时报告具体缺口。实际能力返回后回到当前事实重判,不自行选择型号、算法或编造工程结论。
|
|
24
24
|
6. **向用户交代结果。** 根据真实产物说明本次变化、当前缺口和下一步,区分 PRD 已登记的需求与仍待实现/检查的方案;没有写入回执就明确尚未保存。首版展示、必要取舍及确认邀请的时机均采用 workspace 规则;表达方式采用共用表达规则。
|
|
25
25
|
|
|
26
26
|
图片、链接或“做同款”仍走同一入口,不自动开启另一条整机还原流程。入口按本轮意图调度:还原现成产品(restore)、创造新产品(create)或基于现成产品修改(modify)共用同一旅程,差别只在首先展示什么、参考资料怎么用、哪些特征锁定,依据是原话而不是输入形式,详见[意图路由](../../docs/architecture/intent-routing.md)。还原与改现成先识别产品,展示原产品图及来源后继续;新创先参考相似产品再出概念图,用户已有明确设计图时直接沿用。意图、目标深度与识别结果在首版之前不单独发问:按推断继续,作为系统假设展示并可随时修改;型号识别冲突时保留候选,用户切换即视为需求修订。改轮廓、颜色、材质、表面语言或参考匹配时调用 [appearance](../abelien-appearance/SKILL.md);空间约束、布局、装配、Build123d 或几何检查调用 [structure](../abelien-structure/SKILL.md)。外壳形状同时涉及两者时,由 appearance 定义目标、structure 实现并回传实际渲染、appearance 做视觉验收。只把必须由用户决定的差异交回用户,不让内部视觉审查成为额外的必答关卡。
|
|
@@ -41,6 +41,6 @@ compatibility:
|
|
|
41
41
|
|
|
42
42
|
原生答卡按[问题工具接续契约](../../docs/architecture/question-card.md)消费:选项 ID 和自由原文走原 toolCallId 的 tool 结果,不能包装成新 User 消息。旧卡缺可信原调用时只走明确旧协议或说明缺口;不伪造调用,不把答卡、暂停追问或物理接续 run 当作品确认。可选交互邀请沿用非阻塞接口,不使用必要问题的等待机制。
|
|
43
43
|
|
|
44
|
-
workflow 的恢复、advance、cancel
|
|
44
|
+
领域 workflow 的恢复、advance、cancel 按 workspace 的运行约定执行;作品推进与确认后的交接由宿主回执决定,不推进任何 workflow。guide 只消费真实回执,不把工具成功、需求建议或自己的总结当成作品已采用、已确认或已交付。
|
|
45
45
|
|
|
46
46
|
用户看到的是方案、变化、问题和产物;内部阶段编号、HTTL、cursor 与资源 ID 留在工作上下文。
|
|
@@ -32,8 +32,7 @@ create 输入为 `schemaVersion:1, creationOperationId, inputRevision:0, manifes
|
|
|
32
32
|
`args` 支持 `{{expr}}` 插值
|
|
33
33
|
- `executor: "agent"`:`instruction` 必填;运行到此暂停(`awaiting_agent`),
|
|
34
34
|
instruction 随实例返回给 agent
|
|
35
|
-
-
|
|
36
|
-
- `main(argv,{verifyCompletion})`、底层 workflow deps 接受该回调。内核不解释业务 guard 名称。当前 user-journey 声明 `workspace.confirmed-snapshot`;产品检查/同版确认/快照服务未接时禁止 done。未声明 guard 的一般工作流行为不变。
|
|
35
|
+
- **范围:** 一条 workflow 只承载单一领域内的多步流程——`capability.run` 步骤只调用同一个能力命名空间(如全是 `hardware.*`),不跨领域串接。作品推进、采用、确认与交付由宿主持有,不写成 workflow;`completionGuard` 已移除,定义里出现即 `WORKFLOW_INVALID`。随包定义由 `test/workflow-scope.test.mjs` 把守。
|
|
37
36
|
|
|
38
37
|
## 运行实例(`.abelien/workflows/<runId>.json`)
|
|
39
38
|
|
|
@@ -188,7 +187,6 @@ abelien-harness receipt get --wait <waitId> --digest <resultDigest> --workdir <
|
|
|
188
187
|
| `WORKFLOW_INVALID` | 步骤定义非法(id 重复、action/instruction 缺失等) |
|
|
189
188
|
| `WORKFLOW_RUN_NOT_FOUND` | runId 不存在 |
|
|
190
189
|
| `WORKFLOW_STATE` | 状态机非法转移(如 cancel 已终态的 run) |
|
|
191
|
-
| `WORKFLOW_COMPLETION_REQUIRED` | 缺可信宿主完成校验或回读未通过;保持暂停 |
|
|
192
190
|
| `WORKFLOW_DRIFTED` | 历史步骤形状与现行定义不同;只读恢复 |
|
|
193
191
|
| `VALIDATION` | 必填 input 缺失 |
|
|
194
192
|
| `RECEIPT_INVALID` / `RECEIPT_SIZE` | envelope 不符合 v1/超过大小边界 |
|