@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,254 @@
|
|
|
1
|
+
import { canonicalJson, digest, fail } from './contracts.mjs';
|
|
2
|
+
import { WRITER_TOOLS, TERMINAL_JOBS } from './jobs.mjs';
|
|
3
|
+
import { directoryContext, contextMatches } from '../learning-runner.mjs';
|
|
4
|
+
import { compilePrompt } from './prompts.mjs';
|
|
5
|
+
|
|
6
|
+
const requestHash = options => digest({ messages: options.messages, tools: options.tools ?? [], toolHistory: options.toolHistory ?? null });
|
|
7
|
+
function nativePosition(session) {
|
|
8
|
+
const events = session.snapshotEvents(), start = events.findLast(e => e.type === 'step/start'), turn = events.findLast(e => e.type === 'turn/start');
|
|
9
|
+
if (!start || !turn || !Number.isSafeInteger(start.data.turn) || !Number.isSafeInteger(start.data.step) || turn.data.turn !== start.data.turn || turn.seq >= start.seq || events.slice(start.seq + 1).some(e => ['assistant/message', 'assistant/attempt', 'step/end', 'turn/end'].includes(e.type))) fail('NATIVE_STEP', 'No live unattempted native step');
|
|
10
|
+
return { turn: start.data.turn, step: start.data.step, stepStartSeq: start.seq, events };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Uses DSH create/resume/followup/whenIdle. There is no second agent/tool loop. */
|
|
14
|
+
export class TreeModelOwner {
|
|
15
|
+
constructor({ jobs, host, createUserMessage, ledgerLookup = () => null, applyResult = () => ({}), mountWriterTools, verifyMainInput, inputTokenCount, timers = { setTimeout, clearTimeout } }) {
|
|
16
|
+
Object.assign(this, { jobs, host, createUserMessage, ledgerLookup, applyResult, mountWriterTools, verifyMainInput, inputTokenCount, timers });
|
|
17
|
+
this.handles = new Map(); this.running = new Map(); this.closed = false;
|
|
18
|
+
}
|
|
19
|
+
owns(sessionId) { return this.jobs.repo.list('tree_jobs', j => j.nativeSessionId === sessionId && ['prepared', 'submitted', 'result_received', 'validated'].includes(j.status)).length > 0; }
|
|
20
|
+
jobFor(sessionId) {
|
|
21
|
+
const owners = this.jobs.repo.list('tree_jobs', j => j.nativeSessionId === sessionId && ['prepared', 'submitted', 'result_received', 'validated'].includes(j.status));
|
|
22
|
+
if (owners.length !== 1) fail('MODEL_OWNER', 'One active tree job must own this native session'); return owners[0];
|
|
23
|
+
}
|
|
24
|
+
messageFor(job) {
|
|
25
|
+
if (!job.nativeMessage) fail('MESSAGE_MISSING', 'Native input identity must be persisted before sending'); return job.nativeMessage;
|
|
26
|
+
}
|
|
27
|
+
previousLearningInput(sourceJob, job) {
|
|
28
|
+
if (job.role !== 'learning' || sourceJob.role !== 'learning' || canonicalJson(sourceJob.nodeIds) !== canonicalJson(job.nodeIds)) return false;
|
|
29
|
+
if (sourceJob.status === 'committed') return true;
|
|
30
|
+
if (!job.input.previousReceipt?.rejectedOutputs?.some(receipt => receipt.jobId === sourceJob.id && receipt.state === sourceJob.status && receipt.adopted === false && receipt.nativeLogRef === sourceJob.nativeLogRef)) return false;
|
|
31
|
+
// A rejected result is history, never adopted state. Its exact settled
|
|
32
|
+
// response and the rejection receipt can remain in this node's own worker.
|
|
33
|
+
if (sourceJob.status === 'invalid_output' && sourceJob.resultHash && sourceJob.nativeLogRef && sourceJob.invocationIds.length)
|
|
34
|
+
return sourceJob.invocationIds.every(id => { const call = this.jobs.repo.get('tree_invocations', id); return call?.state === 'result_received' && this.ledgerLookup(call.ledgerRequestId)?.status === 'settled'; });
|
|
35
|
+
return sourceJob.status === 'failed' && sourceJob.error?.code === 'NATIVE_PRE_DISPATCH' && sourceJob.nativeLogRef && !sourceJob.invocationIds.length;
|
|
36
|
+
}
|
|
37
|
+
assertProjection(job, options) {
|
|
38
|
+
if (options.purpose !== undefined || options.system !== undefined) fail('PURPOSE_BYPASS', 'Tree roles use owned native messages, never purpose calls');
|
|
39
|
+
const config = job.modelConfig;
|
|
40
|
+
if (['provider', 'model', 'reasoningEffort', 'maxTokens'].some(k => options[k] !== config[k])) fail('MODEL_CONFIG_CHANGED', 'Actual request differs from frozen model configuration');
|
|
41
|
+
const session = this.host.sessions.get(options.sessionId), header = session?.requestHeader()?.config;
|
|
42
|
+
if (!session || !header || ['provider', 'model', 'reasoningEffort', 'maxTokens'].some(k => header[k] !== options[k])) fail('NATIVE_CONFIG_CHANGED', 'Native header and provider configuration disagree');
|
|
43
|
+
const systems = options.messages.filter(m => m.role === 'system').flatMap(m => m.content ?? []).filter(b => b.type === 'text').map(b => b.text).join('\n');
|
|
44
|
+
if (job.role === 'main') {
|
|
45
|
+
const check = this.verifyMainInput?.(job, options);
|
|
46
|
+
if (check !== true || check?.then) fail('MAIN_INPUT_GUARD', 'Main requires its current trusted context and user-turn guard');
|
|
47
|
+
} else {
|
|
48
|
+
if (systems !== job.prompt.text) fail('PROMPT_CHANGED', 'Auxiliary fixed prompt changed');
|
|
49
|
+
let current = 0;
|
|
50
|
+
for (const message of options.messages.filter(m => m.role !== 'system')) {
|
|
51
|
+
if (message.source?.kind === 'arcfold/tree-job') {
|
|
52
|
+
const sourceJob = this.jobs.repo.get('tree_jobs', message.source.jobId);
|
|
53
|
+
if (!sourceJob || sourceJob.nativeSessionId !== job.nativeSessionId || sourceJob.id !== job.id && !this.previousLearningInput(sourceJob, job)) fail('FOREIGN_JOB_INPUT', 'This session contains input from an unrelated or unresolved job');
|
|
54
|
+
const expected = this.messageFor(sourceJob);
|
|
55
|
+
if (canonicalJson(message) !== canonicalJson(expected)) fail('JOB_INPUT_CHANGED', 'Program input differs from the frozen envelope');
|
|
56
|
+
if (sourceJob.id === job.id) current++;
|
|
57
|
+
} else if (message.source?.kind === 'runtime-context') {
|
|
58
|
+
if (!contextMatches(message, directoryContext(config.cwd))) fail('RUNTIME_CONTEXT_CHANGED', 'Unexpected native context');
|
|
59
|
+
} else if (!['assistant', 'tool', 'developer'].includes(message.role) || message.role === 'developer' && job.role !== 'script_writer' || message.role === 'tool' && job.role !== 'script_writer') fail('FOREIGN_JOB_INPUT', 'Unexpected input in an auxiliary session');
|
|
60
|
+
}
|
|
61
|
+
if (current !== 1) fail('JOB_INPUT_MISSING', 'Exactly one current job envelope is required');
|
|
62
|
+
}
|
|
63
|
+
const tools = (options.tools ?? []).map(t => t.name), history = options.toolHistory;
|
|
64
|
+
if (!['main', 'script_writer'].includes(job.role) && (tools.length || history?.tools?.length || history?.updates?.length)) fail('ROLE_TOOLS', 'Reader roles must have no tools or tool history');
|
|
65
|
+
if (job.role === 'script_writer') {
|
|
66
|
+
const names = [...tools, ...(history?.tools ?? []).map(t => t.name), ...(history?.updates ?? []).flatMap(u => (u.additions ?? []).map(t => t.name))];
|
|
67
|
+
if (names.some(name => !WRITER_TOOLS.includes(name))) fail('ROLE_TOOLS', 'Writer has only the broker tool allowlist');
|
|
68
|
+
}
|
|
69
|
+
// Reservation still uses the declared 1M bound. This byte guard is a separate
|
|
70
|
+
// conservative small-input limit; long-context tests require a trusted full
|
|
71
|
+
// message/system/tools token counter instead of a character-ratio estimate.
|
|
72
|
+
if (job.modelConfig.budgetScope === 'arcfold-tree-v3-150cny') {
|
|
73
|
+
const measured = this.inputTokenCount?.(options);
|
|
74
|
+
if (measured?.then) fail('ASYNC_GUARD', 'Input counting must be synchronous');
|
|
75
|
+
if (measured !== undefined) {
|
|
76
|
+
if (!Number.isSafeInteger(measured) || measured < 0 || measured > (job.trigger.longContextTest === true ? 160000 : 128000)) fail('INPUT_LIMIT', 'Full request token count exceeds the test limit');
|
|
77
|
+
} else if (job.trigger.longContextTest || Buffer.byteLength(canonicalJson({ messages: options.messages, tools: options.tools ?? [], toolHistory: history ?? null })) > 96000) fail('INPUT_COUNTER_REQUIRED', 'Long input requires a trusted tokenizer; no rough estimate may bypass the limit');
|
|
78
|
+
}
|
|
79
|
+
this.jobs.assertCurrent(job); return tools;
|
|
80
|
+
}
|
|
81
|
+
guardStream(options, next) {
|
|
82
|
+
if (!this.owns(options.sessionId)) return next(); const owner = this;
|
|
83
|
+
return (async function* () {
|
|
84
|
+
if (owner.closed) fail('OWNER_CLOSED', 'Tree model owner is closed'); options.signal?.throwIfAborted();
|
|
85
|
+
await owner.prepareInput?.(options); options.signal?.throwIfAborted();
|
|
86
|
+
const job = owner.jobFor(options.sessionId), tools = owner.assertProjection(job, options), position = nativePosition(owner.host.sessions.get(options.sessionId));
|
|
87
|
+
owner.jobs.admitInvocation(job.id, { ...position, requestHash: requestHash(options), allowedTools: tools });
|
|
88
|
+
yield* next();
|
|
89
|
+
})();
|
|
90
|
+
}
|
|
91
|
+
identity(options, session = this.host.sessions.get(options.sessionId)) {
|
|
92
|
+
const job = this.jobFor(options.sessionId); this.assertProjection(job, options); const position = nativePosition(session);
|
|
93
|
+
const invocation = this.jobs.repo.list('tree_invocations', i => i.jobId === job.id && i.nativeSessionId === session.id && i.stepStartSeq === position.stepStartSeq)[0];
|
|
94
|
+
if (!invocation || invocation.state !== 'admitted' || invocation.requestHash !== requestHash(options)) fail('INVOCATION_GUARD', 'Request did not pass the exact tree step guard');
|
|
95
|
+
return { requestId: invocation.ledgerRequestId, metadata: { ...(job.trigger.repairEvidence ?? {}), owner: 'arcfold-dsh-tree-v3', sessionId: session.id, turn: position.turn, step: position.step, stepStartSeq: position.stepStartSeq, nativeThroughSeq: position.events.at(-1).seq, nativePrefixHash: digest(position.events), role: job.role, treeJobId: job.id, treeInvocationId: invocation.id, treeInputHash: job.inputHash, promptHash: job.promptHash, modelConfigHash: job.modelConfigHash, modelVersion: job.modelConfig.modelVersion, treePriceVersion: job.modelConfig.priceVersion, budgetScope: job.modelConfig.budgetScope, stage: job.modelConfig.stage } };
|
|
96
|
+
}
|
|
97
|
+
assertRequest(options) {
|
|
98
|
+
const identity = this.identity(options), job = this.jobFor(options.sessionId);
|
|
99
|
+
if (job.status !== 'submitted') fail('MODEL_OWNER', 'Job is no longer submitted');
|
|
100
|
+
if (this.host.workingDirectory && this.host.workingDirectory.get(this.host.sessions.get(options.sessionId)) !== job.modelConfig.cwd) fail('WORKSPACE_CHANGED', 'Native working directory changed');
|
|
101
|
+
return identity;
|
|
102
|
+
}
|
|
103
|
+
async setup(agentCtx, id) {
|
|
104
|
+
const job = this.jobs.get(id);
|
|
105
|
+
if (!this.host.agentPresets) fail('PRESET_REQUIRED', 'Owned roles require the native preset registry');
|
|
106
|
+
await this.host.agentPresets.mount(agentCtx, job.modelConfig.preset);
|
|
107
|
+
if (job.role !== 'main') {
|
|
108
|
+
// Native restrictions mask inherited tools; broker tools register locally.
|
|
109
|
+
// The request guard separately checks all visible scoped tool names.
|
|
110
|
+
agentCtx.tools.restrict({ allow: [] });
|
|
111
|
+
agentCtx.systemPrompt.suppressRuntimeContext();
|
|
112
|
+
}
|
|
113
|
+
agentCtx.systemPrompt.section({ name: 'arcfold:tree-role', order: 50, interpolate: false, complete: job.role !== 'main', text: job.prompt.text });
|
|
114
|
+
if (job.role === 'script_writer') {
|
|
115
|
+
if (!this.mountWriterTools) fail('WRITER_BROKER_REQUIRED', 'Writer requires dedicated broker tools'); await this.mountWriterTools(agentCtx, job);
|
|
116
|
+
agentCtx.on('tools/pre-execute', async (exec, next) => {
|
|
117
|
+
if (!WRITER_TOOLS.includes(exec.name)) fail('ROLE_TOOLS', 'Unapproved writer tool');
|
|
118
|
+
this.jobs.assertCurrent(this.jobs.get(id)); const result = await next(); this.jobs.assertCurrent(this.jobs.get(id)); return result;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
agentCtx.on('agent/pre-step', async (_payload, next) => {
|
|
122
|
+
const decision = await next(); if (decision.kind === 'reject' || !decision.messages.length) return decision;
|
|
123
|
+
this.jobs.assertCurrent(this.jobs.get(id)); return decision;
|
|
124
|
+
});
|
|
125
|
+
return { commit: () => this.jobs.assertCurrent(this.jobs.get(id)) };
|
|
126
|
+
}
|
|
127
|
+
attachMain(agentCtx) {
|
|
128
|
+
// The normal user Agent remains host-owned. B7 admits its current job in
|
|
129
|
+
// pre-step; this owner does not create another main conversation or inbox.
|
|
130
|
+
const prompt = compilePrompt('main');
|
|
131
|
+
agentCtx.systemPrompt.section({ name: 'arcfold:tree-main', order: 50, interpolate: false, text: prompt.text });
|
|
132
|
+
agentCtx.on('agent/pre-step', async (payload, next) => {
|
|
133
|
+
const decision = await next(); if (decision.kind === 'reject' || !decision.messages.length) return decision;
|
|
134
|
+
const job = this.jobFor(payload.agent.session.id);
|
|
135
|
+
if (job.role !== 'main' || job.trigger.nativeTurn !== payload.turn || job.status !== 'submitted') fail('MAIN_OWNER', 'Current native user turn lacks a submitted main owner');
|
|
136
|
+
this.jobs.assertCurrent(job); return decision;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
async readEvents(sessionId, ownedHandle) {
|
|
140
|
+
const live = this.host.sessions.get(sessionId);
|
|
141
|
+
if (live && live !== ownedHandle?.agent.session) fail('LIVE_SESSION', 'Cannot recover another live native owner');
|
|
142
|
+
let reader;
|
|
143
|
+
try { reader = await this.host.sessionPersistence.open(sessionId, 'read'); }
|
|
144
|
+
catch (e) { if (e?.name === 'SessionPersistenceNotFoundError') return null; throw e; }
|
|
145
|
+
try { return (await reader.read()).events; } finally { await reader.close(); }
|
|
146
|
+
}
|
|
147
|
+
#resultsFromEvents(job, events) {
|
|
148
|
+
const mainStart = job.role === 'main' && events.find(e => e.type === 'turn/start' && e.data.turn === job.trigger.nativeTurn);
|
|
149
|
+
const mainEnd = mainStart && events.find(e => e.type === 'turn/end' && e.data.turn === job.trigger.nativeTurn && e.seq > mainStart.seq);
|
|
150
|
+
const own = events.find(e => e.type === 'user/message' && (job.role === 'main' ? mainStart && mainEnd && e.seq > mainStart.seq && e.seq < mainEnd.seq && e.data.source?.kind === 'user' : e.data.source?.jobId === job.id));
|
|
151
|
+
if (!own) return null;
|
|
152
|
+
const start = [...events].reverse().find(e => e.type === 'turn/start' && e.seq < own.seq), end = start && events.find(e => e.type === 'turn/end' && e.data.turn === start.data.turn && e.seq > own.seq);
|
|
153
|
+
if (!end || job.role !== 'main' && !['completed', 'max-tokens'].includes(end.data.reason?.kind)) return null;
|
|
154
|
+
const messages = events.filter(e => e.type === 'assistant/message' && e.seq > own.seq && e.seq < end.seq);
|
|
155
|
+
const final = messages.at(-1)?.data.message;
|
|
156
|
+
if (!final || final.content?.some(b => b.type === 'tool-call') && !(job.role !== 'main' && end.data.reason?.kind === 'max-tokens')) return null;
|
|
157
|
+
return { final, start, end, own, events };
|
|
158
|
+
}
|
|
159
|
+
consume(id, events) {
|
|
160
|
+
const job = this.jobs.get(id), result = events && this.#resultsFromEvents(job, events);
|
|
161
|
+
if (!result && events && job.role !== 'main' && !job.invocationIds.length) {
|
|
162
|
+
const own = events.filter(e => e.type === 'user/message' && e.data.source?.jobId === job.id);
|
|
163
|
+
const start = own.length === 1 && events.findLast(e => e.type === 'turn/start' && e.seq < own[0].seq);
|
|
164
|
+
const end = start && events.find(e => e.type === 'turn/end' && e.data.turn === start.data.turn && e.seq > own[0].seq);
|
|
165
|
+
if (end?.data.reason?.kind === 'error' && canonicalJson(own[0].data) === canonicalJson(job.nativeMessage)
|
|
166
|
+
&& !events.some(e => e.type === 'assistant/message' && e.seq > start.seq && e.seq < end.seq))
|
|
167
|
+
return this.jobs.failBeforeInvocation(id, { nativeLogRef: `${job.nativeSessionId}#${end.seq}`, eventsHash: digest(events), reason: end.data.reason.error?.message ?? 'Native request guard rejected before admission' });
|
|
168
|
+
}
|
|
169
|
+
if (!result) fail('RESULT_UNKNOWN', 'No complete durable native result for this exact job');
|
|
170
|
+
if (!job.invocationIds.length) fail('INVOCATION_MISSING', 'A model result requires at least one guarded paid identity');
|
|
171
|
+
for (const invocationId of job.invocationIds) {
|
|
172
|
+
const invocation = this.jobs.repo.get('tree_invocations', invocationId), ledger = this.ledgerLookup(invocation.ledgerRequestId);
|
|
173
|
+
const message = events.find(e => e.type === 'assistant/message' && e.seq > invocation.stepStartSeq && !events.some(s => s.type === 'step/start' && s.seq > invocation.stepStartSeq && s.seq < e.seq));
|
|
174
|
+
if (!message || !ledger || ledger.status !== 'settled') fail('USAGE_UNKNOWN', 'Every completed model step requires a settled ledger receipt');
|
|
175
|
+
this.jobs.invocationResult(invocationId, { state: 'result_received', usage: ledger.usage, resultHash: digest(message.data), nativeLogRef: `${job.nativeSessionId}#${message.seq}` });
|
|
176
|
+
}
|
|
177
|
+
this.jobs.receive(id, { message: { role: 'assistant', content: result.final.content }, nativeLogRef: `${job.nativeSessionId}#${result.end.seq}`, eventsHash: digest(events) });
|
|
178
|
+
// A durable provider limit with a settled receipt is a known incomplete
|
|
179
|
+
// result, including reasoning-only output. Preserve it and reject; never
|
|
180
|
+
// parse a coincidentally closed prefix or automatically request more tokens.
|
|
181
|
+
if (job.role !== 'main' && result.end.data.reason?.kind === 'max-tokens')
|
|
182
|
+
return this.jobs.transition(id, 'invalid_output', { error: { code: 'OUTPUT_LIMIT', message: 'Provider reached the output limit before completing the final result' }, rawHash: digest(result.final) });
|
|
183
|
+
const validated = this.jobs.validate(id); return validated.status === 'validated' ? this.jobs.commit(id, this.applyResult) : validated;
|
|
184
|
+
}
|
|
185
|
+
run(id) {
|
|
186
|
+
if (this.running.has(id)) return this.running.get(id); const promise = this.#run(id); this.running.set(id, promise);
|
|
187
|
+
void promise.finally(() => this.running.delete(id)).catch(() => {}); return promise;
|
|
188
|
+
}
|
|
189
|
+
async #run(id) {
|
|
190
|
+
let handle, deadline;
|
|
191
|
+
try {
|
|
192
|
+
if (this.closed) fail('OWNER_CLOSED', 'Owner is closed'); let job = this.jobs.get(id);
|
|
193
|
+
if (job.role === 'main') fail('MAIN_HOST_OWNED', 'Main stays in its existing native user Agent; use attachMain and consume');
|
|
194
|
+
if (job.status === 'committed' || ['invalid_output', 'stale', 'cancelled', 'failed'].includes(job.status)) return job;
|
|
195
|
+
if (job.status === 'queued') job = this.jobs.prepare(id);
|
|
196
|
+
if (job.status === 'prepared' && !job.nativeMessage) job = this.jobs.bindMessage(id, this.createUserMessage({ source: { kind: 'arcfold/tree-job', jobId: job.id, role: job.role, inputHash: job.inputHash, origin: 'program' }, content: [{ type: 'text', text: canonicalJson(job.input) }] }));
|
|
197
|
+
const existing = await this.readEvents(job.nativeSessionId);
|
|
198
|
+
if (job.status !== 'prepared') return this.consume(id, existing);
|
|
199
|
+
if (existing?.some(e => e.type === 'user/message' && e.data.source?.jobId === id)) fail('RESULT_UNKNOWN', 'Prepared job already has native submission evidence; never resend');
|
|
200
|
+
if (existing?.length) {
|
|
201
|
+
const start = existing.findLast(e => e.type === 'turn/start'), end = existing.findLast(e => e.type === 'turn/end');
|
|
202
|
+
if (start && (!end || end.seq < start.seq)) fail('RESULT_UNKNOWN', 'Previous native turn remains uncertain');
|
|
203
|
+
}
|
|
204
|
+
const config = job.modelConfig, options = { agentOptions: { provider: config.provider, model: config.model, maxTokens: config.maxTokens, reasoningEffort: config.reasoningEffort }, setup: ctx => this.setup(ctx, id), meta: { agentPreset: config.preset, cwd: config.cwd } };
|
|
205
|
+
handle = existing?.length ? await this.host.agents.resume({ ...options, resumeSessionId: job.nativeSessionId }) : await this.host.agents.create({ ...options, sessionId: job.nativeSessionId });
|
|
206
|
+
// A resumed learning session retains its previous native selection. Move
|
|
207
|
+
// only this owned background session to the job's already-frozen route;
|
|
208
|
+
// persist that selection before any submission or provider request.
|
|
209
|
+
if (existing?.length && typeof handle.agent.model?.current === 'function') {
|
|
210
|
+
const current = handle.agent.model.current();
|
|
211
|
+
if (!current || ['provider', 'model', 'reasoningEffort'].some(key => current[key] !== config[key])) {
|
|
212
|
+
if (typeof handle.agent.model.select !== 'function') fail('MODEL_SELECTION_UNAVAILABLE', 'Cannot apply the frozen background model to its native session');
|
|
213
|
+
handle.agent.model.select({ provider: config.provider, model: config.model, reasoningEffort: config.reasoningEffort });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
this.handles.set(id, handle);
|
|
217
|
+
if (await this.host.sessions.flush(handle.agent.session) !== true) fail('SESSION_NOT_DURABLE', 'Native session is not durably owned');
|
|
218
|
+
// A long learning append can be deferred before submission, inbox append,
|
|
219
|
+
// invocation identity, reservation or any provider request exists.
|
|
220
|
+
await this.beforeSubmit?.(this.jobs.get(id), handle.agent);
|
|
221
|
+
this.jobs.submit(id);
|
|
222
|
+
if (job.role === 'script_writer') deadline = this.timers.setTimeout(() => handle.agent.cancel({ kind: 'user', message: 'Writer reached its 120 second host limit' }, { keepInbox: false }), 120000);
|
|
223
|
+
handle.agent.followup(this.messageFor(this.jobs.get(id))); await handle.agent.whenIdle();
|
|
224
|
+
const prefix = handle.agent.session.snapshotEvents();
|
|
225
|
+
if (await this.host.sessions.flush(handle.agent.session) !== true) fail('RESULT_UNKNOWN', 'Native result flush failed');
|
|
226
|
+
const durable = await this.readEvents(job.nativeSessionId, handle);
|
|
227
|
+
if (!durable || digest(durable.slice(0, prefix.length)) !== digest(prefix)) fail('RESULT_UNKNOWN', 'Durable result differs from native observed prefix');
|
|
228
|
+
const release = await this.beforeResult?.(this.jobs.get(id));
|
|
229
|
+
try { return this.consume(id, durable); }
|
|
230
|
+
finally { release?.(); }
|
|
231
|
+
} catch (e) {
|
|
232
|
+
const job = this.jobs.get(id);
|
|
233
|
+
if (['WORKER_BUSY', 'WRITER_BUSY', 'NODE_BUSY', 'NODE_ORDER', 'LEARNING_UNKNOWN', 'LEARNING_PAUSED'].includes(e.code)) throw e;
|
|
234
|
+
if (TERMINAL_JOBS.includes(job.status)) return job;
|
|
235
|
+
// Domain application is atomic. Keep a validated result recoverable when
|
|
236
|
+
// an application error rolls the transaction back; it is already received.
|
|
237
|
+
if (['result_received', 'validated'].includes(job.status)) return this.jobs.recordError(id, e);
|
|
238
|
+
const state = e.code === 'JOB_STALE' ? 'stale' : job.status === 'queued' || job.status === 'prepared' && e.code !== 'RESULT_UNKNOWN' ? 'failed' : 'unknown';
|
|
239
|
+
return this.jobs.transition(id, state, { error: { code: e.code ?? 'HOST_ERROR', message: e.message } });
|
|
240
|
+
} finally { if (deadline !== undefined) this.timers.clearTimeout(deadline); if (handle) await handle.dispose(); this.handles.delete(id); }
|
|
241
|
+
}
|
|
242
|
+
async recover() {
|
|
243
|
+
const results = [];
|
|
244
|
+
for (const job of this.jobs.repo.list('tree_jobs', j => ['submitted', 'unknown', 'result_received', 'validated'].includes(j.status) && j.role)) {
|
|
245
|
+
try { results.push(this.consume(job.id, await this.readEvents(job.nativeSessionId))); }
|
|
246
|
+
catch (e) {
|
|
247
|
+
const current = this.jobs.get(job.id);
|
|
248
|
+
if (current.status === 'submitted' && e.code !== 'LIVE_SESSION') this.jobs.transition(job.id, 'unknown', { error: { code: e.code ?? 'HOST_ERROR', message: e.message } });
|
|
249
|
+
results.push({ id: job.id, status: this.jobs.get(job.id).status, error: e.code ?? e.message });
|
|
250
|
+
}
|
|
251
|
+
} return results;
|
|
252
|
+
}
|
|
253
|
+
async close() { this.closed = true; for (const handle of this.handles.values()) handle.agent.cancel({ kind: 'disposed' }, { keepInbox: true }); await Promise.allSettled([...this.running.values()]); }
|
|
254
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { digest, fail } from './contracts.mjs';
|
|
2
|
+
|
|
3
|
+
const unique = values => [...new Map(values.map(v => [digest(v), v])).values()];
|
|
4
|
+
|
|
5
|
+
// Quotes locate evidence; they do not prove that a model's interpretation is
|
|
6
|
+
// correct. Semantic acceptance remains a separate, held-out evaluation.
|
|
7
|
+
export function quotedSource(envelope, quote, role) {
|
|
8
|
+
const source = envelope.sources.find(s => s.id === quote.sourceId);
|
|
9
|
+
if (!envelope.currentTurn?.sourceIds.includes(quote.sourceId) || source?.role !== role || source.origin !== (role === 'user' ? 'user' : 'model')) fail('OUTPUT_CURRENT_SOURCE', 'Observation requires the current actual speaker');
|
|
10
|
+
const start = source.text.indexOf(quote.text);
|
|
11
|
+
if (!quote.text.trim() || start < 0 || source.text.indexOf(quote.text, start + 1) >= 0) fail('OUTPUT_QUOTE', 'Evidence quote must locate one exact span in its source');
|
|
12
|
+
return { source, start, end: start + quote.text.length };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function learningCorrections(output, envelope) {
|
|
16
|
+
const corrections = {}, replacements = new Map();
|
|
17
|
+
for (const revision of output.revisions) {
|
|
18
|
+
const target = envelope.knownItems.find(i => i.id === revision.targetId && i.version === revision.targetVersion);
|
|
19
|
+
if (!target) fail('OUTPUT_VERSION', 'Correction target changed');
|
|
20
|
+
if (target.kind !== 'understanding') fail('OUTPUT_CORRECTION', 'Only understandings may use this correction route');
|
|
21
|
+
if (corrections[target.id]) fail('OUTPUT_CORRECTION', 'One correction per item per result');
|
|
22
|
+
const replacement = output.understandings[revision.replacedByIndex];
|
|
23
|
+
if (!replacement) fail('OUTPUT_CORRECTION', 'A correction needs a replacement');
|
|
24
|
+
const claim = revision.correctionEvidence;
|
|
25
|
+
let correction = envelope.trigger?.correction;
|
|
26
|
+
if (claim) {
|
|
27
|
+
const sources = claim.quotes.map(q => quotedSource(envelope, q, 'user').source);
|
|
28
|
+
if (new Set(sources.map(s => s.id)).size !== sources.length || revision.sourceIds.some(id => !sources.some(s => s.id === id)) || sources.some(s => !revision.sourceIds.includes(s.id)) || revision.sourceIds.some(id => !replacement.sourceIds.includes(id))) fail('OUTPUT_CORRECTION', 'Correction and replacement must bind the same current human evidence');
|
|
29
|
+
correction = { itemIds: [target.id], sourceIds: sources.map(s => s.id), global: claim.scope === 'global', evidence: claim.quotes, classification: 'learning_model' };
|
|
30
|
+
}
|
|
31
|
+
if (!correction?.itemIds?.includes(target.id) || revision.sourceIds.some(id => !correction.sourceIds?.includes(id)) || !revision.sourceIds.some(id => envelope.sources.some(s => s.id === id && s.role === 'user' && s.origin === 'user'))) fail('OUTPUT_CORRECTION', 'Only a source-bound current-user correction may revise an understanding');
|
|
32
|
+
if (!envelope.scope.nodeIds.includes(target.nodeId) && !correction.global) fail('OUTPUT_LOCAL_EXCEPTION', 'A local exception cannot invalidate ancestor content');
|
|
33
|
+
if (replacements.has(revision.replacedByIndex) && replacements.get(revision.replacedByIndex) !== target.nodeId) fail('OUTPUT_CORRECTION', 'One replacement cannot acquire conflicting scopes');
|
|
34
|
+
replacements.set(revision.replacedByIndex, target.nodeId);
|
|
35
|
+
corrections[target.id] = { ...correction, sourceRefs: revision.sourceIds.map(id => envelope.sources.find(s => s.id === id).ref) };
|
|
36
|
+
}
|
|
37
|
+
return corrections;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function validateObservations(output, envelope) {
|
|
41
|
+
const observed = output.observations;
|
|
42
|
+
if (!observed) return;
|
|
43
|
+
const seen = new Set(), once = key => { if (seen.has(key)) fail('OUTPUT_OBSERVATION_DUPLICATE', 'Duplicate observation'); seen.add(key); };
|
|
44
|
+
for (const kind of ['proposals', 'firstResults']) for (const quote of observed[kind]) {
|
|
45
|
+
const span = quotedSource(envelope, quote, 'assistant');
|
|
46
|
+
once(kind + ':' + digest([span.source.ref, span.start, span.end]));
|
|
47
|
+
}
|
|
48
|
+
for (const [kind, key, list] of [['proposalResponses', 'proposalId', 'proposals'], ['resultFeedback', 'fragmentId', 'fragments']]) for (const observation of observed[kind]) {
|
|
49
|
+
once(kind + ':' + observation[key]);
|
|
50
|
+
const target = envelope.observationContext?.[list]?.find(v => v.id === observation[key]);
|
|
51
|
+
if (!target || !target.nodeIds.some(id => envelope.scope.nodeIds.includes(id)) || target.turnId === envelope.currentTurn.id) fail('OUTPUT_OBSERVATION_TARGET', 'Feedback must reference a supplied earlier object in this node');
|
|
52
|
+
observation.quotes.forEach(q => quotedSource(envelope, q, 'user'));
|
|
53
|
+
}
|
|
54
|
+
for (const quote of observed.confirmedDecisions) { quotedSource(envelope, quote, 'user'); once('decision:' + digest(quote)); }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class LearningObservations {
|
|
58
|
+
constructor({ repository, metrics }) { this.repo = repository; this.metrics = metrics; }
|
|
59
|
+
freeze(nodeId, priorTurnIds) {
|
|
60
|
+
const meta = this.repo.meta();
|
|
61
|
+
const select = rows => Object.values(rows ?? {}).filter(r => r.nodeIds.includes(nodeId) && priorTurnIds.includes(r.turnId));
|
|
62
|
+
const proposals = select(meta.proposals).filter(r => this.metrics.resolveSource(r.proposalRef));
|
|
63
|
+
const fragments = select(meta.taskFragments).filter(r => this.metrics.resolveSource(r.firstResultRef));
|
|
64
|
+
const projection = (row, ref, span) => {
|
|
65
|
+
const source = this.metrics.resolveSource(ref);
|
|
66
|
+
return { id: row.id, nodeIds: [nodeId], turnId: row.turnId, sourceId: source.id, text: span ? source.text.slice(span.start, span.end) : source.text };
|
|
67
|
+
};
|
|
68
|
+
// Do not copy mutable response history: it can include a later turn from
|
|
69
|
+
// another learning queue. Only pre-existing objects and their originals.
|
|
70
|
+
return { proposals: proposals.map(p => projection(p, p.proposalRef, p)), fragments: fragments.map(f => projection(f, f.firstResultRef, f.span)),
|
|
71
|
+
sources: unique([...proposals.map(p => this.metrics.resolveSource(p.proposalRef)), ...fragments.map(f => this.metrics.resolveSource(f.firstResultRef))]) };
|
|
72
|
+
}
|
|
73
|
+
}
|