@arcfold/dsh 0.0.0-stage → 0.4.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 +21 -0
- package/README.md +8 -2
- package/THIRD_PARTY_NOTICES.md +11 -0
- package/cordis.patch.yml +138 -0
- package/package.json +70 -4
- package/patches/deepseek-0.2.2-rc.1-transport.json +65 -0
- package/patches/deepseek-0.2.2-rc.1-transport.patch +61 -0
- package/presets/arcfold.patch.yml +110 -0
- package/runtime/README.md +19 -0
- package/runtime/bootstrap.json +15 -0
- package/runtime/desktop-python.tar.gz +0 -0
- package/runtime/host/package.json +11 -0
- package/runtime/host/pnpm-workspace.yaml +9 -0
- package/runtime/initial-policy.json +94 -0
- package/runtime/profile/pnpm-workspace.yaml +7 -0
- package/scripts/exact-package-patch.mjs +106 -0
- package/scripts/patch-deepseek-transport.mjs +14 -0
- package/src/agent.mjs +56 -0
- package/src/browse-scope.mjs +14 -0
- package/src/budget.mjs +369 -0
- package/src/business.mjs +334 -0
- package/src/context-material.mjs +119 -0
- package/src/deepseek-budget-plugin.mjs +58 -0
- package/src/deepseek-provider.mjs +57 -0
- package/src/deepseek-receipt.mjs +97 -0
- package/src/deepseek-transport.mjs +97 -0
- package/src/desktop/adapter.mjs +16 -0
- package/src/desktop/bootstrap.mjs +84 -0
- package/src/desktop/configuration.mjs +117 -0
- package/src/desktop/provider.mjs +46 -0
- package/src/desktop/python-runtime.mjs +105 -0
- package/src/desktop/setup-service.mjs +81 -0
- package/src/desktop/vendor/LICENSE.deepseek +21 -0
- package/src/desktop/vendor/NOTICE.md +5 -0
- package/src/desktop/vendor/deepseek-0.2.2-rc.1.mjs +2919 -0
- package/src/evidence-ownership.mjs +51 -0
- package/src/evidence.mjs +35 -0
- package/src/goal-context.mjs +52 -0
- package/src/goal-execution-tool.mjs +74 -0
- package/src/goal-execution.mjs +333 -0
- package/src/goal-tool.mjs +56 -0
- package/src/goal-work-control.mjs +63 -0
- package/src/goal-work.mjs +257 -0
- package/src/goals.mjs +578 -0
- package/src/importer.mjs +298 -0
- package/src/index.mjs +53 -0
- package/src/learning-contracts.mjs +220 -0
- package/src/learning-defaults.mjs +98 -0
- package/src/learning-goals.mjs +165 -0
- package/src/learning-plugin.mjs +28 -0
- package/src/learning-runner.mjs +275 -0
- package/src/learning.mjs +534 -0
- package/src/legacy-export.mjs +332 -0
- package/src/legacy-import-source.mjs +356 -0
- package/src/metered-provider-host.mjs +101 -0
- package/src/metered-transport.mjs +435 -0
- package/src/method-context.mjs +57 -0
- package/src/method-lifecycle.mjs +34 -0
- package/src/method-tool.mjs +61 -0
- package/src/methods.mjs +425 -0
- package/src/model-selection.mjs +41 -0
- package/src/native-context-call.mjs +88 -0
- package/src/native-continuation.mjs +217 -0
- package/src/native-request-authority.mjs +90 -0
- package/src/openai-models.mjs +4 -0
- package/src/openai-receipt.mjs +98 -0
- package/src/openai-transport.mjs +167 -0
- package/src/openai-usage.mjs +194 -0
- package/src/opportunities.mjs +295 -0
- package/src/preparation-artifacts.mjs +213 -0
- package/src/preparation-plugin.mjs +290 -0
- package/src/runtime-command.mjs +264 -0
- package/src/runtime-config.mjs +417 -0
- package/src/runtime-guard.mjs +284 -0
- package/src/runtime-host.mjs +212 -0
- package/src/runtime-init.mjs +278 -0
- package/src/runtime-installation.mjs +518 -0
- package/src/runtime-lifecycle.mjs +227 -0
- package/src/runtime-model-settings.mjs +111 -0
- package/src/runtime-readiness.mjs +285 -0
- package/src/runtime-workspace.mjs +18 -0
- package/src/session-bridge.mjs +365 -0
- package/src/store.mjs +394 -0
- package/src/tree/LICENSE +21 -0
- package/src/tree/attribution.mjs +120 -0
- package/src/tree/clusters.mjs +61 -0
- package/src/tree/consolidation.mjs +103 -0
- package/src/tree/context.mjs +211 -0
- package/src/tree/contracts.mjs +142 -0
- package/src/tree/experience.mjs +140 -0
- package/src/tree/experiment-budget.mjs +84 -0
- package/src/tree/hooks.mjs +254 -0
- package/src/tree/host-context.mjs +107 -0
- package/src/tree/host-process.mjs +288 -0
- package/src/tree/host.mjs +37 -0
- package/src/tree/improvement.mjs +106 -0
- package/src/tree/items.mjs +294 -0
- package/src/tree/jobs.mjs +184 -0
- package/src/tree/learning.mjs +202 -0
- package/src/tree/metrics.mjs +203 -0
- package/src/tree/model-owner.mjs +254 -0
- package/src/tree/observations.mjs +73 -0
- package/src/tree/prompts.mjs +500 -0
- package/src/tree/reports.mjs +67 -0
- package/src/tree/repository.mjs +236 -0
- package/src/tree/runtime.mjs +745 -0
- package/src/tree/script-writer.mjs +149 -0
- package/src/tree/scripts.mjs +208 -0
- package/src/tree/signals.mjs +118 -0
- package/src/tree/snapshots.mjs +379 -0
- package/src/tree/token-counter.mjs +75 -0
- package/src/tree/token-counter.py +37 -0
- package/src/tree/tokenizer-resources/LICENSE +21 -0
- package/src/tree/tokenizer-resources/README.md +31 -0
- package/src/tree/tokenizer-resources/deepseek_recipe-0.2.0-cp310-abi3-macosx_11_0_arm64.whl +0 -0
- package/src/tree/tokenizer-resources/manifest.json +61 -0
- package/src/tree/tokenizer-resources/native.pyi +1042 -0
- package/src/tree/tokenizer-resources/tokenizer-NOTICE.md +26 -0
- package/src/tree/tokenizer-resources/tokenizer-v4-NOTICE.md +27 -0
- package/src/tree/tokenizer-resources/tokenizer-v4.json +267359 -0
- package/src/tree/tokenizer-resources/tokenizer.json +267359 -0
- package/src/tree/tools.mjs +99 -0
- package/src/tree/tree.mjs +202 -0
- package/src/tree/ui.mjs +285 -0
- package/src/tree/verification.mjs +143 -0
- package/src/understandings.mjs +309 -0
- package/src/web-budget-recovery.mjs +181 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { Agent } from 'undici';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { mkdir, open, readFile, readdir, rename } from 'node:fs/promises';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { DomainError, jsonClone } from './store.mjs';
|
|
6
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
7
|
+
const at = () => new Date().toISOString();
|
|
8
|
+
const fail = (code, message) => new DomainError(code, message, 409);
|
|
9
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
10
|
+
const positive = value => Number.isSafeInteger(value) && value > 0;
|
|
11
|
+
const text = value => typeof value === 'string';
|
|
12
|
+
function required(value, label) {
|
|
13
|
+
if (!text(value) || !value.trim() || value.length > 512 || value.includes('\0'))
|
|
14
|
+
throw fail('invalid_transport_configuration', `${label} is required.`);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
function keys(value, allowed) {
|
|
18
|
+
if (!object(value) || Object.keys(value).some(key => !allowed.includes(key)))
|
|
19
|
+
throw fail('unsupported_model_content', 'An unsupported request field was rejected before sending.');
|
|
20
|
+
}
|
|
21
|
+
function toolSchemas(tools) {
|
|
22
|
+
if (tools === undefined) return;
|
|
23
|
+
if (!Array.isArray(tools)) throw fail('unsupported_model_content', 'Client tool declarations must be an array.');
|
|
24
|
+
for (const tool of tools) {
|
|
25
|
+
keys(tool, ['name', 'description', 'parameters', 'deferLoading']);
|
|
26
|
+
if (!text(tool.name) || !tool.name || !text(tool.description) || !object(tool.parameters) ||
|
|
27
|
+
tool.deferLoading !== undefined && tool.deferLoading !== true)
|
|
28
|
+
throw fail('unsupported_model_content', 'Only text client tools are supported.');
|
|
29
|
+
jsonClone(tool.parameters);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function atomicJson(path, value) {
|
|
33
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
34
|
+
const file = await open(temporary, 'wx', 0o600);
|
|
35
|
+
try { await file.writeFile(JSON.stringify(value) + '\n'); await file.sync(); } finally { await file.close(); }
|
|
36
|
+
await rename(temporary, path);
|
|
37
|
+
const directory = await open(resolve(path, '..'), 'r');
|
|
38
|
+
try { await directory.sync(); } finally { await directory.close(); }
|
|
39
|
+
}
|
|
40
|
+
async function optionalJson(path) {
|
|
41
|
+
try { return JSON.parse(await readFile(path, 'utf8')); }
|
|
42
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
43
|
+
}
|
|
44
|
+
function errorCode(error) {
|
|
45
|
+
if (error?.code === 'ARCFOLD_CONTEXT_CHANGED') return 'arcfold_context_changed';
|
|
46
|
+
if (error?.code === 'ARCFOLD_METHOD_CONTEXT_CHANGED') return 'arcfold_method_context_changed';
|
|
47
|
+
if (error?.code === 'ARCFOLD_GOAL_CONTEXT_CHANGED') return 'arcfold_goal_context_changed';
|
|
48
|
+
if (error instanceof DomainError && /^[a-z][a-z0-9_]{0,70}$/.test(error.code)) return error.code;
|
|
49
|
+
return ['AbortError', 'TimeoutError'].includes(error?.name) ? 'request_interrupted' : 'transport_error';
|
|
50
|
+
}
|
|
51
|
+
function assertReceiptModel(receipt, model) {
|
|
52
|
+
// Aliases require a separately frozen pricing contract; none are implicit.
|
|
53
|
+
if (receipt.model !== model)
|
|
54
|
+
throw fail('provider_model_mismatch', 'The provider receipt model differs from the frozen request.');
|
|
55
|
+
}
|
|
56
|
+
function freezeJson(value) {
|
|
57
|
+
const copy = jsonClone(value);
|
|
58
|
+
const freeze = item => {
|
|
59
|
+
if (item && typeof item === 'object') { for (const child of Object.values(item)) freeze(child); Object.freeze(item); }
|
|
60
|
+
return item;
|
|
61
|
+
};
|
|
62
|
+
return freeze(copy);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
export { fail, object, positive, text, keys, hash };
|
|
67
|
+
|
|
68
|
+
/** One lifecycle for all paid protocol boundaries. */
|
|
69
|
+
export class MeteredTransport {
|
|
70
|
+
constructor({ ledger, artifactDirectory, provider, modelPolicies, requestIdentity, beforeDispatch = () => {},
|
|
71
|
+
protocol, endpoint = protocol.endpoint, allowLocalTestEndpoint = false, dispatcher, ownsDispatcher = dispatcher === undefined, lifecycle,
|
|
72
|
+
timeoutMs = 20 * 60 * 1000, maxRequestBytes = 16 * 1024 * 1024,
|
|
73
|
+
maxResponseBytes = 32 * 1024 * 1024 } = {}) {
|
|
74
|
+
if (!ledger || !artifactDirectory || typeof requestIdentity !== 'function' || typeof beforeDispatch !== 'function' || !object(modelPolicies))
|
|
75
|
+
throw fail('invalid_transport_configuration', 'Ledger, evidence, model policies and native identity are required.');
|
|
76
|
+
required(provider, 'Provider');
|
|
77
|
+
const target = new URL(endpoint);
|
|
78
|
+
protocol.validateEndpoint(endpoint, allowLocalTestEndpoint);
|
|
79
|
+
for (const value of [timeoutMs, maxRequestBytes, maxResponseBytes])
|
|
80
|
+
if (!positive(value)) throw fail('invalid_transport_configuration', 'Transport bounds must be positive integers.');
|
|
81
|
+
this.modelPolicies = jsonClone(modelPolicies);
|
|
82
|
+
protocol.validatePolicies(this.modelPolicies);
|
|
83
|
+
this.ledger = ledger; this.artifactDirectory = resolve(artifactDirectory); this.provider = provider;
|
|
84
|
+
this.requestIdentity = requestIdentity; this.endpoint = target.href; this.timeoutMs = timeoutMs;
|
|
85
|
+
this.beforeDispatch = beforeDispatch;
|
|
86
|
+
this.maxRequestBytes = maxRequestBytes; this.maxResponseBytes = maxResponseBytes;
|
|
87
|
+
this.dispatcher = dispatcher ?? new Agent(); this.ownsDispatcher = ownsDispatcher;
|
|
88
|
+
this.protocol = protocol;
|
|
89
|
+
this.lifecycle = lifecycle ?? { stopping: new AbortController(), inflight: new Map(), evidenceVersions: new Set([protocol.version]) };
|
|
90
|
+
this.stopping = this.lifecycle.stopping; this.inflight = this.lifecycle.inflight; this.closing = null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
validateInput(options) {
|
|
94
|
+
if (this.stopping.signal.aborted) throw fail('provider_closed', 'The paid transport is closed.');
|
|
95
|
+
if (!object(options) || options.provider !== this.provider || !Object.hasOwn(this.modelPolicies, options.model))
|
|
96
|
+
throw fail('unregistered_model_route', 'This route has no authorized accounting policy.');
|
|
97
|
+
const policy = this.modelPolicies[options.model];
|
|
98
|
+
if (!policy.reasoningEfforts.includes(options.reasoningEffort) || !positive(options.maxTokens) ||
|
|
99
|
+
options.maxTokens > (policy.requestMaxTokens ?? policy.maxOutputTokens))
|
|
100
|
+
throw fail('unfrozen_model_configuration', 'Reasoning effort and output limit must match the explicit model policy.');
|
|
101
|
+
if (!Array.isArray(options.messages) || !options.messages.length ||
|
|
102
|
+
options.system !== undefined && !text(options.system))
|
|
103
|
+
throw fail('unsupported_model_content', 'Native conversation input is required.');
|
|
104
|
+
for (const message of options.messages) {
|
|
105
|
+
if (!object(message) || !['system', 'developer', 'user', 'assistant', 'tool'].includes(message.role) || !Array.isArray(message.content))
|
|
106
|
+
throw fail('unsupported_model_content', 'Only native conversation messages are supported.');
|
|
107
|
+
for (const block of message.content) {
|
|
108
|
+
if (block?.type === 'text' || block?.type === 'reasoning' && message.role === 'assistant') {
|
|
109
|
+
keys(block, ['type', 'text']);
|
|
110
|
+
if (!text(block.text)) throw fail('unsupported_model_content', 'Text blocks must contain text.');
|
|
111
|
+
} else if (block?.type === 'tool-call' && message.role === 'assistant') {
|
|
112
|
+
keys(block, ['type', 'id', 'name', 'arguments']);
|
|
113
|
+
if (!text(block.id) || !text(block.name) || !text(block.arguments))
|
|
114
|
+
throw fail('unsupported_model_content', 'Client tool calls must contain text arguments.');
|
|
115
|
+
} else if (['tool-addition', 'tool-removal'].includes(block?.type) && message.role === 'developer') {
|
|
116
|
+
keys(block, ['type', 'toolName']);
|
|
117
|
+
if (!text(block.toolName)) throw fail('unsupported_model_content', 'Client tool updates require a name.');
|
|
118
|
+
} else if (block?.type === 'image' && ['user', 'tool'].includes(message.role) && this.protocol.validateNativeImage) {
|
|
119
|
+
this.protocol.validateNativeImage(block);
|
|
120
|
+
} else throw fail('unsupported_model_content', 'This content is not supported by the selected model route.');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
toolSchemas(options.tools);
|
|
124
|
+
if (options.toolHistory !== undefined) {
|
|
125
|
+
keys(options.toolHistory, ['tools', 'updates']); toolSchemas(options.toolHistory.tools);
|
|
126
|
+
if (!Array.isArray(options.toolHistory.updates)) throw fail('unsupported_model_content', 'Tool history is invalid.');
|
|
127
|
+
for (const update of options.toolHistory.updates) toolSchemas(update.additions);
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
persistJson(path, value) { return atomicJson(path, value); }
|
|
133
|
+
|
|
134
|
+
createTransport(options) {
|
|
135
|
+
this.validateInput(options);
|
|
136
|
+
// Native messages must not change while the official adapter awaits auth or
|
|
137
|
+
// while evidence is persisted. AbortSignal keeps its live cancellation state.
|
|
138
|
+
const nativeOptions = Object.freeze({ ...options, messages: freezeJson(options.messages),
|
|
139
|
+
...(options.tools === undefined ? {} : { tools: freezeJson(options.tools) }),
|
|
140
|
+
...(options.toolHistory === undefined ? {} : { toolHistory: freezeJson(options.toolHistory) }), signal: options.signal });
|
|
141
|
+
const identity = jsonClone(this.requestIdentity(nativeOptions));
|
|
142
|
+
if (!object(identity)) throw fail('missing_native_request_identity', 'Native request identity is required.');
|
|
143
|
+
required(identity.requestId, 'Native request identity');
|
|
144
|
+
if (!object(identity.metadata ?? {})) throw fail('missing_native_request_identity', 'Native request metadata is required.');
|
|
145
|
+
const fixed = { provider: options.provider, model: options.model, maxTokens: options.maxTokens,
|
|
146
|
+
reasoningEffort: options.reasoningEffort, policy: jsonClone(this.modelPolicies[options.model]), identity,
|
|
147
|
+
inputSignal: options.signal, nativeOptions, ownerId: randomUUID() };
|
|
148
|
+
let used = false;
|
|
149
|
+
return { send: (url, init) => {
|
|
150
|
+
if (used) return Promise.reject(fail('request_already_dispatched', 'One native step may send at most once.'));
|
|
151
|
+
used = true;
|
|
152
|
+
return this.send(fixed, url, init);
|
|
153
|
+
} };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async send(fixed, url, init) {
|
|
157
|
+
if (this.stopping.signal.aborted) throw fail('provider_closed', 'The paid transport is closed.');
|
|
158
|
+
const wire = this.validateWire(fixed, url, init);
|
|
159
|
+
const requestId = fixed.identity.requestId, key = hash(requestId);
|
|
160
|
+
const cancellation = new AbortController();
|
|
161
|
+
const signal = AbortSignal.any([this.stopping.signal, cancellation.signal, AbortSignal.timeout(this.timeoutMs),
|
|
162
|
+
...[fixed.inputSignal, init.signal].filter(Boolean)]);
|
|
163
|
+
signal.throwIfAborted();
|
|
164
|
+
let resolveDone;
|
|
165
|
+
const done = new Promise(resolve => { resolveDone = resolve; });
|
|
166
|
+
const state = { requestId, bodyHash: wire.bodyHash, ownerId: fixed.ownerId, key,
|
|
167
|
+
directory: join(this.artifactDirectory, key), done, resolveDone, bytes: 0, chunks: 0,
|
|
168
|
+
responseHash: createHash('sha256'), dispatched: false, ownsReservation: false, settled: false,
|
|
169
|
+
finished: false, closing: null, signal, cancellation, pendingRead: null };
|
|
170
|
+
this.inflight.set(state.ownerId, state);
|
|
171
|
+
try {
|
|
172
|
+
await mkdir(this.artifactDirectory, { recursive: true, mode: 0o700 });
|
|
173
|
+
try { await mkdir(state.directory, { mode: 0o700 }); }
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (error.code === 'EEXIST') throw fail('request_already_dispatched', 'This native request already has preserved attempt evidence.');
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
state.ownsDirectory = true;
|
|
179
|
+
await this.persistJson(join(state.directory, 'identity.json'), { version: this.protocol.version, requestId,
|
|
180
|
+
ownerId: fixed.ownerId, scope: this.ledger.scope, bodyHash: wire.bodyHash, createdAt: at() });
|
|
181
|
+
const reserved = this.ledger.reserve({ requestId, bodyHash: wire.bodyHash, model: fixed.model,
|
|
182
|
+
...this.protocol.reservationSpec(fixed), metadata: { ...(fixed.identity.metadata ?? {}),
|
|
183
|
+
transport: { version: this.protocol.version, ownerId: fixed.ownerId, artifactKey: key }, ...(fixed.policy.priceVersion ? { priceVersion: fixed.policy.priceVersion } : {}) } });
|
|
184
|
+
if (reserved.metadata?.transport?.ownerId !== fixed.ownerId)
|
|
185
|
+
throw fail('request_already_dispatched', 'This native request already belongs to another recorded attempt.');
|
|
186
|
+
state.ownsReservation = true;
|
|
187
|
+
const request = { version: this.protocol.version, requestId, ownerId: fixed.ownerId, bodyHash: wire.bodyHash,
|
|
188
|
+
createdAt: at(), endpoint: url, scope: this.ledger.scope, body: wire.body, serialized: wire.serialized,
|
|
189
|
+
policy: fixed.policy, metadata: reserved.metadata };
|
|
190
|
+
await this.persistJson(join(state.directory, 'request.json'), request);
|
|
191
|
+
signal.throwIfAborted();
|
|
192
|
+
const currentIdentity = jsonClone(this.requestIdentity(fixed.nativeOptions));
|
|
193
|
+
if (currentIdentity.requestId !== requestId)
|
|
194
|
+
throw fail('native_request_changed', 'The native request owner changed before dispatch.');
|
|
195
|
+
const checked = this.beforeDispatch(fixed.nativeOptions);
|
|
196
|
+
if (checked && typeof checked.then === 'function') {
|
|
197
|
+
void Promise.resolve(checked).catch(() => {});
|
|
198
|
+
throw fail('async_before_dispatch', 'The final dispatch check must be synchronous.');
|
|
199
|
+
}
|
|
200
|
+
signal.throwIfAborted();
|
|
201
|
+
// No await from the final authority check through the actual network call.
|
|
202
|
+
this.ledger.markDispatched(requestId); state.dispatched = true;
|
|
203
|
+
const target = new URL(url);
|
|
204
|
+
const response = await this.dispatcher.request({ origin: target.origin, path: target.pathname,
|
|
205
|
+
method: 'POST', headers: wire.headers, body: wire.serialized, signal,
|
|
206
|
+
idempotent: false, maxRedirections: 0, headersTimeout: 0, bodyTimeout: 0 });
|
|
207
|
+
state.networkBody = response.body;
|
|
208
|
+
// An unread Response may be cancelled before its async iterator is pulled.
|
|
209
|
+
// The abort path still records unknown; prevent an unhandled Node error.
|
|
210
|
+
state.networkBody.on('error', () => {});
|
|
211
|
+
state.status = response.statusCode;
|
|
212
|
+
const header = name => Array.isArray(response.headers[name]) ? response.headers[name].join(', ') : response.headers[name] ?? null;
|
|
213
|
+
state.headers = { status: state.status, contentType: header('content-type'),
|
|
214
|
+
requestId: header('x-request-id'), receivedAt: at() };
|
|
215
|
+
await this.persistJson(join(state.directory, 'headers.json'), state.headers);
|
|
216
|
+
state.bodyFile = await open(join(state.directory, 'response-body.bin'), 'wx', 0o600);
|
|
217
|
+
state.chunkFile = await open(join(state.directory, 'response-chunks.jsonl'), 'wx', 0o600);
|
|
218
|
+
state.parser = state.status >= 200 && state.status < 300 && state.headers.contentType?.includes('text/event-stream')
|
|
219
|
+
? this.protocol.createReceipt() : null;
|
|
220
|
+
const iterator = response.body[Symbol.asyncIterator]();
|
|
221
|
+
const consume = async chunk => {
|
|
222
|
+
const bytes = Buffer.from(chunk);
|
|
223
|
+
if (state.bytes + bytes.length > this.maxResponseBytes) throw fail('response_too_large', 'Response evidence capacity exceeded.');
|
|
224
|
+
await state.bodyFile.writeFile(bytes); await state.bodyFile.sync();
|
|
225
|
+
await state.chunkFile.writeFile(JSON.stringify({ sequence: state.chunks++, offset: state.bytes,
|
|
226
|
+
bytes: bytes.length, receivedAt: at() }) + '\n'); await state.chunkFile.sync();
|
|
227
|
+
state.bytes += bytes.length; state.responseHash.update(bytes); state.parser?.push(bytes);
|
|
228
|
+
return bytes;
|
|
229
|
+
};
|
|
230
|
+
const finish = async () => {
|
|
231
|
+
if (!state.parser) {
|
|
232
|
+
if (state.status >= 200 && state.status < 300)
|
|
233
|
+
throw fail('provider_http_error', 'The provider did not return a Messages event stream.');
|
|
234
|
+
await this.persistJson(join(state.directory, 'http-receipt.json'), {
|
|
235
|
+
version: this.protocol.version, requestId, ownerId: state.ownerId, bodyHash: state.bodyHash,
|
|
236
|
+
status: state.status, completeAt: at(), bytes: state.bytes, chunks: state.chunks,
|
|
237
|
+
responseHash: state.responseHash.digest('hex'),
|
|
238
|
+
});
|
|
239
|
+
this.ledger.markUnknown(requestId, 'provider_http_error');
|
|
240
|
+
await this.finish(state); return;
|
|
241
|
+
}
|
|
242
|
+
const parsed = state.parser.finish();
|
|
243
|
+
assertReceiptModel(parsed, fixed.model);
|
|
244
|
+
const receipt = { version: this.protocol.version, requestId, ownerId: state.ownerId, bodyHash: state.bodyHash,
|
|
245
|
+
status: state.status, completeAt: at(), bytes: state.bytes, chunks: state.chunks,
|
|
246
|
+
responseHash: state.responseHash.digest('hex'), ...parsed };
|
|
247
|
+
await this.persistJson(join(state.directory, 'receipt.json'), receipt);
|
|
248
|
+
this.ledger.settle(requestId, parsed.usage); state.settled = true;
|
|
249
|
+
await this.finish(state);
|
|
250
|
+
};
|
|
251
|
+
const stream = new ReadableStream({
|
|
252
|
+
start: controller => { state.controller = controller; },
|
|
253
|
+
pull: controller => {
|
|
254
|
+
const reading = (async () => {
|
|
255
|
+
try {
|
|
256
|
+
signal.throwIfAborted();
|
|
257
|
+
const next = await iterator.next();
|
|
258
|
+
if (next.done) { await finish(); controller.close(); return; }
|
|
259
|
+
const bytes = await consume(next.value);
|
|
260
|
+
if (state.parser?.stopped) {
|
|
261
|
+
// DSH stops consuming at message_stop. Finish evidence and billing
|
|
262
|
+
// before exposing that terminal chunk, retaining any trailing bytes.
|
|
263
|
+
const tail = [];
|
|
264
|
+
for await (const more of { [Symbol.asyncIterator]: () => iterator }) tail.push(await consume(more));
|
|
265
|
+
await finish();
|
|
266
|
+
controller.enqueue(bytes);
|
|
267
|
+
for (const more of tail) controller.enqueue(more);
|
|
268
|
+
controller.close();
|
|
269
|
+
} else controller.enqueue(bytes);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
await this.failure(state, error);
|
|
272
|
+
controller.error(fail(errorCode(error), 'The original provider receipt is incomplete.'));
|
|
273
|
+
}
|
|
274
|
+
})();
|
|
275
|
+
state.pendingRead = reading;
|
|
276
|
+
const clear = () => { if (state.pendingRead === reading) state.pendingRead = null; };
|
|
277
|
+
void reading.then(clear, clear);
|
|
278
|
+
return reading;
|
|
279
|
+
},
|
|
280
|
+
cancel: async () => {
|
|
281
|
+
cancellation.abort();
|
|
282
|
+
await this.interrupt(state);
|
|
283
|
+
},
|
|
284
|
+
}, { highWaterMark: 0 });
|
|
285
|
+
state.abort = () => {
|
|
286
|
+
void this.interrupt(state).catch(() => {});
|
|
287
|
+
};
|
|
288
|
+
signal.addEventListener('abort', state.abort, { once: true });
|
|
289
|
+
if (signal.aborted) state.abort();
|
|
290
|
+
if ([204, 205, 304].includes(state.status)) {
|
|
291
|
+
await this.failure(state, fail('provider_http_error', 'A Messages response body is required.'));
|
|
292
|
+
return new Response(null, { status: state.status });
|
|
293
|
+
}
|
|
294
|
+
return new Response(stream, { status: state.status, headers: response.headers });
|
|
295
|
+
} catch (error) {
|
|
296
|
+
await this.failure(state, error);
|
|
297
|
+
throw fail(errorCode(error), 'The Messages request did not complete.');
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
failure(state, error) {
|
|
302
|
+
if (state.failing) return state.failing;
|
|
303
|
+
if (state.finished) return state.closing;
|
|
304
|
+
state.failing = (async () => {
|
|
305
|
+
try {
|
|
306
|
+
if (state.dispatched && !state.settled) this.ledger.markUnknown(state.requestId, errorCode(error));
|
|
307
|
+
else if (state.ownsReservation && !state.dispatched) {
|
|
308
|
+
const current = this.ledger.get(state.requestId);
|
|
309
|
+
if (current?.status === 'reserved' && !current.dispatchedAt && current.metadata?.transport?.ownerId === state.ownerId)
|
|
310
|
+
this.ledger.releaseUnsent(state.requestId, errorCode(error));
|
|
311
|
+
}
|
|
312
|
+
if (state.ownsDirectory) await this.persistJson(join(state.directory, `error-${randomUUID()}.json`), {
|
|
313
|
+
version: this.protocol.version, requestId: state.requestId, bodyHash: state.bodyHash, ownerId: state.ownerId,
|
|
314
|
+
at: at(), code: errorCode(error), dispatched: state.dispatched, settled: state.settled,
|
|
315
|
+
persistedBytes: state.bytes, chunks: state.chunks,
|
|
316
|
+
}).catch(() => {});
|
|
317
|
+
} finally { await this.finish(state); }
|
|
318
|
+
})();
|
|
319
|
+
return state.failing;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
interrupt(state) {
|
|
323
|
+
if (state.interruption) return state.interruption;
|
|
324
|
+
state.networkBody?.destroy();
|
|
325
|
+
state.interruption = (async () => {
|
|
326
|
+
if (state.pendingRead) await state.pendingRead.catch(() => {});
|
|
327
|
+
if (!state.finished) await this.failure(state, fail('request_interrupted', 'The request stopped.'));
|
|
328
|
+
try { state.controller?.error(fail('request_interrupted', 'The request stopped.')); } catch {}
|
|
329
|
+
await state.done;
|
|
330
|
+
})();
|
|
331
|
+
return state.interruption;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
finish(state) {
|
|
335
|
+
if (state.closing) return state.closing;
|
|
336
|
+
state.finished = true;
|
|
337
|
+
state.closing = (async () => {
|
|
338
|
+
state.signal.removeEventListener('abort', state.abort);
|
|
339
|
+
state.networkBody?.destroy();
|
|
340
|
+
const results = await Promise.allSettled([state.bodyFile?.close(), state.chunkFile?.close()]);
|
|
341
|
+
const complete = () => { this.inflight.delete(state.ownerId); state.resolveDone(); };
|
|
342
|
+
// A pull may still be unwinding after its final disk write. Do not let the
|
|
343
|
+
// caller close the ledger until that operation has actually returned.
|
|
344
|
+
if (state.pendingRead) void state.pendingRead.then(complete, complete);
|
|
345
|
+
else complete();
|
|
346
|
+
if (results.some(result => result.status === 'rejected')) throw fail('receipt_cleanup_failed', 'Receipt file cleanup failed.');
|
|
347
|
+
})();
|
|
348
|
+
return state.closing;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async recover({ exclusive = false } = {}) {
|
|
352
|
+
if (!exclusive || this.inflight.size) throw fail('recovery_requires_exclusive_owner', 'Cold recovery requires exclusive ownership of this evidence directory.');
|
|
353
|
+
const result = { settled: [], unknown: [], released: [], unchanged: [] };
|
|
354
|
+
let entries;
|
|
355
|
+
try { entries = await readdir(this.artifactDirectory, { withFileTypes: true }); }
|
|
356
|
+
catch (error) { if (error.code === 'ENOENT') return result; throw error; }
|
|
357
|
+
for (const entry of entries) {
|
|
358
|
+
if (!entry.isDirectory() || !/^[a-f0-9]{64}$/.test(entry.name)) continue;
|
|
359
|
+
const directory = join(this.artifactDirectory, entry.name);
|
|
360
|
+
const request = await optionalJson(join(directory, 'request.json'));
|
|
361
|
+
const identityEvidence = await optionalJson(join(directory, 'identity.json'));
|
|
362
|
+
if (request && identityEvidence && ['version','requestId','ownerId','scope','bodyHash'].some(field => request[field] !== identityEvidence[field]))
|
|
363
|
+
throw fail('receipt_identity_conflict', 'Saved request and attempt identity disagree.');
|
|
364
|
+
const savedIdentity = request ?? identityEvidence;
|
|
365
|
+
if (savedIdentity && savedIdentity.version !== this.protocol.version) {
|
|
366
|
+
if (!this.lifecycle.evidenceVersions.has(savedIdentity.version))
|
|
367
|
+
throw fail('receipt_identity_conflict', 'Evidence belongs to an unregistered transport version.');
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (!request) {
|
|
371
|
+
const identity = await optionalJson(join(directory, 'identity.json'));
|
|
372
|
+
if (!identity) continue;
|
|
373
|
+
const record = this.ledger.get(identity.requestId);
|
|
374
|
+
if (!record) continue;
|
|
375
|
+
if (identity.version !== this.protocol.version || hash(identity.requestId) !== entry.name || identity.scope !== this.ledger.scope ||
|
|
376
|
+
record.bodyHash !== identity.bodyHash || record.metadata?.transport?.ownerId !== identity.ownerId ||
|
|
377
|
+
record.metadata.transport.version !== this.protocol.version || record.metadata.transport.artifactKey !== entry.name)
|
|
378
|
+
throw fail('receipt_identity_conflict', 'Incomplete attempt evidence does not own its ledger entry.');
|
|
379
|
+
this.ledger.assertScope(identity.requestId);
|
|
380
|
+
if (record.status === 'reserved' && !record.dispatchedAt) {
|
|
381
|
+
this.ledger.releaseUnsent(identity.requestId, 'cold_recovery_unsent'); result.released.push(identity.requestId);
|
|
382
|
+
} else if (record.status === 'dispatched') {
|
|
383
|
+
this.ledger.markUnknown(identity.requestId, 'cold_recovery_incomplete'); result.unknown.push(identity.requestId);
|
|
384
|
+
} else result.unchanged.push(identity.requestId);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (request.version !== this.protocol.version || hash(request.requestId) !== entry.name || request.scope !== this.ledger.scope ||
|
|
388
|
+
hash(request.serialized) !== request.bodyHash || JSON.stringify(JSON.parse(request.serialized)) !== JSON.stringify(request.body))
|
|
389
|
+
throw fail('receipt_identity_conflict', 'Saved request identity is inconsistent.');
|
|
390
|
+
const record = this.ledger.get(request.requestId);
|
|
391
|
+
if (!record || record.bodyHash !== request.bodyHash || record.model !== request.body.model || record.metadata?.transport?.ownerId !== request.ownerId ||
|
|
392
|
+
record.metadata.transport.version !== this.protocol.version || record.metadata.transport.artifactKey !== entry.name)
|
|
393
|
+
throw fail('receipt_identity_conflict', 'Saved request does not own the ledger entry.');
|
|
394
|
+
this.ledger.assertScope(request.requestId);
|
|
395
|
+
const receipt = await optionalJson(join(directory, 'receipt.json'));
|
|
396
|
+
if (receipt) {
|
|
397
|
+
const bytes = await readFile(join(directory, 'response-body.bin'));
|
|
398
|
+
const headers = await optionalJson(join(directory, 'headers.json'));
|
|
399
|
+
if (receipt.version !== this.protocol.version || receipt.requestId !== request.requestId || receipt.ownerId !== request.ownerId ||
|
|
400
|
+
receipt.bodyHash !== request.bodyHash || receipt.bytes !== bytes.length || receipt.responseHash !== hash(bytes) ||
|
|
401
|
+
receipt.status < 200 || receipt.status >= 300 || headers?.status !== receipt.status ||
|
|
402
|
+
!headers.contentType?.includes('text/event-stream')) throw fail('receipt_identity_conflict', 'Saved response identity is inconsistent.');
|
|
403
|
+
const chunks = (await readFile(join(directory, 'response-chunks.jsonl'), 'utf8')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
|
|
404
|
+
let offset = 0;
|
|
405
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
406
|
+
if (chunk.sequence !== index || chunk.offset !== offset || !positive(chunk.bytes))
|
|
407
|
+
throw fail('receipt_identity_conflict', 'Saved receipt chunk ordering is inconsistent.');
|
|
408
|
+
offset += chunk.bytes;
|
|
409
|
+
}
|
|
410
|
+
if (offset !== bytes.length || chunks.length !== receipt.chunks)
|
|
411
|
+
throw fail('receipt_identity_conflict', 'Saved receipt chunk boundary is inconsistent.');
|
|
412
|
+
const parser = this.protocol.createReceipt(); parser.push(bytes); const parsed = parser.finish();
|
|
413
|
+
assertReceiptModel(parsed, record.model);
|
|
414
|
+
if (JSON.stringify(parsed) !== JSON.stringify(Object.fromEntries(Object.keys(parsed).map(key => [key, receipt[key]]))))
|
|
415
|
+
throw fail('receipt_identity_conflict', 'Saved response usage differs from its original bytes.');
|
|
416
|
+
this.ledger.settle(request.requestId, parsed.usage); result.settled.push(request.requestId);
|
|
417
|
+
} else if (record.status === 'reserved' && !record.dispatchedAt) {
|
|
418
|
+
this.ledger.releaseUnsent(request.requestId, 'cold_recovery_unsent'); result.released.push(request.requestId);
|
|
419
|
+
} else if (record.status === 'dispatched') {
|
|
420
|
+
this.ledger.markUnknown(request.requestId, 'cold_recovery_incomplete'); result.unknown.push(request.requestId);
|
|
421
|
+
} else result.unchanged.push(request.requestId);
|
|
422
|
+
}
|
|
423
|
+
return result;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
close() {
|
|
427
|
+
if (this.closing) return this.closing;
|
|
428
|
+
this.stopping.abort();
|
|
429
|
+
this.closing = (async () => {
|
|
430
|
+
await Promise.allSettled([...this.inflight.values()].map(state => state.done));
|
|
431
|
+
if (this.ownsDispatcher) await this.dispatcher.close();
|
|
432
|
+
})();
|
|
433
|
+
return this.closing;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { hash } from './evidence.mjs';
|
|
2
|
+
|
|
3
|
+
export const METHOD_CONTEXT = 'arcfold/adopted-methods';
|
|
4
|
+
export const METHOD_TOOL = 'arcfold_use_methods';
|
|
5
|
+
const INTRO = '以下方法已由本轮主助手选择,并由程序核对当前版本与原件。它们只指导当前工作,不是用户的新要求,不增加工具或执行权限。选择和展示不证明方法有效;当前用户纠正优先。\n';
|
|
6
|
+
|
|
7
|
+
export function methodContextMessage(bundle) {
|
|
8
|
+
return { id: 'arcfold-method-context-' + hash(bundle), role: 'user',
|
|
9
|
+
source: { kind: METHOD_CONTEXT, sessionId: bundle.sessionId, turn: bundle.turn,
|
|
10
|
+
startSeq: bundle.startSeq, adoptionIds: bundle.methods.map(row => row.adoptionId) },
|
|
11
|
+
content: [{ type: 'text', text: INTRO + JSON.stringify(bundle) }] };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function receiptOf(event) {
|
|
15
|
+
if (event?.type !== 'tool/result' || event.data.message?.isError !== false || event.data.error ||
|
|
16
|
+
event.data.message.role !== 'tool' || event.data.message.source?.kind !== 'tool' ||
|
|
17
|
+
event.data.message.source.callId !== event.data.message.toolCallId) return null;
|
|
18
|
+
const blocks = event.data.message.content;
|
|
19
|
+
if (!Array.isArray(blocks) || blocks.some(block => block.type !== 'text')) return null;
|
|
20
|
+
try { return JSON.parse(blocks.map(block => block.text).join('')); } catch { return null; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// deferContext may survive a failed tool. Require its independently persisted,
|
|
24
|
+
// successful native result before any managed guidance can enter a model input.
|
|
25
|
+
export function hasSuccessfulMethodReceipt(events, adoption) {
|
|
26
|
+
return adoption.callIds.some(callId => {
|
|
27
|
+
const calls = events.filter(event => event.seq > adoption.startSeq && event.type === 'tool/call' &&
|
|
28
|
+
event.data.turn === adoption.turn && event.data.name === METHOD_TOOL && event.data.callId === callId);
|
|
29
|
+
if (calls.length !== 1) return false;
|
|
30
|
+
const call = calls[0];
|
|
31
|
+
const results = events.filter(event => event.seq > call.seq && event.type === 'tool/result' &&
|
|
32
|
+
event.data.turn === adoption.turn && event.data.step === call.data.step && event.data.message?.toolCallId === callId);
|
|
33
|
+
if (results.length !== 1) return false;
|
|
34
|
+
const receipt = receiptOf(results[0]);
|
|
35
|
+
return receipt?.adoptionIds?.includes(adoption.id) && receipt.methods?.some(row =>
|
|
36
|
+
row.adoptionId === adoption.id && row.id === adoption.methodId && row.revision === adoption.methodRevision &&
|
|
37
|
+
row.version === adoption.methodVersion && row.contentHash === adoption.contentHash) === true;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function eligibleMethodBundle(methods, events, position) {
|
|
42
|
+
const bundle = methods.currentBundle(position);
|
|
43
|
+
return { ...bundle, methods: bundle.methods.filter(entry => {
|
|
44
|
+
const adoption = methods.store.get('method_adoptions', entry.adoptionId);
|
|
45
|
+
return adoption && hasSuccessfulMethodReceipt(events, adoption);
|
|
46
|
+
}) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function methodTurn(session) {
|
|
50
|
+
const events = session.snapshotEvents();
|
|
51
|
+
const start = events.findLast(event => event.type === 'turn/start');
|
|
52
|
+
const step = events.findLast(event => event.type === 'step/start');
|
|
53
|
+
if (!start || !step || step.seq <= start.seq || step.data.turn !== start.data.turn ||
|
|
54
|
+
events.some(event => event.seq > start.seq && event.type === 'turn/end'))
|
|
55
|
+
throw new Error('Method context requires the current open native turn');
|
|
56
|
+
return { events, position: { sessionId: session.id, turn: start.data.turn, startSeq: start.seq }, step };
|
|
57
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { DomainError, canonicalJson } from './store.mjs';
|
|
2
|
+
import { hash } from './evidence.mjs';
|
|
3
|
+
|
|
4
|
+
export function methodPauseReason(method) {
|
|
5
|
+
const reason = method.pauseReason || method.withdrawalReason || '';
|
|
6
|
+
if (reason === 'import_batch_withdrawn') return '你已撤销这批导入;方法依赖的来源已停止作为依据,因此暂停。原记录保留。';
|
|
7
|
+
if (reason === 'user_request') return '你已选择暂停;需要时可重新查看并决定。';
|
|
8
|
+
if (reason === 'trial_limit') return '试用次数用完,已停止新的试用,等待你决定是否继续。这不代表效果验证通过或失败。';
|
|
9
|
+
if (reason === 'negative_feedback') return '反馈指出需要调整,方法已暂停,等待重新判断。';
|
|
10
|
+
if (reason === 'superseded') return '学习提出了修订,旧方法已暂停;请查看后继版本。';
|
|
11
|
+
if (/^(source_|understanding_|method_)/.test(reason)) return '方法依据的原件、理解或其他方法已撤回、暂停或变化,需要先核对依据。';
|
|
12
|
+
return reason ? '暂停原因:' + reason : '历史记录没有保存暂停原因,无法据此认定曾经提醒过你。';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** One synchronous state transition and notification, including Learn's pauses. */
|
|
16
|
+
export function pauseMethod(store, method, { reason, changes = {}, pendingId = null } = {}) {
|
|
17
|
+
if (typeof reason !== 'string' || !reason.trim() || reason.length > 4000)
|
|
18
|
+
throw new DomainError('invalid_pause_reason', '暂停必须保留具体原因。', 400);
|
|
19
|
+
return store.transaction(() => {
|
|
20
|
+
const current = store.get('methods', method.id);
|
|
21
|
+
if (current?.revision !== method.revision)
|
|
22
|
+
throw new DomainError('revision_conflict', '方法已变化,请重新读取。');
|
|
23
|
+
const patch = { ...changes, status: 'paused', pauseReason: reason };
|
|
24
|
+
if (Object.entries(patch).every(([key, value]) => canonicalJson(current[key] ?? null) === canonicalJson(value))) return current;
|
|
25
|
+
const saved = store.save('methods', { ...current, ...patch }, { expectedRevision: current.revision });
|
|
26
|
+
store.save('notifications', {
|
|
27
|
+
id: 'method-pause-' + hash([saved.id, saved.revision]), kind: 'method_paused', status: 'unread',
|
|
28
|
+
methodId: saved.id, methodRevision: saved.revision, methodVersion: saved.version || 1,
|
|
29
|
+
pendingId, reason, title: '协作方法已暂停',
|
|
30
|
+
message: `“${saved.title || '未命名方法'}”:${methodPauseReason(saved)}`,
|
|
31
|
+
});
|
|
32
|
+
return saved;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
2
|
+
import { HarnessError } from '@deepseek-ai/dsh-llm';
|
|
3
|
+
import { resolveNativeContextCall, belongsToSelectionBatch } from './native-context-call.mjs';
|
|
4
|
+
import { METHOD_TOOL, methodContextMessage } from './method-context.mjs';
|
|
5
|
+
|
|
6
|
+
export const name = 'arcfold-method-tool';
|
|
7
|
+
export const inject = ['tools', 'agents', 'arcfoldBridge', 'arcfoldMethods'];
|
|
8
|
+
const reject = (message, code = 'ARCFOLD_METHOD_CALL_INVALID') => { throw new HarnessError(message, code); };
|
|
9
|
+
// Keep the method-specific error contract while sharing exact native evidence checks.
|
|
10
|
+
export const resolveNativeMethodCall = (ctx, exec) => resolveNativeContextCall(ctx, exec, reject);
|
|
11
|
+
|
|
12
|
+
function assertSelections(args) {
|
|
13
|
+
if (!args || Object.keys(args).length !== 1 || !Object.hasOwn(args, 'selections') ||
|
|
14
|
+
!Array.isArray(args.selections) || args.selections.length < 1 || args.selections.length > 8)
|
|
15
|
+
reject('Provide one to eight method selections only.');
|
|
16
|
+
const fields = ['id', 'revision', 'contentHash', 'reason', 'sourceIds'];
|
|
17
|
+
for (const item of args.selections) if (!item || typeof item !== 'object' || Array.isArray(item) ||
|
|
18
|
+
Object.keys(item).length !== fields.length || fields.some(field => !Object.hasOwn(item, field)))
|
|
19
|
+
reject('Each selection requires exactly id, revision, contentHash, reason and sourceIds.');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function apply(ctx) {
|
|
23
|
+
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
24
|
+
if (!belongsToSelectionBatch(exec, METHOD_TOOL)) return next();
|
|
25
|
+
try {
|
|
26
|
+
const current = await resolveNativeMethodCall(ctx, exec);
|
|
27
|
+
if (current.batch.length !== 1 || current.batch[0].name !== METHOD_TOOL)
|
|
28
|
+
return { kind: 'deny', reason: 'Request arcfold_use_methods alone. Read the returned guidance in the next model step before requesting any other action.' };
|
|
29
|
+
} catch (error) { return { kind: 'deny', reason: error.message }; }
|
|
30
|
+
return next();
|
|
31
|
+
});
|
|
32
|
+
ctx.tools.register(defineTool({
|
|
33
|
+
name: METHOD_TOOL,
|
|
34
|
+
description: 'Select one to eight applicable methods from the current Arcfold catalog. Call this tool alone in the assistant tool batch. It returns a selection receipt; the full guidance arrives as program context in the next model step. Read it before acting. Selection is not user confirmation, new permission, or evidence that a method works.',
|
|
35
|
+
parameters: { selections: { type: 'array', required: true, items: {
|
|
36
|
+
type: 'object', additionalProperties: false, properties: {
|
|
37
|
+
id: { type: 'string', required: true }, revision: { type: 'integer', required: true },
|
|
38
|
+
contentHash: { type: 'string', required: true }, reason: { type: 'string', required: true },
|
|
39
|
+
sourceIds: { type: 'array', required: true, items: { type: 'string' } },
|
|
40
|
+
},
|
|
41
|
+
} } },
|
|
42
|
+
output: { schema: { type: 'json' }, render: (_args, receipt) => [{ type: 'text', text: JSON.stringify(receipt) }] },
|
|
43
|
+
async execute(args, exec) {
|
|
44
|
+
assertSelections(args);
|
|
45
|
+
const current = await resolveNativeMethodCall(ctx, exec);
|
|
46
|
+
if (current.batch.length !== 1 || current.batch[0].name !== METHOD_TOOL || typeof exec.deferContext !== 'function')
|
|
47
|
+
reject('The method selection must be a standalone native tool call.');
|
|
48
|
+
const { receipt, bundle } = ctx.arcfoldMethods.reserve({ sessionId: current.sessionId, turn: current.turn,
|
|
49
|
+
startSeq: current.startSeq, callId: current.callId, contextSnapshotId: current.contextSnapshotId,
|
|
50
|
+
contextMessageId: current.contextMessageId, selections: args.selections,
|
|
51
|
+
maxCharacters: ctx.arcfoldBridge.maxContextCharacters });
|
|
52
|
+
// After reservation, cancellation/defer failure retains the occupied trial.
|
|
53
|
+
// The bridge requires the final successful native receipt before exposing guidance.
|
|
54
|
+
exec.signal.throwIfAborted();
|
|
55
|
+
exec.deferContext(methodContextMessage(bundle));
|
|
56
|
+
exec.signal.throwIfAborted();
|
|
57
|
+
return receipt;
|
|
58
|
+
},
|
|
59
|
+
presentCall: () => ({ card: 'generic', title: 'Select collaboration methods', kind: 'read' }),
|
|
60
|
+
}));
|
|
61
|
+
}
|