@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,745 @@
|
|
|
1
|
+
import { canonicalJson, digest, fail } from './contracts.mjs';
|
|
2
|
+
import { mkdirSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm';
|
|
6
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
7
|
+
import { TreeRepository } from './repository.mjs';
|
|
8
|
+
import { TreeTestLedger, TREE_TEST_SCOPE } from './experiment-budget.mjs';
|
|
9
|
+
import { BudgetLedger } from '../budget.mjs';
|
|
10
|
+
import { openOwnedWebBudget } from '../web-budget-recovery.mjs';
|
|
11
|
+
import { DeepSeekTransport } from '../deepseek-transport.mjs';
|
|
12
|
+
import { registerTreeRequestAuthority, ownEvidenceDirectory } from '../native-request-authority.mjs';
|
|
13
|
+
import { ScriptArtifacts, MethodScriptBroker } from './scripts.mjs';
|
|
14
|
+
import { SnapshotService, ConfinedWorkspaceIO } from './snapshots.mjs';
|
|
15
|
+
import { canonicalPath, processPolicy, beneath } from './host-process.mjs';
|
|
16
|
+
import { TreeJobs } from './jobs.mjs';
|
|
17
|
+
import { TreeModelOwner } from './model-owner.mjs';
|
|
18
|
+
import { NativeSources, ExperienceService } from './experience.mjs';
|
|
19
|
+
import { ContextBuilder, mountTreeContext } from './context.mjs';
|
|
20
|
+
import { AttributionService } from './attribution.mjs';
|
|
21
|
+
import { ConsolidationService } from './consolidation.mjs';
|
|
22
|
+
import { LearningService, WriterRouter, itemInput } from './learning.mjs';
|
|
23
|
+
import { VerificationService, ConsolidationReflection } from './verification.mjs';
|
|
24
|
+
import { EpisodeSignals, evidenceResolver } from './signals.mjs';
|
|
25
|
+
import { ClusterService } from './clusters.mjs';
|
|
26
|
+
import { ImprovementService } from './improvement.mjs';
|
|
27
|
+
import { MetricsService, ReflectionTriggers } from './metrics.mjs';
|
|
28
|
+
import { LocalReports } from './reports.mjs';
|
|
29
|
+
import { HookService, mountHookContext } from './hooks.mjs';
|
|
30
|
+
import { TreeUIService, bindTurnSnapshot } from './ui.mjs';
|
|
31
|
+
import { mountTreeTools, nativeTreeCall } from './tools.mjs';
|
|
32
|
+
import { TreeTokenCounter } from './token-counter.mjs';
|
|
33
|
+
|
|
34
|
+
const program = { kind: 'program', id: 'tree-runtime' };
|
|
35
|
+
const done = new Set(['committed', 'failed', 'invalid_output', 'stale', 'cancelled']);
|
|
36
|
+
const busy = new Set(['NODE_BUSY', 'NODE_ORDER', 'LEARNING_UNKNOWN', 'LEARNING_PAUSED', 'WORKER_BUSY', 'WRITER_BUSY']);
|
|
37
|
+
|
|
38
|
+
// Keep preparation and the last synchronous dispatch check in one admission
|
|
39
|
+
// interval. Requests already on the wire may overlap; no paid request is retried.
|
|
40
|
+
class AdmissionQueue {
|
|
41
|
+
constructor(limit) { this.limit = limit; this.active = 0; this.waiters = []; }
|
|
42
|
+
acquire(signal) {
|
|
43
|
+
signal?.throwIfAborted();
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const entry = { grant: () => {
|
|
46
|
+
signal?.removeEventListener('abort', abort); this.active++;
|
|
47
|
+
let released = false;
|
|
48
|
+
resolve(() => { if (released) return; released = true; this.active--; this.drain(); });
|
|
49
|
+
} };
|
|
50
|
+
const abort = () => { this.waiters = this.waiters.filter(v => v !== entry); reject(signal.reason); };
|
|
51
|
+
signal?.addEventListener('abort', abort, { once: true }); this.waiters.push(entry); this.drain();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
drain() { while (this.active < this.limit && this.waiters.length) this.waiters.shift().grant(); }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
class RuntimeContextBuilder extends ContextBuilder {
|
|
58
|
+
capture(sessionId, role = 'main') {
|
|
59
|
+
const { key: _key, ...value } = super.capture(sessionId, role);
|
|
60
|
+
const branchId = this.repo.meta().attributionSelections?.[sessionId]?.nodeId;
|
|
61
|
+
if (branchId) {
|
|
62
|
+
const ids = new Set(this.repo.list('tree_nodes', n => !n.deleted && this.repo.ancestors(n.id).some(a => a.id === branchId)).map(n => n.id));
|
|
63
|
+
value.segments = value.segments.map(segment => {
|
|
64
|
+
if (segment.key !== 'global-overview') return segment;
|
|
65
|
+
const overview = JSON.parse(segment.text).globalOverview.filter(n => ids.has(n.id));
|
|
66
|
+
return { ...segment, text: canonicalJson({ globalOverview: overview, selectedBranchId: branchId }), version: digest([branchId, overview]),
|
|
67
|
+
sourceRefs: overview.flatMap(n => this.summary(this.repo.get('tree_nodes', n.id))?.sourceRefs ?? []), summaryVersions: segment.summaryVersions.filter(s => ids.has(s.nodeId)) };
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (role === 'main') {
|
|
71
|
+
const turn = this.repo.get('tree_turns', value.turnId), sources = turn.userSourceRefs.map(this.resolveSource);
|
|
72
|
+
const authority = { logicalTurnId: turn.id, assignmentRevision: turn.assignmentRevision,
|
|
73
|
+
nodes: turn.nodeIds.map(id => ({ id, expectedRevision: this.repo.get('tree_nodes', id).revision })),
|
|
74
|
+
evidence: sources.filter(Boolean).map(s => ({ id: s.id, messageId: s.ref.messageId })), fileRefs: [],
|
|
75
|
+
instruction: '工具参数的节点版本和证据编号必须逐字使用本段当前值;此编号表不增加执行授权。' };
|
|
76
|
+
value.segments = [...value.segments, { key: `turn-authority:${turn.id}`, nodeId: null, version: digest(authority), text: canonicalJson(authority), sourceRefs: turn.userSourceRefs, itemVersions: [], summaryVersions: [] }];
|
|
77
|
+
value.renderedHash = digest(value.segments); value.bytes = Buffer.byteLength(value.segments.map(s => s.text).join('\n'));
|
|
78
|
+
}
|
|
79
|
+
return { ...value, key: digest(value) };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Composition around DSH's existing loop. This scheduler runs owned jobs only;
|
|
84
|
+
* it never follows up a main Agent or invents another human turn. */
|
|
85
|
+
export class TreeRuntime {
|
|
86
|
+
constructor({ repository, host, createUserMessage, defineTool, modelConfig, ledgerLookup, resourcesFor, artifacts, createWriterOptions,
|
|
87
|
+
jobs, owner, inputTokenCount, tokenCounter, annotations, browserScope, snapshotsFor, followConversationModel = false }) {
|
|
88
|
+
Object.assign(this, { repo: repository, host, createUserMessage, defineTool, modelConfig, resourcesFor });
|
|
89
|
+
this.logs = new Map(); this.mainAgents = new Map(); this.settlements = new Map(); this.workers = new Map(); this.closed = false;
|
|
90
|
+
this.admissions = new AdmissionQueue(1); this.requestSlots = new AdmissionQueue(2); this.mainAdmissions = new Map(); this.shutdown = new AbortController();
|
|
91
|
+
this.enabled = true;
|
|
92
|
+
this.sources = new NativeSources({ readSession: id => ({ sessionId: id, durable: this.logs.has(id), events: this.logs.get(id) }) });
|
|
93
|
+
this.resolveSource = evidenceResolver(repository, ref => this.sources.resolve(ref));
|
|
94
|
+
const options = { repository, resolveSource: this.resolveSource, sources: this.sources, context: () => ({ actor: program }) };
|
|
95
|
+
this.experience = new ExperienceService(options); this.builder = new RuntimeContextBuilder(options);
|
|
96
|
+
this.jobs = jobs ?? new TreeJobs({ repository, assertSources: rows => rows.every(s => Boolean(this.resolveSource(s.ref))) });
|
|
97
|
+
this.jobs.assertSources = rows => rows.every(s => Boolean(this.resolveSource(s.ref)));
|
|
98
|
+
this.owner = owner ?? new TreeModelOwner({ jobs: this.jobs, host, createUserMessage, ledgerLookup, inputTokenCount });
|
|
99
|
+
this.tokenCounter = tokenCounter;
|
|
100
|
+
if (tokenCounter) { this.owner.prepareInput = options => tokenCounter.prepare(options); this.owner.inputTokenCount = options => tokenCounter.count(options); }
|
|
101
|
+
this.owner.verifyMainInput = (job, request) => this.verifyMainInput(job, request);
|
|
102
|
+
this.owner.applyResult = (result, context) => this.applyResult(result, context);
|
|
103
|
+
this.owner.beforeSubmit = (job, agent) => this.beforeSubmit(job, agent);
|
|
104
|
+
// Attribution is already enclosed in the main admission interval. Other
|
|
105
|
+
// roles may overlap on the wire, but commit only between main admissions.
|
|
106
|
+
this.owner.beforeResult = job => job.role === 'attribution' ? undefined : this.admissions.acquire(this.shutdown.signal);
|
|
107
|
+
this.consolidation = new ConsolidationService({ repository, experience: this.experience, contextBuilder: this.builder, jobs: this.jobs });
|
|
108
|
+
// Bind background work to the originating durable job rather than to a
|
|
109
|
+
// mutable global selection shared by several conversations.
|
|
110
|
+
const sourceForBackground = (origin = {}) => {
|
|
111
|
+
if (!followConversationModel) return null;
|
|
112
|
+
let parent = origin.parentJobId && this.repo.get('tree_jobs', origin.parentJobId);
|
|
113
|
+
if (origin.parentJobId && !parent) fail('MODEL_SOURCE_MISSING', 'The originating background model job is unavailable');
|
|
114
|
+
if (!parent && origin.turnId) {
|
|
115
|
+
parent = this.repo.list('tree_jobs', j => j.role === 'main' && j.trigger?.turnId === origin.turnId)[0];
|
|
116
|
+
if (!parent) fail('MODEL_SOURCE_MISSING', 'The originating conversation model job is unavailable');
|
|
117
|
+
}
|
|
118
|
+
if (!parent && origin.nodeIds?.length) parent = this.repo.list('tree_jobs', j => j.role === 'main' && j.status === 'committed' && j.nodeIds.some(id => origin.nodeIds.includes(id)))
|
|
119
|
+
.sort((a, b) => (b.enqueuedOrder ?? 0) - (a.enqueuedOrder ?? 0) || b.id.localeCompare(a.id))[0];
|
|
120
|
+
return parent ?? null;
|
|
121
|
+
};
|
|
122
|
+
this.backgroundModelConfig = (role, origin = {}) => {
|
|
123
|
+
if (!followConversationModel) return modelConfig(role);
|
|
124
|
+
if (origin.agent) return modelConfig(role, origin.agent, origin.selection ?? origin.agent.model?.current?.() ?? origin.agent.model);
|
|
125
|
+
return modelConfig(role, undefined, sourceForBackground(origin)?.modelConfig);
|
|
126
|
+
};
|
|
127
|
+
this.backgroundModelConfig.source = sourceForBackground;
|
|
128
|
+
this.writers = new WriterRouter({ repository, jobs: this.jobs, modelConfig: this.backgroundModelConfig, artifacts, createWriterOptions, defineTool });
|
|
129
|
+
this.owner.mountWriterTools = (ctx, job) => this.writers.mount(ctx, job);
|
|
130
|
+
this.signals = new EpisodeSignals({ repository, resolveSource: this.resolveSource, experience: this.experience });
|
|
131
|
+
this.clusters = new ClusterService({ repository, signals: this.signals });
|
|
132
|
+
const common = { repository, jobs: this.jobs, modelConfig: this.backgroundModelConfig, experience: this.experience, contextBuilder: this.builder, writerRouter: this.writers };
|
|
133
|
+
this.learning = new LearningService({ ...common, consolidation: this.consolidation, signals: this.signals, annotations });
|
|
134
|
+
this.verification = new VerificationService({ ...common, clusters: this.clusters });
|
|
135
|
+
this.reflection = new ConsolidationReflection({ repository, consolidation: this.consolidation, resolveSource: this.resolveSource, writerRouter: this.writers });
|
|
136
|
+
this.reports = new LocalReports({ repository, resolveSource: this.resolveSource });
|
|
137
|
+
this.improvement = new ImprovementService({ ...common, reports: this.reports });
|
|
138
|
+
this.metrics = new MetricsService({ repository, experience: this.experience, signals: this.signals });
|
|
139
|
+
this.triggers = new ReflectionTriggers({ repository, metrics: this.metrics, clusters: this.clusters, verification: this.verification });
|
|
140
|
+
this.attribution = new AttributionService({ repository, sources: this.sources, contextBuilder: this.builder, jobs: this.jobs, modelOwner: this.owner,
|
|
141
|
+
modelConfig: (agent, selection) => this.backgroundModelConfig('attribution', { agent, selection }), cancelJob: id => this.owner.handles.get(id)?.agent.cancel({ kind: 'user', message: 'Attribution cancelled' }, { keepInbox: false }) });
|
|
142
|
+
this.ui = new TreeUIService({ repository, resolveSource: this.resolveSource, metrics: this.metrics, reports: this.reports,
|
|
143
|
+
attribution: this.attribution, selectBranch: (payload, context, requestId) => this.selectBranch(payload, context, requestId),
|
|
144
|
+
snapshotsFor: snapshotsFor ?? (context => {
|
|
145
|
+
const session = this.host.sessions.get(context.sessionId);
|
|
146
|
+
if (!session) fail('SNAPSHOT_UNAVAILABLE', 'Open the originating session before restoring its workspace');
|
|
147
|
+
return this.resourcesFor(this.host.workingDirectory.get(session), context).snapshots;
|
|
148
|
+
}), browserScope: browserScope ?? (context => {
|
|
149
|
+
const known = this.mainAgents.has(context.sessionId) || this.repo.list('tree_turns', t => t.sessionId === context.sessionId).length;
|
|
150
|
+
return known ? { nodeIds: this.repo.list('tree_nodes', n => !n.deleted).map(n => n.id) } : null;
|
|
151
|
+
}) });
|
|
152
|
+
}
|
|
153
|
+
diagnostic(kind, error, extra = {}) {
|
|
154
|
+
const value = { kind, code: error.code ?? 'HOST_ERROR', message: error.message, ...extra };
|
|
155
|
+
this.repo.command('recordTreeRuntimeDiagnostic', { requestId: digest(['runtime-diagnostic', value]), expected: {}, input: value }, program, () => {
|
|
156
|
+
const meta = this.repo.meta(); this.repo.save('tree_meta', { ...meta, runtimeDiagnostics: { ...meta.runtimeDiagnostics, [digest(value)]: { ...value, at: this.repo.now() } } }, meta.revision); return value;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
async freezeSession(session) {
|
|
160
|
+
const before = session.snapshotEvents();
|
|
161
|
+
if (await this.host.sessions.flush(session) !== true) fail('NATIVE_NOT_DURABLE', 'Native originals must be flushed before tree processing');
|
|
162
|
+
const reader = await this.host.sessionPersistence.open(session.id, 'read');
|
|
163
|
+
try {
|
|
164
|
+
const packet = await reader.read(), events = packet.events;
|
|
165
|
+
if (digest(events.slice(0, before.length)) !== digest(before)) fail('NATIVE_NOT_DURABLE', 'Native durable prefix disagrees with observation');
|
|
166
|
+
this.logs.set(session.id, events); return events;
|
|
167
|
+
} finally { await reader.close(); }
|
|
168
|
+
}
|
|
169
|
+
async recover() {
|
|
170
|
+
// Read historical sources before checking a received job. No Agent is resumed.
|
|
171
|
+
const ids = new Set(this.repo.list('tree_turns').map(t => t.sessionId));
|
|
172
|
+
for (const id of ids) {
|
|
173
|
+
const live = this.host.sessions.get(id);
|
|
174
|
+
if (live) await this.freezeSession(live);
|
|
175
|
+
else { let reader; try { reader = await this.host.sessionPersistence.open(id, 'read'); this.logs.set(id, (await reader.read()).events); }
|
|
176
|
+
catch (error) { if (error.name !== 'SessionPersistenceNotFoundError') throw error; this.logs.set(id, []); this.diagnostic('source_missing', error, { sessionId: id }); }
|
|
177
|
+
finally { await reader?.close(); } }
|
|
178
|
+
}
|
|
179
|
+
const recovered = await this.owner.recover();
|
|
180
|
+
for (const job of this.repo.list('tree_jobs', j => j.role === 'main' && j.status === 'prepared')) {
|
|
181
|
+
// Native human sessions are never automatically resumed by this worker.
|
|
182
|
+
// A prepared main has not crossed submit and cannot own a paid request.
|
|
183
|
+
if (this.host.sessions.get(job.nativeSessionId)) continue;
|
|
184
|
+
if (job.invocationIds.length) fail('JOB_CORRUPT', 'Prepared main unexpectedly owns an invocation');
|
|
185
|
+
this.jobs.transition(job.id, 'cancelled', { error: { code: 'UNSENT_RESTART', message: 'Host restarted before main submission; original input retained, no automatic replay' } });
|
|
186
|
+
}
|
|
187
|
+
for (const turn of this.repo.list('tree_turns', t => !['settled', 'interrupted', 'failed'].includes(t.status))) {
|
|
188
|
+
try { this.settleTurn(turn.id, { recovery: true }); } catch (error) { this.diagnostic('experience_recovery', error, { turnId: turn.id }); }
|
|
189
|
+
}
|
|
190
|
+
for (const job of this.repo.list('tree_jobs', j => j.role === 'learning' && j.status === 'failed')) this.handlePressure(job);
|
|
191
|
+
for (const job of this.repo.list('tree_jobs', j => j.role && j.status === 'committed')) this.afterCommit(job);
|
|
192
|
+
this.kick(); return recovered;
|
|
193
|
+
}
|
|
194
|
+
applyResult(result, context) {
|
|
195
|
+
const { job } = context;
|
|
196
|
+
if (job.role === 'main') return { logicalTurnId: job.trigger.turnId, nativeTurn: job.trigger.nativeTurn };
|
|
197
|
+
if (job.role === 'consolidation' && job.trigger.nativeCompaction) {
|
|
198
|
+
if (result.broaden.length || result.retrospect.understandings.length || result.retrospect.methods.length) fail('COMPACTION_OUTPUT', 'Native compaction cannot modify tree learning');
|
|
199
|
+
return { nativeCompaction: job.input.nativeCompaction.inputHash, summary: result.summary };
|
|
200
|
+
}
|
|
201
|
+
if (job.role === 'attribution') {
|
|
202
|
+
const before = this.repo.list('tree_nodes'), receipt = this.attribution.applyResult(result, context);
|
|
203
|
+
this.metrics.observeTree(receipt, before, { logicalTurnId: receipt.result.turnId }); return receipt;
|
|
204
|
+
}
|
|
205
|
+
const services = { learning: this.learning, consolidation: this.reflection, verification: this.verification, improvement: this.improvement, script_writer: this.writers };
|
|
206
|
+
return services[job.role].applyResult(result, context);
|
|
207
|
+
}
|
|
208
|
+
mainJob(sessionId, nativeTurn) { return this.repo.list('tree_jobs', j => j.role === 'main' && j.trigger.nativeSessionId === sessionId && j.trigger.nativeTurn === nativeTurn)[0]; }
|
|
209
|
+
verifyMainInput(job, request) {
|
|
210
|
+
const agent = this.mainAgents.get(request.sessionId), scope = this.builder.scope(request.sessionId), turn = this.repo.get('tree_turns', job.trigger.turnId);
|
|
211
|
+
if (!agent || job.role !== 'main' || scope?.phase !== 'active' || scope.turnId !== turn?.id || turn.assignmentRevision !== job.input.assignmentRevision) return false;
|
|
212
|
+
if (!job.input.sources.every(source => request.messages.some(m => m.source?.kind === 'user' && m.id === source.ref.messageId && canonicalJson(m.content) === canonicalJson(source.content)))) return false;
|
|
213
|
+
const mounted = this.builder.mounted.get(request.sessionId), snapshot = this.builder.capture(request.sessionId);
|
|
214
|
+
return mounted?.snapshotKey === snapshot.key && canonicalJson(request.messages.filter(m => m.source?.kind === 'arcfold/tree-context')) === canonicalJson(mounted.messages);
|
|
215
|
+
}
|
|
216
|
+
hooks(sessionId) {
|
|
217
|
+
const agent = this.mainAgents.get(sessionId); if (!agent) fail('MAIN_REQUIRED', 'Only an attached native main session has hook authority');
|
|
218
|
+
return new HookService({ repository: this.repo, readScope: id => this.builder.scope(id), resolveSource: this.resolveSource, workspace: this.host.workingDirectory.get(agent.session) });
|
|
219
|
+
}
|
|
220
|
+
async prepare(payload, decision) {
|
|
221
|
+
if (decision.kind === 'reject' || !decision.messages.length) return decision;
|
|
222
|
+
const { agent, turn: nativeTurn, signal } = payload, sessionId = agent.session.id;
|
|
223
|
+
signal.throwIfAborted(); await this.settlements.get(sessionId);
|
|
224
|
+
const combined = AbortSignal.any([signal, this.shutdown.signal]), slot = await this.requestSlots.acquire(combined);
|
|
225
|
+
let release;
|
|
226
|
+
try { release = await this.admissions.acquire(combined); }
|
|
227
|
+
catch (error) { slot(); throw error; }
|
|
228
|
+
this.mainAdmissions.set(sessionId, { release, slot });
|
|
229
|
+
try { return await this.prepareAdmitted(payload, decision); }
|
|
230
|
+
catch (error) { this.releaseMain(sessionId); throw error; }
|
|
231
|
+
}
|
|
232
|
+
releaseMain(sessionId, preparationOnly = false) {
|
|
233
|
+
const entry = this.mainAdmissions.get(sessionId); if (!entry) return;
|
|
234
|
+
entry.release();
|
|
235
|
+
if (!preparationOnly) { entry.slot(); this.mainAdmissions.delete(sessionId); }
|
|
236
|
+
}
|
|
237
|
+
async prepareAdmitted(payload, decision) {
|
|
238
|
+
const { agent, turn: nativeTurn, signal } = payload, sessionId = agent.session.id;
|
|
239
|
+
signal.throwIfAborted(); await this.freezeSession(agent.session);
|
|
240
|
+
let turn = this.attribution.existing(sessionId, nativeTurn), fresh = !turn;
|
|
241
|
+
if (fresh) {
|
|
242
|
+
if (!decision.messages.some(m => m.source?.kind === 'user')) { this.releaseMain(sessionId); return { kind: 'reject' }; }
|
|
243
|
+
this.consumeBranchContinuation(sessionId, nativeTurn, decision.messages);
|
|
244
|
+
if (this.attribution.capacity(sessionId).blocked) {
|
|
245
|
+
this.deferForBranch(sessionId, nativeTurn, decision.messages);
|
|
246
|
+
this.releaseMain(sessionId); return { kind: 'reject' };
|
|
247
|
+
}
|
|
248
|
+
const workspace = this.host.workingDirectory.get(agent.session), resources = this.resourcesFor(workspace);
|
|
249
|
+
let snapshot;
|
|
250
|
+
try { snapshot = await resources.snapshots.capture({ requestId: digest(['main-snapshot', sessionId, nativeTurn]), reason: 'main_turn', signal }); }
|
|
251
|
+
catch (error) { this.diagnostic('snapshot_unavailable', error, { sessionId, nativeTurn }); }
|
|
252
|
+
turn = await this.attribution.attribute({ sessionId, nativeTurn, messages: decision.messages, signal, agent, modelSelection: payload.modelSelection });
|
|
253
|
+
if (!turn) { this.releaseMain(sessionId); return { kind: 'reject' }; }
|
|
254
|
+
if (snapshot) bindTurnSnapshot(this.repo, snapshot.id, turn.id);
|
|
255
|
+
}
|
|
256
|
+
const hooks = this.hooks(sessionId), contextMessages = [];
|
|
257
|
+
const add = result => { const value = hooks.message(result); if (value) contextMessages.push(this.createUserMessage(value)); };
|
|
258
|
+
const scope = await this.builder.activate(sessionId, turn.id, { cancelPrevious: async leaving => {
|
|
259
|
+
for (const event of leaving.leaveEvents) {
|
|
260
|
+
// An explicit owner undo may remove the old target after its turn.
|
|
261
|
+
// Its hooks are no longer eligible; still cancel the old session below.
|
|
262
|
+
const node = this.repo.get('tree_nodes', event.nodeId);
|
|
263
|
+
if (node && !node.deleted) add(hooks.evaluate({ sessionId, moment: 'node_leave', nodeId: event.nodeId, eventKey: `${leaving.token}:leave:${event.nodeId}`, fields: {}, fileEffects: 'unknown', signal }));
|
|
264
|
+
}
|
|
265
|
+
this.resourcesFor(this.host.workingDirectory.get(agent.session)).cancelSession?.(sessionId);
|
|
266
|
+
} });
|
|
267
|
+
if (fresh) {
|
|
268
|
+
for (const event of scope.enterEvents) add(hooks.evaluate({ sessionId, moment: 'node_enter', nodeId: event.nodeId, eventKey: `${scope.token}:enter:${event.nodeId}`, fields: { nodeId: event.nodeId, nodeName: this.repo.get('tree_nodes', event.nodeId).name }, signal }));
|
|
269
|
+
add(hooks.evaluate({ sessionId, moment: 'user_message', eventKey: `${turn.id}:human`, fields: { userMessage: this.sources.claimed(sessionId, decision.messages).map(s => s.text).join('\n') }, origin: 'user', signal }));
|
|
270
|
+
}
|
|
271
|
+
let job = this.mainJob(sessionId, nativeTurn);
|
|
272
|
+
if (!job) {
|
|
273
|
+
const sources = turn.userSourceRefs.map(this.resolveSource), context = this.builder.build(turn.nodeIds), rows = this.builder.activeItems(context.ancestorPaths.flat());
|
|
274
|
+
job = this.jobs.enqueue({ role: 'main', key: turn.id, nodeIds: turn.nodeIds, revisionSet: {}, config: this.modelConfig('main', agent, payload.modelSelection),
|
|
275
|
+
trigger: { kind: 'user_turn', producerRole: 'user', origin: 'user', nativeSessionId: sessionId, nativeTurn, turnId: turn.id },
|
|
276
|
+
input: { scope: { nodeIds: turn.nodeIds, ancestorPaths: context.ancestorPaths, treeRevision: context.treeRevision }, sources, knownItems: rows.map(itemInput), assignmentRevision: turn.assignmentRevision,
|
|
277
|
+
allowedIds: { nodeIds: context.ancestorPaths.flat(), subjectIds: rows.map(r => r.item.id), fileIds: [] }, changeReceipts: [] } });
|
|
278
|
+
this.jobs.prepare(job.id); job = this.jobs.submit(job.id);
|
|
279
|
+
}
|
|
280
|
+
if (job.status !== 'submitted') fail('MAIN_RESULT_UNKNOWN', 'This native turn already has a result or unresolved attempt; do not resend');
|
|
281
|
+
return { ...decision, messages: [...decision.messages, ...contextMessages] };
|
|
282
|
+
}
|
|
283
|
+
saveBranchPending(value, requestId) {
|
|
284
|
+
return this.repo.command('recordAttributionPending', { requestId, expected: {}, input: value }, program, () => {
|
|
285
|
+
const meta = this.repo.meta(); this.repo.save('tree_meta', { ...meta, attributionPending: { ...meta.attributionPending, [value.id]: value } }, meta.revision); return value;
|
|
286
|
+
}).result;
|
|
287
|
+
}
|
|
288
|
+
deferForBranch(sessionId, nativeTurn, messages) {
|
|
289
|
+
const sources = this.sources.claimed(sessionId, messages), id = digest(['attribution-pending', sessionId, nativeTurn]);
|
|
290
|
+
if (!sources.length) fail('PROGRAM_NOT_USER', 'Only an original human input can wait for branch selection');
|
|
291
|
+
const old = this.repo.meta().attributionPending?.[id]; if (old) return old;
|
|
292
|
+
return this.saveBranchPending({ id, sessionId, nativeTurn, sourceRefs: sources.map(s => s.ref), state: 'waiting', revision: 1,
|
|
293
|
+
preview: sources.map(s => s.text).join('\n').slice(0, 2000), at: this.repo.now() }, id);
|
|
294
|
+
}
|
|
295
|
+
consumeBranchContinuation(sessionId, nativeTurn, messages) {
|
|
296
|
+
const ids = new Set(messages.filter(m => m.source?.kind === 'user').map(m => m.id));
|
|
297
|
+
for (const pending of Object.values(this.repo.meta().attributionPending ?? {})) {
|
|
298
|
+
if (pending.sessionId !== sessionId || !['dispatch_intent', 'queued'].includes(pending.state) || !pending.sourceRefs.every(r => ids.has(r.messageId))) continue;
|
|
299
|
+
this.saveBranchPending({ ...pending, state: 'consumed', revision: pending.revision + 1, resumedNativeTurn: nativeTurn }, digest(['consume-branch', pending.id, nativeTurn]));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
async selectBranch(payload, context, requestId) {
|
|
303
|
+
const { sessionId, signal, actor } = context, agent = this.mainAgents.get(sessionId);
|
|
304
|
+
if (!agent || agent.status !== 'idle') fail('SESSION_BUSY', '请等当前对话结束,再选择目标范围。');
|
|
305
|
+
await this.settlements.get(sessionId);
|
|
306
|
+
const release = await this.admissions.acquire(AbortSignal.any([signal, this.shutdown.signal]));
|
|
307
|
+
try {
|
|
308
|
+
signal.throwIfAborted();
|
|
309
|
+
await this.freezeSession(agent.session);
|
|
310
|
+
if (agent.status !== 'idle') fail('SESSION_BUSY', '对话状态已变化,请刷新后再选择。');
|
|
311
|
+
const selection = this.attribution.selection(sessionId), meta = this.repo.meta();
|
|
312
|
+
if (meta.treeRevision !== payload.expectedTreeRevision || selection.revision !== payload.expectedSelectionRevision) fail('REVISION_CONFLICT', '目标或范围已变化,请刷新后选择。');
|
|
313
|
+
if (payload.nodeId !== null) {
|
|
314
|
+
const node = this.repo.get('tree_nodes', payload.nodeId);
|
|
315
|
+
if (!node || node.deleted) fail('NODE_NOT_FOUND', '选定的目标已不存在。');
|
|
316
|
+
const count = this.attribution.candidateNodes(this.repo.now(), node.id).length;
|
|
317
|
+
if (count > 2000) fail('CANDIDATE_CAPACITY', `这支仍有${count}个候选,超过2000个;请再选择下一级目标。`);
|
|
318
|
+
} else if (payload.pendingId && this.attribution.candidateNodes().length > 2000) fail('CANDIDATE_CAPACITY', '整棵树超量,请选定一支后再继续。');
|
|
319
|
+
let pending, messages;
|
|
320
|
+
if (payload.pendingId) {
|
|
321
|
+
pending = meta.attributionPending?.[payload.pendingId];
|
|
322
|
+
if (!pending || pending.sessionId !== sessionId || pending.state !== 'waiting') fail('CONTINUATION_UNAVAILABLE', '这条消息已经继续或发送结果待核对,不能重复发送。');
|
|
323
|
+
if (this.mainJob(sessionId, pending.nativeTurn) || this.attribution.existing(sessionId, pending.nativeTurn)) fail('CONTINUATION_UNAVAILABLE', '这轮已进入执行,不能从范围选择入口重发。');
|
|
324
|
+
const events = this.sources.events(sessionId);
|
|
325
|
+
messages = pending.sourceRefs.map(ref => {
|
|
326
|
+
const source = this.sources.resolve(ref), event = events[ref.seq], message = event?.data.inserted?.find(m => m.id === ref.messageId);
|
|
327
|
+
if (!source || source.origin !== 'user' || !message || events.some(e => e.type === 'user/message' && e.data.id === ref.messageId)) fail('CONTINUATION_UNAVAILABLE', '原话已经处理或原件无法核对,不能重发。');
|
|
328
|
+
return message;
|
|
329
|
+
});
|
|
330
|
+
if (agent.inbox.hasPending) fail('SESSION_BUSY', '还有待处理的输入,请先核对当前对话。');
|
|
331
|
+
}
|
|
332
|
+
this.repo.command('selectAttributionBranch', { requestId, expected: {}, input: { sessionId, ...payload } }, actor, () => {
|
|
333
|
+
const current = this.repo.meta();
|
|
334
|
+
const next = { nodeId: payload.nodeId, revision: selection.revision + 1, at: this.repo.now(), requestId };
|
|
335
|
+
// Persist the intent before touching DSH's inbox. An interrupted intent
|
|
336
|
+
// is visible and is never automatically replayed.
|
|
337
|
+
this.repo.save('tree_meta', { ...current, attributionSelections: { ...current.attributionSelections, [sessionId]: next },
|
|
338
|
+
...(pending ? { attributionPending: { ...current.attributionPending, [pending.id]: { ...pending, state: 'dispatch_intent', revision: pending.revision + 1, continuationRequestId: requestId } } } : {}) }, current.revision);
|
|
339
|
+
return next;
|
|
340
|
+
});
|
|
341
|
+
if (pending) {
|
|
342
|
+
// These are the same durable human messages, not fabricated follow-ups.
|
|
343
|
+
// Queue the full batch in one native step; only the last append wakes it.
|
|
344
|
+
messages.forEach((message, index) => agent.send(message, 'next-step', index === messages.length - 1));
|
|
345
|
+
await this.freezeSession(agent.session);
|
|
346
|
+
const current = this.repo.meta().attributionPending[pending.id];
|
|
347
|
+
this.saveBranchPending({ ...current, state: 'queued', revision: current.revision + 1 }, digest(['queue-branch', requestId]));
|
|
348
|
+
}
|
|
349
|
+
return { selection: this.attribution.selection(sessionId), continued: pending?.id ?? null };
|
|
350
|
+
} finally { release(); }
|
|
351
|
+
}
|
|
352
|
+
mountMain(ctx, agent) {
|
|
353
|
+
const sessionId = agent.session.id; this.mainAgents.set(sessionId, agent);
|
|
354
|
+
this.owner.attachMain(ctx);
|
|
355
|
+
mountHookContext(ctx, { service: this.hooks(sessionId), sessionId, createUserMessage: this.createUserMessage });
|
|
356
|
+
mountTreeContext(ctx, { builder: this.builder, sessionId, createUserMessage: this.createUserMessage });
|
|
357
|
+
// Prepare wraps the downstream host decision. It only admits accepted human
|
|
358
|
+
// input; subsequent tool/program steps reuse the existing logical turn.
|
|
359
|
+
ctx.on('agent/pre-step', async (payload, next) => this.prepare(payload, await next()));
|
|
360
|
+
ctx.on('llm/stream', (request, next) => {
|
|
361
|
+
if (request.sessionId !== sessionId) return next();
|
|
362
|
+
const runtime = this;
|
|
363
|
+
return (async function* () {
|
|
364
|
+
const job = runtime.owner.jobFor(sessionId), invocation = runtime.repo.list('tree_invocations', i => i.jobId === job.id).at(-1);
|
|
365
|
+
if (!invocation) fail('INVOCATION_GUARD', 'Native main must be admitted before exposure');
|
|
366
|
+
runtime.builder.expose(sessionId, invocation.id, invocation.stepStartSeq, runtime.builder.capture(sessionId));
|
|
367
|
+
try {
|
|
368
|
+
for await (const chunk of next()) {
|
|
369
|
+
if (runtime.owner.ledgerLookup(invocation.ledgerRequestId)?.dispatchedAt) runtime.releaseMain(sessionId, true);
|
|
370
|
+
yield chunk;
|
|
371
|
+
}
|
|
372
|
+
} finally { runtime.releaseMain(sessionId); }
|
|
373
|
+
})();
|
|
374
|
+
});
|
|
375
|
+
mountTreeTools(ctx, this);
|
|
376
|
+
this.mountToolHooks(ctx, agent);
|
|
377
|
+
ctx.on('agent/turn-stopping', payload => this.stopping(payload));
|
|
378
|
+
ctx.effect(() => () => { this.mainAgents.delete(sessionId); this.resourcesFor(this.host.workingDirectory.get(agent.session)).cancelSession?.(sessionId); }, 'release tree main adapter');
|
|
379
|
+
}
|
|
380
|
+
toolFields(exec) {
|
|
381
|
+
const fields = { toolName: exec.name };
|
|
382
|
+
if (exec.name === 'bash' && typeof exec.arguments.command === 'string') fields.command = exec.arguments.command;
|
|
383
|
+
if (['read', 'write', 'edit'].includes(exec.name) && typeof exec.arguments.file_path === 'string') fields.filePath = exec.arguments.file_path;
|
|
384
|
+
return fields;
|
|
385
|
+
}
|
|
386
|
+
turnEffects(agent) {
|
|
387
|
+
const events = agent.session.snapshotEvents(), start = events.findLast(e => e.type === 'turn/start');
|
|
388
|
+
const calls = events.filter(e => e.type === 'tool/call' && e.seq > (start?.seq ?? -1)), files = [];
|
|
389
|
+
const readOnly = new Set(['read', 'glob', 'grep', 'ask_user', 'todo', 'web_fetch', 'arcfold_read_method', 'arcfold_read_history', 'arcfold_mark_complete', 'arcfold_request_script']);
|
|
390
|
+
let complete = true;
|
|
391
|
+
for (const call of calls) {
|
|
392
|
+
if (readOnly.has(call.data.name)) continue;
|
|
393
|
+
const result = events.find(e => e.type === 'tool/result' && e.data.message?.toolCallId === call.data.callId);
|
|
394
|
+
if (['write', 'edit'].includes(call.data.name) && result && !result.data.message.isError) {
|
|
395
|
+
try { files.push(JSON.parse(call.data.arguments).file_path); } catch { complete = false; }
|
|
396
|
+
} else complete = false;
|
|
397
|
+
}
|
|
398
|
+
return { fields: { earlierToolsInTurn: calls.map(e => e.data.name), earlierFilesInTurn: [...new Set(files.filter(p => typeof p === 'string'))] }, fileEffects: complete ? 'complete' : 'unknown' };
|
|
399
|
+
}
|
|
400
|
+
mountToolHooks(ctx, agent) {
|
|
401
|
+
const hooks = this.hooks(agent.session.id), pending = new Map();
|
|
402
|
+
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
403
|
+
const base = await next(); if (!['allow', 'ask'].includes(base.kind)) return base;
|
|
404
|
+
await nativeTreeCall(ctx, this, exec);
|
|
405
|
+
const effects = this.turnEffects(agent);
|
|
406
|
+
// Exclude this pending call from the "earlier" projection.
|
|
407
|
+
effects.fields.earlierToolsInTurn = effects.fields.earlierToolsInTurn.slice(0, -1);
|
|
408
|
+
const result = hooks.evaluate({ sessionId: agent.session.id, moment: 'before_tool', eventKey: exec.callId, fields: { ...this.toolFields(exec), ...effects.fields }, fileEffects: effects.fileEffects, signal: exec.signal });
|
|
409
|
+
pending.set(exec.callId, result);
|
|
410
|
+
if (result.decision === 'deny') return { kind: 'deny', reason: '当前方法要求阻止这次工具操作。', info: { code: 'TREE_HOOK_DENIED' } };
|
|
411
|
+
if (result.decision === 'ask') return { kind: 'ask', reason: '当前方法要求本次操作先经用户确认。' };
|
|
412
|
+
return base;
|
|
413
|
+
});
|
|
414
|
+
ctx.tools.guard(exec => {
|
|
415
|
+
const result = pending.get(exec.callId); if (!result) return '当前工具调用没有完成目标范围检查。';
|
|
416
|
+
try { exec.signal.throwIfAborted(); hooks.assertCurrent(result.snapshot); if (result.decision === 'deny') return '当前方法拒绝这次工具操作。'; }
|
|
417
|
+
catch { return '目标范围或方法已改变,本次工具操作没有执行。'; }
|
|
418
|
+
});
|
|
419
|
+
ctx.on('tools/post-execute', async (exec, result, next) => {
|
|
420
|
+
const base = await next(), before = pending.get(exec.callId);
|
|
421
|
+
if (!before || exec.signal.aborted) return base;
|
|
422
|
+
const after = hooks.evaluate({ sessionId: agent.session.id, moment: 'after_tool', eventKey: exec.callId,
|
|
423
|
+
fields: { ...this.toolFields(exec), isError: result.isError, output: result.content.filter(b => b.type === 'text').map(b => b.text).join('\n') }, signal: exec.signal });
|
|
424
|
+
const contexts = [before, after].map(value => hooks.message(value)).filter(Boolean).map(value => this.createUserMessage(value));
|
|
425
|
+
return contexts.length ? { ...base, additionalContexts: [...(base.additionalContexts ?? []), ...contexts] } : base;
|
|
426
|
+
});
|
|
427
|
+
ctx.on('tools/result', exec => { pending.delete(exec.callId); });
|
|
428
|
+
}
|
|
429
|
+
async stopping({ agent, turn, signal }) {
|
|
430
|
+
signal.throwIfAborted();
|
|
431
|
+
const sessionId = agent.session.id, hooks = this.hooks(sessionId), events = agent.session.snapshotEvents(), start = events.findLast(e => e.type === 'turn/start').seq;
|
|
432
|
+
const own = events.filter(e => e.seq > start), effects = this.turnEffects(agent), fields = { toolsCalled: own.filter(e => e.type === 'tool/call').map(e => e.data.name), ...effects.fields, filesWritten: effects.fields.earlierFilesInTurn };
|
|
433
|
+
const result = hooks.evaluate({ sessionId, moment: 'turn_end', eventKey: `${turn}:${events.at(-1).seq}`, fields, fileEffects: effects.fileEffects, signal });
|
|
434
|
+
// A new human inbox entry invalidates a continuation, even when it arrived
|
|
435
|
+
// during the final provider response instead of cancelling that response.
|
|
436
|
+
if (!result.continuation || [...agent.inbox.nextTurn, ...agent.inbox.nextStep].some(m => m.source?.kind === 'user')) return;
|
|
437
|
+
const message = this.createUserMessage(hooks.message(result));
|
|
438
|
+
if (hooks.claimContinuation(result, message.id, signal).claimed) agent.steer(message);
|
|
439
|
+
}
|
|
440
|
+
observe(session, event) {
|
|
441
|
+
if (this.closed || !this.mainAgents.has(session.id) || event.type !== 'turn/end') return;
|
|
442
|
+
this.releaseMain(session.id);
|
|
443
|
+
const previous = this.settlements.get(session.id) ?? Promise.resolve();
|
|
444
|
+
const promise = previous.catch(() => {}).then(async () => {
|
|
445
|
+
const release = await this.admissions.acquire(this.shutdown.signal);
|
|
446
|
+
try {
|
|
447
|
+
await this.freezeSession(session); const turn = this.attribution.existing(session.id, event.data.turn); if (!turn) return;
|
|
448
|
+
const job = this.mainJob(session.id, event.data.turn);
|
|
449
|
+
if (job && !done.has(job.status)) {
|
|
450
|
+
try { this.owner.consume(job.id, this.logs.get(session.id)); }
|
|
451
|
+
catch (error) { if (this.jobs.get(job.id).status === 'submitted') this.jobs.transition(job.id, 'unknown', { error: { code: error.code ?? 'HOST_ERROR', message: error.message } }); this.diagnostic('main_settlement', error, { jobId: job.id }); }
|
|
452
|
+
}
|
|
453
|
+
this.settleTurn(turn.id); this.kick();
|
|
454
|
+
} finally { release(); }
|
|
455
|
+
}).catch(error => this.diagnostic('turn_settlement', error, { sessionId: session.id, nativeTurn: event.data.turn }));
|
|
456
|
+
this.settlements.set(session.id, promise); return promise;
|
|
457
|
+
}
|
|
458
|
+
settleTurn(turnId, { recovery = false } = {}) {
|
|
459
|
+
const turn = this.repo.get('tree_turns', turnId), events = this.sources.events(turn.sessionId);
|
|
460
|
+
const start = events.find(e => e.type === 'turn/start' && e.data.turn === turn.nativeTurn), end = start && events.find(e => e.type === 'turn/end' && e.seq > start.seq && e.data.turn === turn.nativeTurn);
|
|
461
|
+
const committedUser = end && events.some(e => e.type === 'user/message' && e.data.source?.kind === 'user' && e.seq > start.seq && e.seq < end.seq);
|
|
462
|
+
if (!end && recovery && !this.host.sessions.get(turn.sessionId)) {
|
|
463
|
+
if (!start || !turn.userSourceRefs.length || turn.userSourceRefs.some(ref => this.resolveSource(ref)?.origin !== 'user')) fail('SOURCE_CHANGED', 'Unfinished recovered turn has no durable original');
|
|
464
|
+
const own = events.filter(e => e.seq >= start.seq), hash = digest(own);
|
|
465
|
+
return this.repo.command('recordInterruptedTreeRecovery', { requestId: digest(['recovered-interruption', turnId]), expected: {}, input: { turnId, hash } }, program, () => {
|
|
466
|
+
const current = this.repo.get('tree_turns', turnId), refs = [...turn.userSourceRefs, ...own.map(e => this.sources.project(turn.sessionId, e)).filter(s => s && s.role !== 'user').map(s => s.ref)];
|
|
467
|
+
this.repo.save('tree_turns', { ...current, status: 'interrupted', settledAt: this.repo.now(), experienceRefs: refs, experienceHash: hash, nativeEndSeq: null,
|
|
468
|
+
recoveryEvidence: { reason: 'host_restart_without_durable_end', throughSeq: own.at(-1)?.seq, hash }, nativeEndReason: null }, current.revision);
|
|
469
|
+
return { turnId, status: 'interrupted', learningIntents: [], nativeEndMissing: true };
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
if (!end || committedUser || end.data.reason?.kind === 'completed') return this.experience.settle(turnId);
|
|
473
|
+
// A cancellation/error during attribution can precede the native input
|
|
474
|
+
// commit. Keep the genuine durable inbox reference, never invent a user
|
|
475
|
+
// message or schedule learning for a conversation that did not run.
|
|
476
|
+
if (!turn.userSourceRefs.length || turn.userSourceRefs.some(ref => this.resolveSource(ref)?.origin !== 'user')) fail('SOURCE_CHANGED', 'Interrupted input lost its durable original');
|
|
477
|
+
const own = events.filter(e => e.seq >= start.seq && e.seq <= end.seq), hash = digest(own), status = end.data.reason.kind === 'error' ? 'failed' : 'interrupted';
|
|
478
|
+
return this.repo.command('settleInterruptedTreeAdmission', { requestId: digest(['interrupted-admission', turnId]), expected: {}, input: { turnId, hash, endSeq: end.seq } }, program, () => {
|
|
479
|
+
const current = this.repo.get('tree_turns', turnId);
|
|
480
|
+
this.repo.save('tree_turns', { ...current, status, settledAt: this.repo.now(), experienceRefs: turn.userSourceRefs, experienceHash: hash, nativeEndSeq: end.seq, nativeEndReason: end.data.reason, inputCommitted: false }, current.revision);
|
|
481
|
+
return { turnId, status, inputCommitted: false, learningIntents: [] };
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
enqueueSummary(plan) {
|
|
485
|
+
const frozen = this.consolidation.freeze(plan), key = digest([plan.nodeId, plan.sourceWatermark, plan.reason]);
|
|
486
|
+
const old = this.repo.get('tree_jobs', digest(['model_job', 'consolidation', key])); if (old) return old;
|
|
487
|
+
return this.jobs.enqueue({ role: 'consolidation', key, nodeIds: [plan.nodeId], input: frozen.input, revisionSet: frozen.revisions, config: this.backgroundModelConfig('consolidation', { turnId: plan.throughTurnIds.at(-1), nodeIds: [plan.nodeId] }), trigger: { kind: plan.reason === 'node_leave' ? 'leave_timeout' : plan.reason, modelSourceTurnId: plan.throughTurnIds.at(-1) } });
|
|
488
|
+
}
|
|
489
|
+
afterCommit(job) {
|
|
490
|
+
if (job.status !== 'committed') return;
|
|
491
|
+
if (job.trigger.nativeCompaction) return;
|
|
492
|
+
if (job.role === 'script_writer') this.writers.activateReady(job.input.binding.methodId, job.input.binding.draftVersion);
|
|
493
|
+
if (job.role === 'consolidation') {
|
|
494
|
+
this.consolidation.finishEpoch(job.id);
|
|
495
|
+
const summary = this.repo.list('tree_summaries', s => s.jobId === job.id)[0];
|
|
496
|
+
if (summary && summary.reason !== 'threshold') this.triggers.scan(summary.nodeId, { summaryId: summary.id, parentJobId: job.id });
|
|
497
|
+
}
|
|
498
|
+
if (job.role === 'learning') for (const nodeId of job.nodeIds) this.triggers.scan(nodeId, { parentJobId: job.id });
|
|
499
|
+
}
|
|
500
|
+
async beforeSubmit(job, agent) {
|
|
501
|
+
if (job.role !== 'learning' || !this.tokenCounter) return;
|
|
502
|
+
const options = { provider: job.modelConfig.provider, model: job.modelConfig.model, reasoningEffort: job.modelConfig.reasoningEffort, maxTokens: job.modelConfig.maxTokens,
|
|
503
|
+
messages: [{ role: 'system', content: [{ type: 'text', text: job.prompt.text }] }, ...agent.session.deriveMessages().filter(m => m.role !== 'system'), job.nativeMessage],
|
|
504
|
+
tools: [], toolHistory: { tools: [], updates: [] }, signal: this.shutdown.signal };
|
|
505
|
+
const reading = await this.tokenCounter.prepare(options), fullRequest = this.tokenCounter.request(options);
|
|
506
|
+
const model = await this.host.llm.resolveModelInfo(job.modelConfig.provider, job.modelConfig.model, this.shutdown.signal);
|
|
507
|
+
const pressure = this.consolidation.pressure({ fullRequest, measure: () => reading, contextWindow: model.context?.contextWindow, maxOutputTokens: job.modelConfig.maxTokens });
|
|
508
|
+
if (pressure.needsSummary) {
|
|
509
|
+
this.diagnostic('learning_pressure', { code: 'LEARNING_PRESSURE', message: 'Learning append stopped before submission' }, { jobId: job.id, measurement: pressure });
|
|
510
|
+
fail('LEARNING_PRESSURE', `Exact input ${pressure.tokens} exceeds ${pressure.limit}; original input retained and no request submitted`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
handlePressure(job) {
|
|
514
|
+
if (job.role !== 'learning' || job.status !== 'failed' || job.error?.code !== 'LEARNING_PRESSURE' || job.submittedAt || job.invocationIds.length) return false;
|
|
515
|
+
const nodeId = job.nodeIds[0], session = this.repo.list('tree_learning_sessions', s => s.nodeId === nodeId)[0];
|
|
516
|
+
if (session?.contextEpoch !== job.contextEpoch) return true;
|
|
517
|
+
this.repo.command('handleLearningPressure', { requestId: digest(['learning-pressure', job.id]), expected: {}, input: { jobId: job.id, contextEpoch: job.contextEpoch } }, program, () => {
|
|
518
|
+
const plan = this.consolidation.plan(nodeId, 'threshold');
|
|
519
|
+
this.jobs.pauseLearning(nodeId, '精确输入计数达到上限,等待整理;原文保留。');
|
|
520
|
+
if (plan && plan.sourceWatermark !== session.sourceWatermark) return { summaryJobId: this.enqueueSummary(plan).id };
|
|
521
|
+
this.diagnostic('learning_capacity', { code: 'LEARNING_CAPACITY', message: 'No older material can be summarized while retaining the last three learning turns' }, { jobId: job.id, nodeId });
|
|
522
|
+
return { capacityBlocked: true };
|
|
523
|
+
});
|
|
524
|
+
return true;
|
|
525
|
+
}
|
|
526
|
+
async summarizeNative(input, agent, signal) {
|
|
527
|
+
signal?.throwIfAborted();
|
|
528
|
+
if (this.mainAgents.get(agent.session.id) !== agent) fail('COMPACTION_OWNER', 'Only an attached main may compact its native history');
|
|
529
|
+
await this.freezeSession(agent.session); await this.idle(); signal?.throwIfAborted();
|
|
530
|
+
const start = agent.session.snapshotEvents().findLast(e => e.type === 'compaction/start');
|
|
531
|
+
if (!start) fail('COMPACTION_OWNER', 'Native compaction transaction required');
|
|
532
|
+
const messages = input.messages.filter(m => m.role !== 'system' && (['assistant', 'tool'].includes(m.role) || m.source?.kind === 'user' || m.source?.kind === 'compact-checkpoint'));
|
|
533
|
+
const messageIds = new Set(messages.map(m => m.id)), toolIds = new Set(messages.flatMap(m => (m.content ?? []).filter(b => b.type === 'tool-call').map(b => b.id)));
|
|
534
|
+
const sources = this.logs.get(agent.session.id).filter(e => messageIds.has(e.type === 'user/message' ? e.data.id : e.data.message?.id) || e.type === 'tool/call' && toolIds.has(e.data.callId))
|
|
535
|
+
.map(e => this.sources.project(agent.session.id, e)).filter(Boolean);
|
|
536
|
+
const previous = messages.filter(m => m.source?.kind === 'compact-checkpoint');
|
|
537
|
+
const scope = this.builder.scope(agent.session.id), nodeIds = scope?.nodeIds ?? [this.repo.rootId()];
|
|
538
|
+
const key = digest(['native-compaction', agent.session.id, start.data.compactionId]), existing = this.repo.get('tree_jobs', digest(['model_job', 'consolidation', key]));
|
|
539
|
+
const job = existing ?? this.jobs.enqueue({ role: 'consolidation', key, nodeIds, revisionSet: {}, config: this.backgroundModelConfig('consolidation', { agent }),
|
|
540
|
+
trigger: { kind: 'threshold', reason: 'threshold', nativeCompaction: true },
|
|
541
|
+
input: { scope: { nodeIds, ancestorPaths: nodeIds.map(id => this.repo.ancestors(id).map(n => n.id)), treeRevision: this.repo.meta().treeRevision }, sources, knownItems: [], changeReceipts: [],
|
|
542
|
+
allowedIds: { nodeIds, subjectIds: [], fileIds: [] }, nativeCompaction: { sessionId: agent.session.id, inputHash: digest(input), previous,
|
|
543
|
+
instruction: '只压缩给定原文与先前安全检查点,保留用户决定、助手建议的区别及工具结果。不要加入节点理解、方法或程序注入文字。' } } });
|
|
544
|
+
const cancel = () => this.owner.handles.get(job.id)?.agent.cancel({ kind: 'user', message: 'Main compaction cancelled' }, { keepInbox: false });
|
|
545
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
546
|
+
let completed;
|
|
547
|
+
try { signal?.throwIfAborted(); completed = await this.owner.run(job.id); signal?.throwIfAborted(); }
|
|
548
|
+
finally { signal?.removeEventListener('abort', cancel); }
|
|
549
|
+
if (completed.status !== 'committed') fail('COMPACTION_UNAVAILABLE', `Owned summary ${completed.status}; original history retained`);
|
|
550
|
+
const content = [{ type: 'text', text: canonicalJson(completed.resultRef.summary) }];
|
|
551
|
+
return { summary: content, rawOutput: completed.rawMessage.content, provider: completed.modelConfig.provider, model: completed.modelConfig.model, maxTokens: completed.modelConfig.maxTokens };
|
|
552
|
+
}
|
|
553
|
+
scan() {
|
|
554
|
+
if (this.closed) return Promise.resolve(false);
|
|
555
|
+
if (this.scanPromise) return this.scanPromise;
|
|
556
|
+
this.scanPromise = this.scanAdmitted().finally(() => { this.scanPromise = null; });
|
|
557
|
+
return this.scanPromise;
|
|
558
|
+
}
|
|
559
|
+
async scanAdmitted() {
|
|
560
|
+
const release = await this.admissions.acquire(this.shutdown.signal);
|
|
561
|
+
try { return this.scanOne(); }
|
|
562
|
+
finally { release(); }
|
|
563
|
+
}
|
|
564
|
+
async scanOne() {
|
|
565
|
+
if (this.closed) return false;
|
|
566
|
+
for (const job of this.repo.list('tree_jobs', j => j.role === 'consolidation' && j.status === 'committed' && j.input.summaryPlan?.reason === 'threshold')) {
|
|
567
|
+
const session = this.repo.list('tree_learning_sessions', s => s.nodeId === job.nodeIds[0])[0];
|
|
568
|
+
if (session?.contextEpoch !== job.input.summaryPlan.contextEpoch) continue;
|
|
569
|
+
try { this.afterCommit(job); } catch (error) { if (!busy.has(error.code)) this.diagnostic('epoch_completion', error, { jobId: job.id }); }
|
|
570
|
+
}
|
|
571
|
+
for (const intent of this.repo.list('tree_jobs', j => !j.role && j.kind === 'learning' && j.status === 'queued')) {
|
|
572
|
+
try { this.learning.enqueue(intent.input.turnId, intent.nodeIds[0]); }
|
|
573
|
+
catch (error) { if (!busy.has(error.code)) this.diagnostic('learning_queue', error, { intentId: intent.id }); }
|
|
574
|
+
}
|
|
575
|
+
for (const intent of this.repo.list('tree_jobs', j => !j.role && j.kind === 'verification' && j.status === 'queued')) {
|
|
576
|
+
try { this.verification.enqueueCorrection(intent); }
|
|
577
|
+
catch (error) {
|
|
578
|
+
if (busy.has(error.code)) continue;
|
|
579
|
+
this.diagnostic('source_recheck_queue', error, { intentId: intent.id });
|
|
580
|
+
if (['JOB_STALE', 'SOURCE_CHANGED', 'RECHECK_EVIDENCE_MISSING'].includes(error.code)) this.repo.command('retireSourceRecheckIntent',
|
|
581
|
+
{ requestId: digest(['retire-source-recheck', intent.id]), expected: { ['tree_jobs:' + intent.id]: intent.revision }, input: { intentId: intent.id, reason: error.code } },
|
|
582
|
+
{ kind: 'program', id: 'source-recheck' }, () => { this.repo.save('tree_jobs', { ...intent, status: 'stale', error: { code: error.code, message: error.message } }, intent.revision); return { intentId: intent.id }; });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
for (const plan of this.consolidation.due()) this.enqueueSummary(plan);
|
|
586
|
+
for (const node of this.repo.list('tree_nodes', n => !n.deleted && n.status === 'completed' && n.statusOrigin !== 'cascade')) {
|
|
587
|
+
if (this.repo.list('tree_jobs', j => ['learning', 'consolidation'].includes(j.role) && j.nodeIds.includes(node.id) && !done.has(j.status)).length) continue;
|
|
588
|
+
const plan = this.consolidation.plan(node.id, 'node_complete'); if (plan) this.enqueueSummary(plan);
|
|
589
|
+
}
|
|
590
|
+
for (const role of ['attribution', 'learning', 'consolidation', 'verification', 'improvement']) this.improvement.enqueue(role);
|
|
591
|
+
const ready = [...this.repo.list('tree_jobs', j => j.status === 'prepared' && j.role), ...this.jobs.queued()];
|
|
592
|
+
let started = false;
|
|
593
|
+
for (const job of ready.filter(j => !['main', 'attribution'].includes(j.role))) {
|
|
594
|
+
if (this.workers.size >= 2 || this.workers.has(job.id)) continue;
|
|
595
|
+
if (job.role === 'script_writer' && [...this.workers.keys()].some(id => this.jobs.get(id).role === 'script_writer')) continue;
|
|
596
|
+
try { this.jobs.prepare(job.id); }
|
|
597
|
+
catch (error) { if (!busy.has(error.code)) { this.jobs.transition(job.id, 'stale', { error: { code: error.code, message: error.message } }); this.diagnostic('job_preparation', error, { jobId: job.id }); } continue; }
|
|
598
|
+
const promise = this.runWorker(job.id).catch(error => { if (!this.closed && !busy.has(error.code)) this.diagnostic('job_worker', error, { jobId: job.id }); })
|
|
599
|
+
.finally(() => { this.workers.delete(job.id); if (!this.closed) this.kick(); });
|
|
600
|
+
this.workers.set(job.id, promise);
|
|
601
|
+
started = true;
|
|
602
|
+
}
|
|
603
|
+
return started;
|
|
604
|
+
}
|
|
605
|
+
async runWorker(id) {
|
|
606
|
+
const slot = await this.requestSlots.acquire(this.shutdown.signal);
|
|
607
|
+
try {
|
|
608
|
+
const result = await this.owner.run(id), release = await this.admissions.acquire(this.shutdown.signal);
|
|
609
|
+
try { if (!this.handlePressure(result)) this.afterCommit(result); }
|
|
610
|
+
finally { release(); }
|
|
611
|
+
} finally { slot(); }
|
|
612
|
+
}
|
|
613
|
+
kick() {
|
|
614
|
+
if (this.closed || this.scanPending) return;
|
|
615
|
+
this.scanPending = true;
|
|
616
|
+
queueMicrotask(() => { this.scanPending = false; void this.scan().then(progress => { if (progress) this.kick(); }).catch(error => { if (!this.closed) this.diagnostic('worker_scan', error); }); });
|
|
617
|
+
}
|
|
618
|
+
async idle() {
|
|
619
|
+
for (;;) { await Promise.all([...this.settlements.values()]); const progress = await this.scan(); await Promise.all([...this.workers.values()]); if (!progress && !this.workers.size && !this.scanPending) return; }
|
|
620
|
+
}
|
|
621
|
+
start() { this.timer = setInterval(() => this.kick(), 60000); this.timer.unref(); }
|
|
622
|
+
async close() { this.closed = true; clearInterval(this.timer); this.shutdown.abort(); for (const id of this.mainAdmissions.keys()) this.releaseMain(id); await this.owner.close(); await Promise.allSettled([...this.settlements.values(), ...this.workers.values(), this.scanPromise]); }
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Explicit separate namespace; the legacy bridge/writers are never provided. */
|
|
626
|
+
export async function installTreeRuntime(ctx, config, { legacyFilename } = {}) {
|
|
627
|
+
// The business plugin starts before filesystem/working-directory providers.
|
|
628
|
+
// Runtime capabilities resolve from the native root at operation time, as in
|
|
629
|
+
// the existing bridge; they are checked before admitting any operation.
|
|
630
|
+
const host = ctx.root;
|
|
631
|
+
const path = (value, field) => { if (!isAbsolute(value ?? '')) fail('TREE_CONFIG', `Explicit absolute ${field} required`); return canonicalPath(value); };
|
|
632
|
+
const privateRoot = path(config.privateRoot, 'privateRoot'), runtimeRoot = path(config.runtimeRoot, 'runtimeRoot');
|
|
633
|
+
const treeFilename = path(config.treeFilename, 'treeFilename'), workspace = path(config.workspace, 'workspace');
|
|
634
|
+
if (!beneath(treeFilename, privateRoot) || treeFilename === privateRoot || beneath(runtimeRoot, privateRoot) || beneath(privateRoot, runtimeRoot)) fail('TREE_CONFIG', 'Tree database is private; execution files require a separate root');
|
|
635
|
+
for (const directory of [privateRoot, runtimeRoot]) mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
636
|
+
const budget = config.budget ?? {}, ledgerPath = path(budget.ledgerPath, 'budget.ledgerPath'), evidence = path(budget.artifactDirectory, 'budget.artifactDirectory');
|
|
637
|
+
const webOnly = budget.mode === 'web-only';
|
|
638
|
+
if (budget.mode !== undefined && !webOnly || webOnly && (budget.manifest || budget.scope !== 'web-unlimited' || Object.hasOwn(budget, 'authorizationRef') || Object.hasOwn(budget, 'ceilingCny'))) fail('TREE_CONFIG', 'Web-only accounting must retain its explicit website authority without a test allowance');
|
|
639
|
+
if (!beneath(ledgerPath, privateRoot) || !beneath(evidence, privateRoot) || ledgerPath === treeFilename || (webOnly ? !budget.webAuthorization : !budget.authorizationRef)) fail('TREE_CONFIG', 'Explicit private ledger/evidence paths and authorization required');
|
|
640
|
+
const lease = await ownEvidenceDirectory(evidence);
|
|
641
|
+
let repo, ledger, transport, runtime, unregister, closing;
|
|
642
|
+
const close = () => closing ??= (async () => {
|
|
643
|
+
try { await runtime?.close(); await transport?.close(); }
|
|
644
|
+
finally { try { unregister?.(); ledger?.close(); repo?.close(); } finally { await lease.close(); } }
|
|
645
|
+
})();
|
|
646
|
+
ctx.effect(() => close, 'close tree workers, metering, storage and evidence ownership');
|
|
647
|
+
try {
|
|
648
|
+
repo = new TreeRepository({ treeFilename, userId: config.userId, legacyFilename });
|
|
649
|
+
const experiment = budget.manifest?.budgetScope === TREE_TEST_SCOPE;
|
|
650
|
+
ledger = experiment ? new TreeTestLedger({ filename: ledgerPath, manifest: budget.manifest, authorizationRef: budget.authorizationRef, create: budget.create === true })
|
|
651
|
+
: webOnly ? openOwnedWebBudget({ artifactDirectory: evidence, ledgerPath, webAuthorization: budget.webAuthorization, owner: lease }).ledger
|
|
652
|
+
: new BudgetLedger(ledgerPath, { ceilingCny: budget.ceilingCny, authorizationRef: budget.authorizationRef, scope: budget.scope, webAuthorization: budget.webAuthorization, create: false });
|
|
653
|
+
const modelPolicies = experiment ? { [ledger.manifest.model]: ledger.modelPolicy() } : budget.modelPolicies;
|
|
654
|
+
if (!modelPolicies || !Object.keys(modelPolicies).length) fail('TREE_CONFIG', 'Frozen model policies are required');
|
|
655
|
+
const protectedRoots = [privateRoot, ...(config.protectedRoots ?? []).map(p => path(p, 'protectedRoots'))];
|
|
656
|
+
if (legacyFilename) protectedRoots.push(canonicalPath(legacyFilename));
|
|
657
|
+
const resources = new Map(), brokers = new Set();
|
|
658
|
+
const artifacts = new ScriptArtifacts({ repository: repo, directory: join(privateRoot, 'artifacts'), resolveSource: ref => runtime.resolveSource(ref) });
|
|
659
|
+
const sessionPolicy = session => {
|
|
660
|
+
if (!ctx.get('sandboxPolicy')) fail('POLICY_UNAVAILABLE', 'Current native file permissions are required');
|
|
661
|
+
return host.sandboxPolicy.resolve({ session });
|
|
662
|
+
};
|
|
663
|
+
const resourceFor = (directory, uiContext) => {
|
|
664
|
+
directory = realpathSync(directory);
|
|
665
|
+
let resource = resources.get(directory);
|
|
666
|
+
const snapshotOptions = { repository: repo, workspace: directory, storageRoot: join(privateRoot, 'snapshots'), runtimeRoot, protectedRoots };
|
|
667
|
+
if (!resource) {
|
|
668
|
+
const snapshots = new SnapshotService(snapshotOptions), io = new ConfinedWorkspaceIO({ workspace: directory, runtimeRoot, protectedRoots });
|
|
669
|
+
resource = { snapshots, io, cancelSession: id => { for (const broker of brokers) broker.cancelSession(id); },
|
|
670
|
+
brokerFor: ({ agent, actor, exec }) => {
|
|
671
|
+
const context = () => {
|
|
672
|
+
exec.signal.throwIfAborted();
|
|
673
|
+
if (runtime.mainAgents.get(agent.session.id) !== agent || host.agents.currentInitiator() !== agent || agent.status !== 'running') fail('MAIN_REQUIRED', 'Native main tool authority ended');
|
|
674
|
+
return { actor, sessionId: agent.session.id, nativeToolCallId: exec.callId, sessionPolicy: sessionPolicy(agent.session) };
|
|
675
|
+
};
|
|
676
|
+
const broker = new MethodScriptBroker({ repository: repo, artifacts, snapshots, runtimeRoot, protectedRoots, context,
|
|
677
|
+
resolveSource: runtime.resolveSource, readScope: id => runtime.builder.scope(id),
|
|
678
|
+
// Authored files narrow the live native tool's permission; they
|
|
679
|
+
// cannot grant it. Snapshot coverage checks every declared file.
|
|
680
|
+
authorizeWrites: ({ context: current, artifact }) => ({ paths: artifact.writePaths ?? [], authorizationRef: digest({ nativeToolCallId: current.nativeToolCallId, sessionId: current.sessionId, sessionPolicy: current.sessionPolicy, artifactId: artifact.id, artifactRevision: artifact.revision }) }) });
|
|
681
|
+
brokers.add(broker); return broker;
|
|
682
|
+
} };
|
|
683
|
+
resources.set(directory, resource);
|
|
684
|
+
}
|
|
685
|
+
if (!uiContext) return resource;
|
|
686
|
+
const session = ctx.sessions.get(uiContext.sessionId);
|
|
687
|
+
if (!session || host.workingDirectory.get(session) !== directory) fail('SNAPSHOT_UNAVAILABLE', 'Restore session workspace changed');
|
|
688
|
+
return { ...resource, snapshots: new SnapshotService({ ...snapshotOptions, context: () => {
|
|
689
|
+
uiContext.signal.throwIfAborted(); runtime.ui.authorize(uiContext);
|
|
690
|
+
if (ctx.sessions.get(uiContext.sessionId) !== session || host.workingDirectory.get(session) !== directory) fail('WORKSPACE_CHANGED', 'Restore session changed');
|
|
691
|
+
return { ...uiContext, sessionPolicy: sessionPolicy(session) };
|
|
692
|
+
} }) };
|
|
693
|
+
};
|
|
694
|
+
const modelConfig = (role, agent, selection) => {
|
|
695
|
+
const selected = config.models?.[role]; if (!selected) fail('TREE_CONFIG', `Explicit ${role} model configuration required`);
|
|
696
|
+
const route = role === 'main' ? { ...selected, ...(selection ?? agent?.model?.current?.() ?? agent?.model), maxTokens: agent?.options.maxTokens ?? selected.maxTokens }
|
|
697
|
+
: config.followConversationModel === true ? { ...selected, provider: selection?.provider ?? config.models.main.provider, model: selection?.model ?? config.models.main.model } : selected;
|
|
698
|
+
const policy = modelPolicies[route.model];
|
|
699
|
+
if (route.provider !== 'deepseek-official' || !policy) fail('TREE_CONFIG', 'Role model has no authorized metered route');
|
|
700
|
+
// A resumed/forked native conversation can retain a model other than the
|
|
701
|
+
// current main default. Record the version belonging to the actual route.
|
|
702
|
+
const versions = experiment ? [ledger.manifest.modelVersion] : [...new Set(Object.values(config.models)
|
|
703
|
+
.filter(value => value.provider === route.provider && value.model === route.model).map(value => value.modelVersion))];
|
|
704
|
+
if (versions.length !== 1 || typeof versions[0] !== 'string' || !versions[0]) fail('TREE_CONFIG', 'Selected model requires one explicit matching version');
|
|
705
|
+
return { provider: route.provider, model: route.model, reasoningEffort: route.reasoningEffort, maxTokens: route.maxTokens,
|
|
706
|
+
preset: role === 'script_writer' ? 'arcfold-tree-writer' : 'arcfold-tree-reader', cwd: agent ? host.workingDirectory.get(agent.session) : workspace,
|
|
707
|
+
modelVersion: versions[0], priceVersion: experiment ? ledger.manifestHash : policy.priceVersion,
|
|
708
|
+
budgetScope: experiment ? TREE_TEST_SCOPE : budget.scope, stage: selected.stage ?? config.stage ?? 'runtime' };
|
|
709
|
+
};
|
|
710
|
+
for (const role of ['attribution', 'learning', 'consolidation', 'verification', 'improvement', 'script_writer']) modelConfig(role);
|
|
711
|
+
if (!config.tokenCounter) fail('TREE_CONFIG', 'The new tree requires an explicit verified offline token counter');
|
|
712
|
+
if (config.adapter !== undefined && config.adapter !== 'desktop-private-v1') fail('TREE_CONFIG', 'Unknown provider adapter');
|
|
713
|
+
const adapter = config.adapter === 'desktop-private-v1'
|
|
714
|
+
? await (await import('../desktop/adapter.mjs')).loadDesktopDeepSeekAdapter() : undefined;
|
|
715
|
+
const tokenCounter = new TreeTokenCounter({ python: config.tokenCounter.python, moduleDirectory: join(privateRoot, 'tokenizer-dependency'), adapter });
|
|
716
|
+
runtime = new TreeRuntime({ repository: repo, host, createUserMessage, defineTool, modelConfig, followConversationModel: config.followConversationModel === true, ledgerLookup: id => ledger.get(id), artifacts, resourcesFor: resourceFor, tokenCounter,
|
|
717
|
+
createWriterOptions: job => ({ workspaceIO: resourceFor(job.modelConfig.cwd).io, runtimeRoot, protectedRoots }),
|
|
718
|
+
browserScope: context => ctx.sessions.get(context.sessionId) ? { nodeIds: repo.list('tree_nodes', n => !n.deleted).map(n => n.id) } : null });
|
|
719
|
+
runtime.hostPolicy = supplied => {
|
|
720
|
+
const agent = ctx.get('agents')?.currentInitiator(), policy = supplied ?? sessionPolicy(agent?.session);
|
|
721
|
+
const directory = agent ? host.workingDirectory.get(agent.session) : policy.workspaceRoot;
|
|
722
|
+
return processPolicy({ workspace: directory, runtimeRoot, protectedRoots,
|
|
723
|
+
immutableRoots: [dirname(fileURLToPath(import.meta.url))], sessionPolicy: policy, authorizedWriteRoots: [directory] });
|
|
724
|
+
};
|
|
725
|
+
// This empty legacy facade exists solely for the already-audited authority
|
|
726
|
+
// resolver. It grants no old work/learning ownership and starts no writers.
|
|
727
|
+
const business = Object.freeze({ get: () => null, listAll: () => [] });
|
|
728
|
+
unregister = registerTreeRequestAuthority(business, runtime.owner);
|
|
729
|
+
transport = new DeepSeekTransport({ ledger, artifactDirectory: evidence, provider: 'deepseek-official', modelPolicies,
|
|
730
|
+
endpoint: budget.endpoint, allowLocalTestEndpoint: budget.allowLocalTestEndpoint === true, timeoutMs: budget.timeoutMs,
|
|
731
|
+
requestIdentity: options => runtime.owner.identity(options), beforeDispatch: options => runtime.owner.assertRequest(options) });
|
|
732
|
+
await transport.recover({ exclusive: true });
|
|
733
|
+
ctx.on('llm/stream', (options, next) => {
|
|
734
|
+
if (!runtime.owner.owns(options.sessionId)) fail('MODEL_OWNER', 'New-tree profile refuses every unowned request');
|
|
735
|
+
transport.validateInput(options); return runtime.owner.guardStream(options, next);
|
|
736
|
+
}, { prepend: true });
|
|
737
|
+
ctx.on('session/event', (session, event) => runtime.observe(session, event));
|
|
738
|
+
ctx.provide('arcfoldBusiness', business);
|
|
739
|
+
ctx.provide('arcfoldTreeRuntime', runtime);
|
|
740
|
+
ctx.provide('arcfoldTreeV3', runtime.ui);
|
|
741
|
+
ctx.provide('arcfoldDeepSeekTransport', Object.freeze({ summary: () => ledger.summary(), createTransport: options => transport.createTransport(options),
|
|
742
|
+
configuration: Object.freeze({ provider: 'deepseek-official', endpoint: transport.endpoint, modelPolicies: transport.modelPolicies }) }));
|
|
743
|
+
await runtime.recover(); runtime.start(); return runtime;
|
|
744
|
+
} catch (error) { await close(); throw error; }
|
|
745
|
+
}
|