@dsh-enhanced/assistant-evolution 0.1.6 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -17
- package/cordis.patch.yml +4 -1
- package/lib/guidance.d.ts +2 -0
- package/lib/guidance.d.ts.map +1 -1
- package/lib/guidance.js +7 -1
- package/lib/guidance.js.map +1 -1
- package/lib/index.d.ts +2 -2
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +2 -2
- package/lib/index.js.map +1 -1
- package/lib/review.d.ts +1 -1
- package/lib/review.d.ts.map +1 -1
- package/lib/review.js +22 -0
- package/lib/review.js.map +1 -1
- package/lib/service.d.ts +236 -12
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +947 -42
- package/lib/service.js.map +1 -1
- package/lib/sqlite.d.ts +1 -1
- package/lib/sqlite.d.ts.map +1 -1
- package/lib/sqlite.js +508 -7
- package/lib/sqlite.js.map +1 -1
- package/lib/store.d.ts +136 -3
- package/lib/store.d.ts.map +1 -1
- package/lib/store.js +1672 -108
- package/lib/store.js.map +1 -1
- package/lib/tools.d.ts.map +1 -1
- package/lib/tools.js +222 -1
- package/lib/tools.js.map +1 -1
- package/lib/types.d.ts +211 -11
- package/lib/types.d.ts.map +1 -1
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.d.ts.map +1 -1
- package/lib/version.js +1 -1
- package/lib/version.js.map +1 -1
- package/package.json +14 -4
package/lib/service.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { isAbsolute, resolve } from 'node:path';
|
|
3
3
|
import { Context, Service } from '@deepseek-ai/cordis';
|
|
4
4
|
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
5
5
|
import Schema from '@deepseek-ai/schemastery';
|
|
6
|
+
import { canonicalEvaluationHostScope, evaluationLearningProjectionDigest, } from '@dsh-enhanced/assistant-evaluation';
|
|
6
7
|
import { ApprovalSettlementConflict, validateApprovalSettlement, } from '@dsh-enhanced/assistant-policy';
|
|
7
8
|
import { buildGuidance, buildGuidanceSnapshot } from './guidance.js';
|
|
8
9
|
import { evolutionMutationReview } from './review.js';
|
|
9
|
-
import { EvolutionStore, EvolutionStoreError, evolutionDigest } from './store.js';
|
|
10
|
+
import { EvolutionStore, EvolutionStoreError, evolutionDigest, supervisedGrowthAnalystProposalIdempotencyKey, } from './store.js';
|
|
10
11
|
import { registerEvolutionTools } from './tools.js';
|
|
11
|
-
import { legacyEvolutionScope } from './types.js';
|
|
12
|
+
import { legacyEvolutionScope, SUPERVISED_GROWTH_ANALYST_CONTRACT_VERSION, } from './types.js';
|
|
12
13
|
const configSchema = Schema.object({
|
|
13
14
|
databasePath: Schema.string().required(),
|
|
14
15
|
evaluationWindow: Schema.number().step(1).min(1).max(10_000).default(20),
|
|
@@ -23,8 +24,20 @@ const configSchema = Schema.object({
|
|
|
23
24
|
defaultProposalTtlMs: Schema.number().step(1).min(1).default(900_000),
|
|
24
25
|
reconcileIntervalMs: Schema.number().step(1).min(0).default(15_000),
|
|
25
26
|
reconcileLimit: Schema.number().step(1).min(1).max(1_000).default(50),
|
|
27
|
+
autonomousRollback: Schema.boolean().default(false),
|
|
26
28
|
});
|
|
27
29
|
const evolutionApprovalSource = 'dsh-enhanced-assistant-evolution';
|
|
30
|
+
function registrationOwnedByDeliveryPresentation(registration) {
|
|
31
|
+
const candidate = registration;
|
|
32
|
+
try {
|
|
33
|
+
return typeof candidate.owner === 'object' && candidate.owner !== null
|
|
34
|
+
&& typeof candidate.owner.ownsTrustedDeliveryPresentationRegistration === 'function'
|
|
35
|
+
&& candidate.owner.ownsTrustedDeliveryPresentationRegistration(registration);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
28
41
|
export class AssistantEvolutionError extends Error {
|
|
29
42
|
code;
|
|
30
43
|
constructor(code, message) {
|
|
@@ -33,6 +46,51 @@ export class AssistantEvolutionError extends Error {
|
|
|
33
46
|
this.name = 'AssistantEvolutionError';
|
|
34
47
|
}
|
|
35
48
|
}
|
|
49
|
+
/** Fixed Policy subject used by the non-model supervised-growth runbook. */
|
|
50
|
+
export const HOST_RECOVERY_BACKGROUND_ID = 'dsh-enhanced-assistant-recovery';
|
|
51
|
+
/** The only Automation allowed to use the model-visible analyst capability. */
|
|
52
|
+
export const SUPERVISED_GROWTH_ANALYST_AUTOMATION_ID = 'heartbeat:supervised-growth-analyst';
|
|
53
|
+
const evolutionHostScopeBrand = Symbol('assistant-evolution.host-scope');
|
|
54
|
+
/** Canonical, immutable and intentionally non-serializable Host scope token. */
|
|
55
|
+
export function canonicalEvolutionHostScope(input) {
|
|
56
|
+
const scopeKey = canonicalEvolutionScope(input.workspace, input.preset);
|
|
57
|
+
const [workspace, preset] = JSON.parse(scopeKey);
|
|
58
|
+
const branded = { workspace, preset };
|
|
59
|
+
Object.defineProperty(branded, evolutionHostScopeBrand, {
|
|
60
|
+
value: true, enumerable: false, configurable: false, writable: false,
|
|
61
|
+
});
|
|
62
|
+
return Object.freeze(branded);
|
|
63
|
+
}
|
|
64
|
+
function exactEvolutionHostScope(input) {
|
|
65
|
+
if (typeof input !== 'object' || input === null || !Object.isFrozen(input)
|
|
66
|
+
|| input[evolutionHostScopeBrand] !== true) {
|
|
67
|
+
throw new AssistantEvolutionError('invalid-input', 'Host evolution operations require a canonical immutable scope');
|
|
68
|
+
}
|
|
69
|
+
const canonical = canonicalEvolutionHostScope(input);
|
|
70
|
+
if (canonical.workspace !== input.workspace || canonical.preset !== input.preset) {
|
|
71
|
+
throw new AssistantEvolutionError('invalid-input', 'Host evolution scope is not canonical');
|
|
72
|
+
}
|
|
73
|
+
return input;
|
|
74
|
+
}
|
|
75
|
+
function hostText(value, label, maxBytes) {
|
|
76
|
+
if (typeof value !== 'string') {
|
|
77
|
+
throw new AssistantEvolutionError('invalid-input', `${label} must be a string`);
|
|
78
|
+
}
|
|
79
|
+
const normalized = value.normalize('NFC').trim();
|
|
80
|
+
if (normalized === '' || Buffer.byteLength(normalized, 'utf8') > maxBytes
|
|
81
|
+
|| hasControl(normalized)) {
|
|
82
|
+
throw new AssistantEvolutionError('invalid-input', `${label} is invalid`);
|
|
83
|
+
}
|
|
84
|
+
return normalized;
|
|
85
|
+
}
|
|
86
|
+
function hasControl(value) {
|
|
87
|
+
for (const character of value) {
|
|
88
|
+
const codePoint = character.codePointAt(0);
|
|
89
|
+
if (codePoint <= 0x1f || codePoint === 0x7f)
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
36
94
|
export function canonicalEvolutionScope(workspace, preset) {
|
|
37
95
|
const normalizedPreset = preset.normalize('NFC').trim();
|
|
38
96
|
if (!isAbsolute(workspace) || normalizedPreset === '') {
|
|
@@ -63,24 +121,30 @@ function ruleIdFromStableMutation(stable) {
|
|
|
63
121
|
* only then let an approved rule shape future sessions as injected advisory
|
|
64
122
|
* context. Three boundaries are structural rather than cosmetic:
|
|
65
123
|
*
|
|
66
|
-
* - **No self-
|
|
67
|
-
*
|
|
68
|
-
*
|
|
124
|
+
* - **No self-adoption.** New or changed guidance is always an
|
|
125
|
+
* `assistant-policy` proposal. The only optional autonomous mutation removes
|
|
126
|
+
* an exact active rule after Host-recomputed regression evidence; it can never
|
|
127
|
+
* create or revise guidance.
|
|
69
128
|
* - **No privilege growth.** Guidance is injected as data and policy never reads
|
|
70
129
|
* the rule table, so a rule can change approach but never authority.
|
|
71
|
-
* - **No in-place revision.**
|
|
72
|
-
*
|
|
130
|
+
* - **No in-place revision.** Replacement is always retire-then-adopt. A rollback
|
|
131
|
+
* may remove the old rule, but the replacement still needs its own approval,
|
|
132
|
+
* so an old decision can never silently cover new guidance.
|
|
73
133
|
*/
|
|
74
134
|
export class AssistantEvolutionService extends Service {
|
|
75
135
|
static Config = configSchema;
|
|
76
|
-
static inject = ['assistantPolicy'];
|
|
136
|
+
static inject = ['assistantPolicy', 'assistantEvaluation'];
|
|
77
137
|
store;
|
|
78
138
|
policy;
|
|
139
|
+
context;
|
|
79
140
|
config;
|
|
80
|
-
|
|
141
|
+
now;
|
|
81
142
|
delivery;
|
|
143
|
+
presentationProducerGeneration = `assistant-evolution-presentation:${randomUUID()}`;
|
|
144
|
+
presentationSink;
|
|
82
145
|
active = true;
|
|
83
|
-
|
|
146
|
+
lastReconciledAt = 0;
|
|
147
|
+
constructor(ctx, input, options = {}) {
|
|
84
148
|
super(ctx, 'assistantEvolution');
|
|
85
149
|
try {
|
|
86
150
|
this.config = configSchema(input);
|
|
@@ -91,7 +155,9 @@ export class AssistantEvolutionService extends Service {
|
|
|
91
155
|
const policy = ctx.get('assistantPolicy');
|
|
92
156
|
if (policy === undefined)
|
|
93
157
|
throw new Error('assistant-evolution: assistantPolicy service is required');
|
|
158
|
+
this.context = ctx;
|
|
94
159
|
this.policy = policy;
|
|
160
|
+
this.now = options.now ?? Date.now;
|
|
95
161
|
this.store = new EvolutionStore({
|
|
96
162
|
path: this.config.databasePath,
|
|
97
163
|
maxGuidanceBytes: this.config.maxRuleGuidanceBytes,
|
|
@@ -99,6 +165,7 @@ export class AssistantEvolutionService extends Service {
|
|
|
99
165
|
ctx.inject(['assistantDelivery'], deliveryCtx => {
|
|
100
166
|
const delivery = deliveryCtx.get('assistantDelivery');
|
|
101
167
|
this.delivery = delivery;
|
|
168
|
+
this.reconcileApplicationPresentations();
|
|
102
169
|
return () => {
|
|
103
170
|
if (this.delivery === delivery)
|
|
104
171
|
this.delivery = undefined;
|
|
@@ -118,6 +185,7 @@ export class AssistantEvolutionService extends Service {
|
|
|
118
185
|
});
|
|
119
186
|
ctx.effect(() => () => {
|
|
120
187
|
this.active = false;
|
|
188
|
+
this.presentationSink?.dispose();
|
|
121
189
|
this.store.close();
|
|
122
190
|
}, 'assistant-evolution.database');
|
|
123
191
|
if (this.config.reconcileIntervalMs > 0) {
|
|
@@ -135,7 +203,55 @@ export class AssistantEvolutionService extends Service {
|
|
|
135
203
|
}, 'assistant-evolution.reconcile');
|
|
136
204
|
}
|
|
137
205
|
}
|
|
138
|
-
/**
|
|
206
|
+
/** Aggregate health seam. It never returns content, identities, scopes, or paths. */
|
|
207
|
+
health() {
|
|
208
|
+
this.assertActive();
|
|
209
|
+
return Object.freeze({
|
|
210
|
+
...this.store.health(),
|
|
211
|
+
lastReconciledAt: this.lastReconciledAt,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
trustedDeliveryPresentationProducerGeneration() {
|
|
215
|
+
this.assertActive();
|
|
216
|
+
return this.presentationProducerGeneration;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Accept only the publisher minted by the exact live Delivery instance.
|
|
220
|
+
* The registration is held privately; it is never surfaced to an Agent,
|
|
221
|
+
* tool schema, proposal payload, or Evolution read API.
|
|
222
|
+
*/
|
|
223
|
+
registerTrustedDeliveryPresentationSink(registration) {
|
|
224
|
+
this.assertActive();
|
|
225
|
+
if (registration.protocol !== 'assistant-delivery/trusted-presentation-producer/v1'
|
|
226
|
+
|| registration.producer !== 'assistant-evolution'
|
|
227
|
+
|| registration.generation !== this.presentationProducerGeneration
|
|
228
|
+
|| typeof registration.publish !== 'function'
|
|
229
|
+
|| !registrationOwnedByDeliveryPresentation(registration)) {
|
|
230
|
+
throw new AssistantEvolutionError('forbidden', 'trusted Delivery presentation registration is invalid');
|
|
231
|
+
}
|
|
232
|
+
const current = this.presentationSink;
|
|
233
|
+
if (current !== undefined) {
|
|
234
|
+
if (current.registration === registration)
|
|
235
|
+
return current.dispose;
|
|
236
|
+
throw new AssistantEvolutionError('forbidden', 'trusted Delivery presentation sink is already registered');
|
|
237
|
+
}
|
|
238
|
+
const token = Symbol('assistant-evolution.trusted-delivery-presentation');
|
|
239
|
+
let live = true;
|
|
240
|
+
const dispose = () => {
|
|
241
|
+
if (!live)
|
|
242
|
+
return;
|
|
243
|
+
live = false;
|
|
244
|
+
if (this.presentationSink?.token === token)
|
|
245
|
+
this.presentationSink = undefined;
|
|
246
|
+
};
|
|
247
|
+
this.presentationSink = Object.freeze({ token, registration, dispose });
|
|
248
|
+
// Existing durable terminal receipts become eligible as soon as their
|
|
249
|
+
// exact Delivery publisher appears; the local outbox remains authoritative
|
|
250
|
+
// if Delivery rejects or later revokes this generation.
|
|
251
|
+
this.reconcileApplicationPresentations();
|
|
252
|
+
return dispose;
|
|
253
|
+
}
|
|
254
|
+
/** Record one model-reported outcome as learning-ineligible operational audit. */
|
|
139
255
|
recordEpisode(agent, input) {
|
|
140
256
|
const scopeKey = this.authorize(agent, 'append', `situation:${input.situation}`);
|
|
141
257
|
return this.store.recordEpisode({
|
|
@@ -145,14 +261,17 @@ export class AssistantEvolutionService extends Service {
|
|
|
145
261
|
detail: input.detail,
|
|
146
262
|
source: 'foreground',
|
|
147
263
|
trust: 'self-reported',
|
|
264
|
+
evidenceKind: 'operational',
|
|
148
265
|
...(input.ruleId === undefined ? {} : { claimedRuleId: input.ruleId }),
|
|
149
266
|
occurredAt: input.occurredAt,
|
|
150
267
|
idempotencyKey: input.idempotencyKey,
|
|
151
268
|
});
|
|
152
269
|
}
|
|
153
270
|
/**
|
|
154
|
-
* Record an outcome observed by trusted local infrastructure rather
|
|
155
|
-
* turn, for example a finished background automation run.
|
|
271
|
+
* Record an execution outcome observed by trusted local infrastructure rather
|
|
272
|
+
* than a model turn, for example a finished background automation run. It is
|
|
273
|
+
* deliberately operational and learning-ineligible: execution success/failure
|
|
274
|
+
* does not establish objective task quality.
|
|
156
275
|
*
|
|
157
276
|
* There is no Agent to authorize here, so this is deliberately narrow: it can
|
|
158
277
|
* only append evidence. It cannot adopt, retire, or read rules, so an automation
|
|
@@ -186,11 +305,96 @@ export class AssistantEvolutionService extends Service {
|
|
|
186
305
|
scopeKey,
|
|
187
306
|
source: 'automation',
|
|
188
307
|
trust: trusted ? 'trusted' : 'legacy',
|
|
308
|
+
evidenceKind: trusted ? 'operational' : 'legacy-unknown',
|
|
189
309
|
...(attributed
|
|
190
310
|
? { ruleId: receipt.ruleId, guidanceVersion: receipt.guidanceVersion }
|
|
191
311
|
: input.ruleId === undefined ? {} : { claimedRuleId: input.ruleId }),
|
|
192
312
|
});
|
|
193
313
|
}
|
|
314
|
+
/**
|
|
315
|
+
* Deprecated unsafe v5 shape. Free caller-supplied quality fields can never
|
|
316
|
+
* produce learning-eligible evidence again; use projectEvaluationOutcome().
|
|
317
|
+
*/
|
|
318
|
+
recordEvaluationOutcome(input) {
|
|
319
|
+
this.assertActive();
|
|
320
|
+
void input;
|
|
321
|
+
throw new AssistantEvolutionError('forbidden', 'free-form Evaluation projection is disabled; use projectEvaluationOutcome with an authoritative receipt');
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Project one exact trusted Evaluation row into the learning ledger. The
|
|
325
|
+
* caller cannot supply outcome, detail, kind, time, situation or attribution.
|
|
326
|
+
*/
|
|
327
|
+
projectEvaluationOutcome(input) {
|
|
328
|
+
this.assertActive();
|
|
329
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)
|
|
330
|
+
|| Object.keys(input).sort().join(',') !== 'evaluationId,scope') {
|
|
331
|
+
throw new AssistantEvolutionError('invalid-input', 'Evaluation projection accepts only an exact scope and evaluationId');
|
|
332
|
+
}
|
|
333
|
+
const scope = exactEvolutionHostScope(input.scope);
|
|
334
|
+
const evaluationId = hostText(input.evaluationId, 'evaluationId', 200);
|
|
335
|
+
const receipt = this.trustedTaskLearningReceipt(scope, evaluationId);
|
|
336
|
+
const projected = this.applyTrustedTaskLearningReceipt(scope, receipt);
|
|
337
|
+
if (projected.episode === undefined) {
|
|
338
|
+
throw new AssistantEvolutionError('forbidden', 'the canonical task revision retracts rather than upserts a behavioural learning vote');
|
|
339
|
+
}
|
|
340
|
+
return projected.episode;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Narrow structural seam used only by Evaluation's durable outbox. The raw
|
|
344
|
+
* scope is canonicalized here; quality fields are still fetched exclusively
|
|
345
|
+
* from the trusted Evaluation ledger.
|
|
346
|
+
*/
|
|
347
|
+
projectTrustedEvaluationOutcome(_input) {
|
|
348
|
+
this.assertActive();
|
|
349
|
+
throw new AssistantEvolutionError('forbidden', 'raw Evaluation projection protocol is retired; use the versioned task-revision capability');
|
|
350
|
+
}
|
|
351
|
+
/** Evaluation protocol v2 sink. Every success echoes the exact applied revision. */
|
|
352
|
+
projectTrustedEvaluationTaskRevision(input) {
|
|
353
|
+
this.assertActive();
|
|
354
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)
|
|
355
|
+
|| Object.keys(input).sort().join(',') !== 'evaluationId,scope'
|
|
356
|
+
|| typeof input.scope !== 'object' || input.scope === null || Array.isArray(input.scope)
|
|
357
|
+
|| Object.keys(input.scope).sort().join(',') !== 'preset,workspace') {
|
|
358
|
+
throw new AssistantEvolutionError('invalid-input', 'trusted Evaluation projection input is invalid');
|
|
359
|
+
}
|
|
360
|
+
const evaluationId = hostText(input.evaluationId, 'evaluationId', 200);
|
|
361
|
+
const scope = canonicalEvolutionHostScope(input.scope);
|
|
362
|
+
const receipt = this.trustedTaskLearningReceipt(scope, evaluationId);
|
|
363
|
+
const projected = this.applyTrustedTaskLearningReceipt(scope, receipt);
|
|
364
|
+
return Object.freeze({
|
|
365
|
+
triggerOutcomeId: evaluationId,
|
|
366
|
+
subjectKind: receipt.projection.subjectKind,
|
|
367
|
+
subjectRef: receipt.projection.subjectRef,
|
|
368
|
+
version: receipt.projection.version,
|
|
369
|
+
digest: receipt.projection.digest,
|
|
370
|
+
scopeWatermark: receipt.scopeWatermark,
|
|
371
|
+
disposition: receipt.projection.disposition,
|
|
372
|
+
status: projected.replayed ? 'replayed' : 'applied',
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Called by Assistant Automations only after it installed the immutable
|
|
377
|
+
* production execution context and bound the Policy initiator. This closes
|
|
378
|
+
* the session-start ordering gap without weakening background Policy.
|
|
379
|
+
*/
|
|
380
|
+
injectAutomationGuidance(agent, execution) {
|
|
381
|
+
this.assertActive();
|
|
382
|
+
const current = agent.ctx
|
|
383
|
+
.get('assistantAutomationExecution');
|
|
384
|
+
const installed = current;
|
|
385
|
+
if (typeof current !== 'object' || current === null || Array.isArray(current)
|
|
386
|
+
|| Object.keys(current).sort().join(',') !== 'automationId,mode,occurrenceId'
|
|
387
|
+
|| installed?.mode !== execution.mode
|
|
388
|
+
|| installed.automationId !== execution.automationId
|
|
389
|
+
|| installed.occurrenceId !== execution.occurrenceId
|
|
390
|
+
|| Object.keys(execution).sort().join(',') !== 'automationId,mode,occurrenceId'
|
|
391
|
+
|| execution.mode !== 'production'
|
|
392
|
+
|| hostText(execution.automationId, 'automationId', 500) !== execution.automationId
|
|
393
|
+
|| hostText(execution.occurrenceId, 'occurrenceId', 500) !== execution.occurrenceId) {
|
|
394
|
+
throw new AssistantEvolutionError('forbidden', 'automation guidance requires the exact installed production execution context');
|
|
395
|
+
}
|
|
396
|
+
this.injectGuidance(agent);
|
|
397
|
+
}
|
|
194
398
|
/** Query an exact post-injection receipt for trusted automation attribution. */
|
|
195
399
|
async captureAutomationExposure(input) {
|
|
196
400
|
this.assertActive();
|
|
@@ -207,20 +411,271 @@ export class AssistantEvolutionService extends Service {
|
|
|
207
411
|
/** Candidate rule changes implied by current evidence. Never auto-applied. */
|
|
208
412
|
candidates(agent) {
|
|
209
413
|
const scopeKey = this.authorize(agent, 'inspect', 'candidates');
|
|
210
|
-
return this.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
414
|
+
return this.candidatesForScope(scopeKey);
|
|
415
|
+
}
|
|
416
|
+
/** Agent-free, exact-scope candidate inspection for the fixed Host runbook. */
|
|
417
|
+
hostCandidates(input) {
|
|
418
|
+
const scopeKey = this.authorizeHost(input, 'inspect', 'candidates');
|
|
419
|
+
return this.candidatesForScope(scopeKey);
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Review exactly one adoption candidate from the dedicated production
|
|
423
|
+
* analyst. The random review token is durable and bound to this occurrence;
|
|
424
|
+
* no foreground or preview session can obtain one.
|
|
425
|
+
*/
|
|
426
|
+
reviewSupervisedGrowthAdoption(agent) {
|
|
427
|
+
const execution = this.authorizeSupervisedGrowthAnalyst(agent, 'inspect', 'analyst-adoption');
|
|
428
|
+
// Even review requires the route that a later proposal will use. This
|
|
429
|
+
// prevents an analyst with no owner destination from minting stale tokens.
|
|
430
|
+
this.approvalRoute(agent, undefined);
|
|
431
|
+
const candidate = this.candidatesForScope(execution.scopeKey)
|
|
432
|
+
.filter(entry => entry.kind === 'adopt')
|
|
433
|
+
.sort((left, right) => {
|
|
434
|
+
const rateOrder = right.stats.failures * left.stats.total
|
|
435
|
+
- left.stats.failures * right.stats.total;
|
|
436
|
+
return rateOrder || right.stats.failures - left.stats.failures
|
|
437
|
+
|| right.stats.total - left.stats.total
|
|
438
|
+
|| left.situation.localeCompare(right.situation);
|
|
439
|
+
})[0];
|
|
440
|
+
if (candidate === undefined) {
|
|
441
|
+
return Object.freeze({ contractVersion: SUPERVISED_GROWTH_ANALYST_CONTRACT_VERSION });
|
|
442
|
+
}
|
|
443
|
+
const frozen = this.store.registerSupervisedGrowthAnalystReview({
|
|
444
|
+
scopeKey: execution.scopeKey,
|
|
445
|
+
occurrenceId: execution.occurrenceId,
|
|
446
|
+
candidate,
|
|
447
|
+
evidenceWindow: this.config.evaluationWindow,
|
|
448
|
+
});
|
|
449
|
+
return Object.freeze({
|
|
450
|
+
contractVersion: SUPERVISED_GROWTH_ANALYST_CONTRACT_VERSION,
|
|
451
|
+
candidate: Object.freeze({
|
|
452
|
+
...frozen.review.evidence,
|
|
453
|
+
reviewToken: frozen.review.reviewToken,
|
|
454
|
+
proposalExists: frozen.proposalExists,
|
|
455
|
+
}),
|
|
218
456
|
});
|
|
219
457
|
}
|
|
458
|
+
/**
|
|
459
|
+
* Turn one frozen analyst review into the normal owner approval. Identity is
|
|
460
|
+
* evidence-derived; wording changes and concurrent executions join the first
|
|
461
|
+
* durable proposal instead of producing several cards.
|
|
462
|
+
*/
|
|
463
|
+
proposeSupervisedGrowthAdoption(agent, input) {
|
|
464
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)
|
|
465
|
+
|| Object.keys(input).sort().join(',') !== 'guidance,reviewToken') {
|
|
466
|
+
throw new AssistantEvolutionError('invalid-input', 'analyst proposal accepts only reviewToken and guidance');
|
|
467
|
+
}
|
|
468
|
+
const execution = this.authorizeSupervisedGrowthAnalyst(agent, 'propose', 'proposals');
|
|
469
|
+
const route = this.approvalRoute(agent, undefined);
|
|
470
|
+
const reviewToken = hostText(input.reviewToken, 'reviewToken', 200);
|
|
471
|
+
const review = this.store.getSupervisedGrowthAnalystReview(reviewToken);
|
|
472
|
+
if (review === undefined || review.scopeKey !== execution.scopeKey
|
|
473
|
+
|| review.occurrenceId !== execution.occurrenceId) {
|
|
474
|
+
throw new AssistantEvolutionError('not-found', 'analyst review token does not belong to this production execution');
|
|
475
|
+
}
|
|
476
|
+
const evidence = review.evidence;
|
|
477
|
+
if (evidence.contractVersion !== SUPERVISED_GROWTH_ANALYST_CONTRACT_VERSION
|
|
478
|
+
|| evidence.total < this.config.minSample
|
|
479
|
+
|| evidence.failures / evidence.total < this.config.adoptFailureRate) {
|
|
480
|
+
throw new AssistantEvolutionError('forbidden', 'analyst review no longer satisfies adoption thresholds');
|
|
481
|
+
}
|
|
482
|
+
const idempotencyKey = supervisedGrowthAnalystProposalIdempotencyKey({
|
|
483
|
+
scopeKey: execution.scopeKey,
|
|
484
|
+
situation: evidence.situation,
|
|
485
|
+
evidenceDigest: evidence.evidenceDigest,
|
|
486
|
+
evidenceTotal: evidence.evidenceTotal,
|
|
487
|
+
contractVersion: evidence.contractVersion,
|
|
488
|
+
});
|
|
489
|
+
const ruleId = ruleIdFromStableMutation(evolutionDigest([
|
|
490
|
+
'supervised-growth-analyst-rule/v1',
|
|
491
|
+
idempotencyKey,
|
|
492
|
+
]));
|
|
493
|
+
const mutation = {
|
|
494
|
+
op: 'adopt',
|
|
495
|
+
ruleId,
|
|
496
|
+
input: {
|
|
497
|
+
scopeKey: execution.scopeKey,
|
|
498
|
+
situation: evidence.situation,
|
|
499
|
+
guidance: input.guidance,
|
|
500
|
+
},
|
|
501
|
+
baseline: {
|
|
502
|
+
scopeKey: execution.scopeKey,
|
|
503
|
+
situation: evidence.situation,
|
|
504
|
+
failures: evidence.failures,
|
|
505
|
+
total: evidence.total,
|
|
506
|
+
},
|
|
507
|
+
evidence: {
|
|
508
|
+
sampleEpisodeIds: evidence.sampleEpisodeIds,
|
|
509
|
+
digest: evidence.evidenceDigest,
|
|
510
|
+
total: evidence.evidenceTotal,
|
|
511
|
+
window: evidence.evidenceWindow,
|
|
512
|
+
scopeWatermark: evidence.scopeWatermark,
|
|
513
|
+
taskRevisions: evidence.taskRevisions,
|
|
514
|
+
},
|
|
515
|
+
};
|
|
516
|
+
const ttlMs = this.config.defaultProposalTtlMs;
|
|
517
|
+
const requester = `automation:${SUPERVISED_GROWTH_ANALYST_AUTOMATION_ID}`;
|
|
518
|
+
const reviewPresentation = evolutionMutationReview(mutation);
|
|
519
|
+
const creationIntent = {
|
|
520
|
+
idempotencyKey,
|
|
521
|
+
requester,
|
|
522
|
+
principal: route.principal,
|
|
523
|
+
action: reviewPresentation.action,
|
|
524
|
+
resource: reviewPresentation.resource,
|
|
525
|
+
diff: reviewPresentation.diff,
|
|
526
|
+
summary: reviewPresentation.summary,
|
|
527
|
+
ttlMs,
|
|
528
|
+
...(route.dispatch === undefined ? {} : { dispatch: route.dispatch }),
|
|
529
|
+
};
|
|
530
|
+
const local = this.withLearningWriterFence(mutation, () => (this.store.createSupervisedGrowthAnalystProposal({
|
|
531
|
+
reviewToken,
|
|
532
|
+
scopeKey: execution.scopeKey,
|
|
533
|
+
occurrenceId: execution.occurrenceId,
|
|
534
|
+
requester,
|
|
535
|
+
principal: route.principal,
|
|
536
|
+
mutation,
|
|
537
|
+
expiresAt: Date.now() + ttlMs,
|
|
538
|
+
creationIntent,
|
|
539
|
+
})));
|
|
540
|
+
if (local.proposal.policyProposalId !== undefined) {
|
|
541
|
+
if (local.proposal.status !== 'pending') {
|
|
542
|
+
return result(local.proposal, true, local.proposal.resultRuleId === undefined
|
|
543
|
+
? undefined
|
|
544
|
+
: this.store.getRule(local.proposal.resultRuleId));
|
|
545
|
+
}
|
|
546
|
+
return this.settleAttached(local.proposal, true) ?? result(local.proposal, true);
|
|
547
|
+
}
|
|
548
|
+
return this.submitLocalProposal(local.proposal, local.replayed);
|
|
549
|
+
}
|
|
220
550
|
listRules(agent, status) {
|
|
221
551
|
const scopeKey = this.authorize(agent, 'inspect', 'rules');
|
|
222
552
|
return this.store.listRules(scopeKey, status);
|
|
223
553
|
}
|
|
554
|
+
/** Agent-free, exact-scope rule inspection for the fixed Host runbook. */
|
|
555
|
+
hostListRules(input) {
|
|
556
|
+
const scopeKey = this.authorizeHost(input, 'inspect', `rules:${input.status ?? 'all'}`);
|
|
557
|
+
return this.store.listRules(scopeKey, input.status);
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Remove one exact active guidance generation through the opt-in low-risk lane.
|
|
561
|
+
*
|
|
562
|
+
* The model identifies only the immutable rule and its observed version. This
|
|
563
|
+
* method derives scope from the Agent, authorizes the exact Policy action, and
|
|
564
|
+
* delegates the evidence/risk/reason decision to one transactional Host path.
|
|
565
|
+
*/
|
|
566
|
+
rollback(agent, input) {
|
|
567
|
+
const normalized = this.normalizedRollbackInput(input);
|
|
568
|
+
const scopeKey = this.authorize(agent, 'rollback', `rule:${normalized.ruleId}`);
|
|
569
|
+
return this.rollbackForScope(scopeKey, normalized);
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Retire one exact active rule by CAS. The Store admits only quality-eligible
|
|
573
|
+
* objective/verification evidence attributed to an exact exposure receipt;
|
|
574
|
+
* operational automation outcomes cannot satisfy this lane.
|
|
575
|
+
*/
|
|
576
|
+
hostRollbackOne(input) {
|
|
577
|
+
const normalized = this.normalizedRollbackInput(input);
|
|
578
|
+
const scopeKey = this.authorizeHost(input, 'rollback', `rule:${normalized.ruleId}`);
|
|
579
|
+
return this.rollbackForScope(scopeKey, normalized);
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Ask the authenticated owner to remove one exact active guidance version.
|
|
583
|
+
*
|
|
584
|
+
* This is intentionally a proposal, not a direct rollback: the current Agent
|
|
585
|
+
* supplies the exact workspace/preset scope, Delivery supplies the owner
|
|
586
|
+
* principal, and Policy freezes a distinct `evolution.owner-undo` review
|
|
587
|
+
* tuple. Approval can only retire the rule; it cannot revise its guidance or
|
|
588
|
+
* grant authority. No regression sample is required because the owner is the
|
|
589
|
+
* authority withdrawing their own earlier approval.
|
|
590
|
+
*/
|
|
591
|
+
requestOwnerUndo(agent, input) {
|
|
592
|
+
this.assertActive();
|
|
593
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
594
|
+
throw new AssistantEvolutionError('invalid-input', 'owner undo input must be an object');
|
|
595
|
+
}
|
|
596
|
+
const keys = Object.keys(input).sort().join(',');
|
|
597
|
+
if (keys !== 'expectedVersion,operationId,ruleId'
|
|
598
|
+
&& keys !== 'expectedVersion,operationId,ruleId,ttlMs') {
|
|
599
|
+
throw new AssistantEvolutionError('invalid-input', 'owner undo accepts only ruleId, expectedVersion, operationId, and optional ttlMs');
|
|
600
|
+
}
|
|
601
|
+
const scopeKey = this.authorize(agent, 'propose', 'proposals');
|
|
602
|
+
const ruleId = hostText(input.ruleId, 'ruleId', 200);
|
|
603
|
+
if (!/^rule-[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u
|
|
604
|
+
.test(ruleId)) {
|
|
605
|
+
throw new AssistantEvolutionError('invalid-input', 'ruleId must be an immutable server-issued rule ID');
|
|
606
|
+
}
|
|
607
|
+
if (!Number.isSafeInteger(input.expectedVersion) || input.expectedVersion < 1
|
|
608
|
+
|| input.expectedVersion > 1_000_000_000) {
|
|
609
|
+
throw new AssistantEvolutionError('invalid-input', 'expectedVersion must be a positive safe integer');
|
|
610
|
+
}
|
|
611
|
+
const operationId = hostText(input.operationId, 'operationId', 500);
|
|
612
|
+
const ttlMs = input.ttlMs ?? this.config.defaultProposalTtlMs;
|
|
613
|
+
if (!Number.isSafeInteger(ttlMs) || ttlMs < 1) {
|
|
614
|
+
throw new AssistantEvolutionError('invalid-input', 'ttlMs must be a positive safe integer');
|
|
615
|
+
}
|
|
616
|
+
const targetRule = this.store.getRule(ruleId);
|
|
617
|
+
if (targetRule === undefined || targetRule.scopeKey !== scopeKey) {
|
|
618
|
+
throw new AssistantEvolutionError('not-found', 'evolution rule was not found in the Agent scope');
|
|
619
|
+
}
|
|
620
|
+
const route = this.approvalRoute(agent, undefined);
|
|
621
|
+
const requester = `agent:${agent.session.header.agentPreset}:owner-undo`;
|
|
622
|
+
const mutation = {
|
|
623
|
+
op: 'owner-undo',
|
|
624
|
+
scopeKey,
|
|
625
|
+
ruleId,
|
|
626
|
+
situation: targetRule.situation,
|
|
627
|
+
guidance: targetRule.guidance,
|
|
628
|
+
generation: targetRule.generation,
|
|
629
|
+
expectedVersion: input.expectedVersion,
|
|
630
|
+
reason: 'Owner-approved immediate guidance undo.',
|
|
631
|
+
};
|
|
632
|
+
// operationId is the caller's retry identity. Deliberately exclude the
|
|
633
|
+
// target tuple from this key so reusing one operation for another target is
|
|
634
|
+
// detected as an idempotency conflict instead of creating a second card.
|
|
635
|
+
const stable = evolutionDigest(['owner-undo-v1', scopeKey, route.principal, operationId]);
|
|
636
|
+
const idempotencyKey = `evolution-owner-undo:${stable}`;
|
|
637
|
+
const review = evolutionMutationReview(mutation);
|
|
638
|
+
const creationIntent = {
|
|
639
|
+
idempotencyKey,
|
|
640
|
+
requester,
|
|
641
|
+
principal: route.principal,
|
|
642
|
+
action: review.action,
|
|
643
|
+
resource: review.resource,
|
|
644
|
+
diff: review.diff,
|
|
645
|
+
summary: review.summary,
|
|
646
|
+
ttlMs,
|
|
647
|
+
...(route.dispatch === undefined ? {} : { dispatch: route.dispatch }),
|
|
648
|
+
};
|
|
649
|
+
const prior = this.store.getProposalByIdempotencyKey(idempotencyKey);
|
|
650
|
+
if (prior === undefined) {
|
|
651
|
+
if (targetRule.status !== 'active') {
|
|
652
|
+
throw new AssistantEvolutionError('not-found', 'active evolution rule was not found in the Agent scope');
|
|
653
|
+
}
|
|
654
|
+
if (targetRule.version !== input.expectedVersion) {
|
|
655
|
+
throw new AssistantEvolutionError('invalid-input', 'owner undo expectedVersion is stale');
|
|
656
|
+
}
|
|
657
|
+
if (this.store.ruleApprovalPrincipal(ruleId) !== route.principal) {
|
|
658
|
+
throw new AssistantEvolutionError('forbidden', 'owner undo route does not match the principal who approved this guidance');
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
const local = this.store.createProposal({
|
|
662
|
+
idempotencyKey,
|
|
663
|
+
requester,
|
|
664
|
+
principal: route.principal,
|
|
665
|
+
mutation,
|
|
666
|
+
// Policy proposal expiry uses the wall clock; the injectable clock is
|
|
667
|
+
// reserved for deterministic ledger/audit timestamps.
|
|
668
|
+
expiresAt: Date.now() + ttlMs,
|
|
669
|
+
creationIntent,
|
|
670
|
+
});
|
|
671
|
+
if (local.policyProposalId !== undefined) {
|
|
672
|
+
if (local.status !== 'pending') {
|
|
673
|
+
return result(local, true, local.resultRuleId === undefined ? undefined : this.store.getRule(local.resultRuleId));
|
|
674
|
+
}
|
|
675
|
+
return this.settleAttached(local, true) ?? result(local, true);
|
|
676
|
+
}
|
|
677
|
+
return this.submitLocalProposal(local, false);
|
|
678
|
+
}
|
|
224
679
|
/**
|
|
225
680
|
* Propose adopting or retiring a rule. Returns a pending proposal; the owner
|
|
226
681
|
* decides through the normal approval surface.
|
|
@@ -260,6 +715,9 @@ export class AssistantEvolutionService extends Service {
|
|
|
260
715
|
sampleEpisodeIds: candidate.evidence.map(entry => entry.episodeId),
|
|
261
716
|
digest: candidate.evidenceDigest,
|
|
262
717
|
total: candidate.evidenceTotal,
|
|
718
|
+
window: this.config.evaluationWindow,
|
|
719
|
+
scopeWatermark: candidate.scopeWatermark,
|
|
720
|
+
taskRevisions: candidate.taskRevisions,
|
|
263
721
|
},
|
|
264
722
|
};
|
|
265
723
|
}
|
|
@@ -284,7 +742,11 @@ export class AssistantEvolutionService extends Service {
|
|
|
284
742
|
}
|
|
285
743
|
mutation = {
|
|
286
744
|
op: 'retire',
|
|
745
|
+
scopeKey,
|
|
287
746
|
ruleId: existing.id,
|
|
747
|
+
situation: existing.situation,
|
|
748
|
+
guidance: existing.guidance,
|
|
749
|
+
generation: existing.generation,
|
|
288
750
|
expectedVersion: existing.version,
|
|
289
751
|
reason: input.mutation.reason,
|
|
290
752
|
evaluation: candidate.stats,
|
|
@@ -293,6 +755,9 @@ export class AssistantEvolutionService extends Service {
|
|
|
293
755
|
sampleEpisodeIds: candidate.evidence.map(entry => entry.episodeId),
|
|
294
756
|
digest: candidate.evidenceDigest,
|
|
295
757
|
total: candidate.evidenceTotal,
|
|
758
|
+
window: this.config.evaluationWindow,
|
|
759
|
+
scopeWatermark: candidate.scopeWatermark,
|
|
760
|
+
taskRevisions: candidate.taskRevisions,
|
|
296
761
|
},
|
|
297
762
|
};
|
|
298
763
|
}
|
|
@@ -313,14 +778,14 @@ export class AssistantEvolutionService extends Service {
|
|
|
313
778
|
ttlMs,
|
|
314
779
|
...(route.dispatch === undefined ? {} : { dispatch: route.dispatch }),
|
|
315
780
|
};
|
|
316
|
-
const local = this.store.createProposal({
|
|
781
|
+
const local = this.withLearningWriterFence(mutation, () => this.store.createProposal({
|
|
317
782
|
idempotencyKey,
|
|
318
783
|
requester,
|
|
319
784
|
principal: route.principal,
|
|
320
785
|
mutation,
|
|
321
786
|
expiresAt: Date.now() + ttlMs,
|
|
322
787
|
creationIntent,
|
|
323
|
-
});
|
|
788
|
+
}));
|
|
324
789
|
if (local.policyProposalId !== undefined) {
|
|
325
790
|
if (local.status !== 'pending') {
|
|
326
791
|
return result(local, true, local.resultRuleId === undefined ? undefined : this.store.getRule(local.resultRuleId));
|
|
@@ -338,7 +803,17 @@ export class AssistantEvolutionService extends Service {
|
|
|
338
803
|
reconcileProposals(limit) {
|
|
339
804
|
this.assertActive();
|
|
340
805
|
const settled = [];
|
|
341
|
-
|
|
806
|
+
const bounded = limit ?? this.config.reconcileLimit;
|
|
807
|
+
const queue = new Map();
|
|
808
|
+
for (const proposal of this.store.listUnattachedProposalIntents(bounded)) {
|
|
809
|
+
queue.set(proposal.proposalId, proposal);
|
|
810
|
+
}
|
|
811
|
+
for (const proposal of this.store.listPendingProposals(bounded)) {
|
|
812
|
+
if (queue.size >= bounded)
|
|
813
|
+
break;
|
|
814
|
+
queue.set(proposal.proposalId, proposal);
|
|
815
|
+
}
|
|
816
|
+
for (const pending of queue.values()) {
|
|
342
817
|
try {
|
|
343
818
|
const result = pending.policyProposalId === undefined
|
|
344
819
|
? this.submitLocalProposal(pending, false)
|
|
@@ -355,11 +830,72 @@ export class AssistantEvolutionService extends Service {
|
|
|
355
830
|
this.store.deferPendingProposal(pending.proposalId);
|
|
356
831
|
}
|
|
357
832
|
}
|
|
833
|
+
this.reconcileApplicationPresentations(limit);
|
|
834
|
+
this.lastReconciledAt = this.now();
|
|
358
835
|
return settled;
|
|
359
836
|
}
|
|
837
|
+
/**
|
|
838
|
+
* Publish domain terminal receipts independently from proposal polling. A
|
|
839
|
+
* crash after Delivery accepts but before the local ack simply republishes the
|
|
840
|
+
* same revision and digest on restart.
|
|
841
|
+
*/
|
|
842
|
+
reconcileApplicationPresentations(limit) {
|
|
843
|
+
this.assertActive();
|
|
844
|
+
const sink = this.presentationSink;
|
|
845
|
+
if (sink === undefined)
|
|
846
|
+
return 0;
|
|
847
|
+
const bounded = limit ?? this.config.reconcileLimit;
|
|
848
|
+
let published = 0;
|
|
849
|
+
for (const entry of this.store.listPendingEvolutionApplicationReceipts(bounded)) {
|
|
850
|
+
if (this.presentationSink?.token !== sink.token)
|
|
851
|
+
return published;
|
|
852
|
+
const receipt = entry.receipt;
|
|
853
|
+
try {
|
|
854
|
+
sink.registration.publish(this.applicationPresentation(receipt));
|
|
855
|
+
this.store.settleEvolutionApplicationPublication({
|
|
856
|
+
localProposalId: receipt.localProposalId,
|
|
857
|
+
receiptDigest: receipt.receiptDigest,
|
|
858
|
+
outcome: 'published',
|
|
859
|
+
});
|
|
860
|
+
published += 1;
|
|
861
|
+
}
|
|
862
|
+
catch (error) {
|
|
863
|
+
const code = error instanceof Error
|
|
864
|
+
? `delivery-${error.name.normalize('NFC').toLowerCase().replaceAll(/[^a-z0-9]+/gu, '-').slice(0, 80)}`
|
|
865
|
+
: 'delivery-unknown-error';
|
|
866
|
+
this.store.settleEvolutionApplicationPublication({
|
|
867
|
+
localProposalId: receipt.localProposalId,
|
|
868
|
+
receiptDigest: receipt.receiptDigest,
|
|
869
|
+
outcome: 'retry',
|
|
870
|
+
error: code,
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return published;
|
|
875
|
+
}
|
|
876
|
+
applicationPresentation(receipt) {
|
|
877
|
+
return Object.freeze({
|
|
878
|
+
presentationKey: `approval-application:${receipt.policyProposalId}`,
|
|
879
|
+
originalOutboxIdempotencyKey: `approval-card:${receipt.policyProposalId}`,
|
|
880
|
+
revision: receipt.revision,
|
|
881
|
+
presentation: Object.freeze({
|
|
882
|
+
kind: 'approval-application',
|
|
883
|
+
policyProposalId: receipt.policyProposalId,
|
|
884
|
+
localProposalId: receipt.localProposalId,
|
|
885
|
+
applicationStatus: receipt.applicationStatus,
|
|
886
|
+
operation: receipt.operation,
|
|
887
|
+
terminalAt: receipt.terminalAt,
|
|
888
|
+
receiptDigest: receipt.receiptDigest,
|
|
889
|
+
...(receipt.ruleId === undefined ? {} : { ruleId: receipt.ruleId }),
|
|
890
|
+
...(receipt.resultingRuleVersion === undefined
|
|
891
|
+
? {} : { resultingRuleVersion: receipt.resultingRuleVersion }),
|
|
892
|
+
...(receipt.ruleStatus === undefined ? {} : { ruleStatus: receipt.ruleStatus }),
|
|
893
|
+
}),
|
|
894
|
+
});
|
|
895
|
+
}
|
|
360
896
|
submitLocalProposal(local, replayed) {
|
|
361
897
|
const intent = local.creationIntent;
|
|
362
|
-
if (intent === undefined) {
|
|
898
|
+
if (intent === undefined || (intent.dispatch !== undefined && intent.dispatch.routeVersion !== 2)) {
|
|
363
899
|
const conflict = this.store.settleProposal({
|
|
364
900
|
proposalId: local.proposalId,
|
|
365
901
|
securityConflict: true,
|
|
@@ -375,7 +911,7 @@ export class AssistantEvolutionService extends Service {
|
|
|
375
911
|
diff: intent.diff,
|
|
376
912
|
summary: intent.summary,
|
|
377
913
|
notAfter: local.expiresAt,
|
|
378
|
-
...(intent.dispatch ===
|
|
914
|
+
...(intent.dispatch?.routeVersion === 2 ? { dispatch: intent.dispatch } : {}),
|
|
379
915
|
};
|
|
380
916
|
const recovered = this.policy.recoverOrCreateProposal(recoveryInput);
|
|
381
917
|
if (recovered.kind === 'abandoned') {
|
|
@@ -383,7 +919,8 @@ export class AssistantEvolutionService extends Service {
|
|
|
383
919
|
proposalId: local.proposalId,
|
|
384
920
|
securityConflict: true,
|
|
385
921
|
});
|
|
386
|
-
|
|
922
|
+
const completed = this.store.completeAbandonedProposalRecovery(local.proposalId);
|
|
923
|
+
return result(completed, replayed || recovered.replayed || conflict.replayed, conflict.rule);
|
|
387
924
|
}
|
|
388
925
|
const decision = recovered.proposal;
|
|
389
926
|
const snapshot = this.policy.getProposal(decision.proposalId);
|
|
@@ -441,12 +978,27 @@ export class AssistantEvolutionService extends Service {
|
|
|
441
978
|
return undefined;
|
|
442
979
|
try {
|
|
443
980
|
const terminal = validateApprovalSettlement(snapshot, local.settlementExpectation);
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
981
|
+
let applied;
|
|
982
|
+
try {
|
|
983
|
+
applied = this.withLearningWriterFence(local.mutation, () => this.store.settleProposal({
|
|
984
|
+
proposalId: local.proposalId,
|
|
985
|
+
policyStatus: terminal.status,
|
|
986
|
+
policyVersion: terminal.version,
|
|
987
|
+
reviewExpectation: local.settlementExpectation,
|
|
988
|
+
}));
|
|
989
|
+
}
|
|
990
|
+
catch (error) {
|
|
991
|
+
if (!(error instanceof AssistantEvolutionError) || error.code !== 'forbidden')
|
|
992
|
+
throw error;
|
|
993
|
+
// A stale/missing authoritative fence is itself the domain terminal.
|
|
994
|
+
// This conflict-only transaction cannot apply a rule and therefore does
|
|
995
|
+
// not need to acquire Evaluation in the reverse lock order.
|
|
996
|
+
applied = this.store.settleProposal({
|
|
997
|
+
proposalId: local.proposalId,
|
|
998
|
+
securityConflict: true,
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
this.reconcileApplicationPresentations();
|
|
450
1002
|
return result(applied.proposal, replayed || applied.replayed, applied.rule);
|
|
451
1003
|
}
|
|
452
1004
|
catch (error) {
|
|
@@ -456,6 +1008,7 @@ export class AssistantEvolutionService extends Service {
|
|
|
456
1008
|
proposalId: local.proposalId,
|
|
457
1009
|
securityConflict: true,
|
|
458
1010
|
});
|
|
1011
|
+
this.reconcileApplicationPresentations();
|
|
459
1012
|
return result(conflict.proposal, replayed || conflict.replayed, conflict.rule);
|
|
460
1013
|
}
|
|
461
1014
|
}
|
|
@@ -463,7 +1016,8 @@ export class AssistantEvolutionService extends Service {
|
|
|
463
1016
|
if (this.delivery !== undefined) {
|
|
464
1017
|
const dispatch = this.delivery.prepareAgentApproval(agent, { sourceId: evolutionApprovalSource });
|
|
465
1018
|
const workspace = agent?.session.header.cwd;
|
|
466
|
-
if (dispatch.
|
|
1019
|
+
if (dispatch.routeVersion !== 2
|
|
1020
|
+
|| dispatch.sourceId !== evolutionApprovalSource || dispatch.workspace !== workspace
|
|
467
1021
|
|| dispatch.principal.trim() === '') {
|
|
468
1022
|
throw new AssistantEvolutionError('invalid-input', 'authenticated approval route does not match the Agent');
|
|
469
1023
|
}
|
|
@@ -478,6 +1032,315 @@ export class AssistantEvolutionService extends Service {
|
|
|
478
1032
|
}
|
|
479
1033
|
return { principal };
|
|
480
1034
|
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Acquire Evaluation first and Evolution only inside its synchronous callback.
|
|
1037
|
+
* No code holding an Evolution writer lock is allowed to call this method.
|
|
1038
|
+
*/
|
|
1039
|
+
withLearningWriterFence(mutation, callback) {
|
|
1040
|
+
if (mutation.op === 'owner-undo')
|
|
1041
|
+
return callback();
|
|
1042
|
+
const evidence = mutation.evidence;
|
|
1043
|
+
if (evidence === undefined || evidence.scopeWatermark === undefined
|
|
1044
|
+
|| evidence.taskRevisions === undefined || evidence.taskRevisions.length !== evidence.total
|
|
1045
|
+
|| evidence.taskRevisions.length < 1) {
|
|
1046
|
+
throw new AssistantEvolutionError('forbidden', 'evidence-dependent Evolution operations require a complete Evaluation writer fence');
|
|
1047
|
+
}
|
|
1048
|
+
const scopeKey = mutation.op === 'adopt' ? mutation.input.scopeKey : mutation.scopeKey;
|
|
1049
|
+
let scope;
|
|
1050
|
+
try {
|
|
1051
|
+
const parsed = JSON.parse(scopeKey);
|
|
1052
|
+
if (!Array.isArray(parsed) || parsed.length !== 2
|
|
1053
|
+
|| typeof parsed[0] !== 'string' || typeof parsed[1] !== 'string')
|
|
1054
|
+
throw new Error('invalid');
|
|
1055
|
+
scope = { workspace: parsed[0], preset: parsed[1] };
|
|
1056
|
+
}
|
|
1057
|
+
catch {
|
|
1058
|
+
throw new AssistantEvolutionError('forbidden', 'Evolution evidence scope is not canonical');
|
|
1059
|
+
}
|
|
1060
|
+
const evaluation = this.context.get('assistantEvaluation');
|
|
1061
|
+
if (evaluation === undefined
|
|
1062
|
+
|| typeof evaluation.withTrustedLearningWriterFence !== 'function') {
|
|
1063
|
+
throw new AssistantEvolutionError('forbidden', 'authoritative Evaluation writer fence capability is unavailable');
|
|
1064
|
+
}
|
|
1065
|
+
const result = evaluation.withTrustedLearningWriterFence({
|
|
1066
|
+
scope: canonicalEvaluationHostScope(scope),
|
|
1067
|
+
scopeWatermark: evidence.scopeWatermark,
|
|
1068
|
+
evidence: evidence.taskRevisions,
|
|
1069
|
+
}, callback);
|
|
1070
|
+
if (typeof result !== 'object' || result === null || !Object.isFrozen(result)
|
|
1071
|
+
|| result.matched !== true) {
|
|
1072
|
+
const reason = typeof result === 'object' && result !== null && 'reason' in result
|
|
1073
|
+
? String(result.reason)
|
|
1074
|
+
: 'invalid-fence-receipt';
|
|
1075
|
+
throw new AssistantEvolutionError('forbidden', `Evolution evidence is stale at the authoritative writer fence: ${reason}`);
|
|
1076
|
+
}
|
|
1077
|
+
return result.value;
|
|
1078
|
+
}
|
|
1079
|
+
candidatesForScope(scopeKey) {
|
|
1080
|
+
return this.store.candidates({
|
|
1081
|
+
scopeKey,
|
|
1082
|
+
window: this.config.evaluationWindow,
|
|
1083
|
+
minSample: this.config.minSample,
|
|
1084
|
+
adoptFailureRate: this.config.adoptFailureRate,
|
|
1085
|
+
retireFailureRate: this.config.retireFailureRate,
|
|
1086
|
+
limit: this.config.maxCandidates,
|
|
1087
|
+
evidenceSampleLimit: this.config.maxEvidenceSamples,
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
trustedTaskLearningReceipt(scope, evaluationId) {
|
|
1091
|
+
const evaluation = this.context.get('assistantEvaluation');
|
|
1092
|
+
if (evaluation === undefined || typeof evaluation.getTrustedTaskLearningProjection !== 'function') {
|
|
1093
|
+
throw new AssistantEvolutionError('forbidden', 'authoritative assistantEvaluation lookup is unavailable');
|
|
1094
|
+
}
|
|
1095
|
+
const receipt = evaluation.getTrustedTaskLearningProjection({
|
|
1096
|
+
scope: canonicalEvaluationHostScope(scope),
|
|
1097
|
+
outcomeId: evaluationId,
|
|
1098
|
+
});
|
|
1099
|
+
if (receipt === undefined) {
|
|
1100
|
+
throw new AssistantEvolutionError('not-found', 'trusted Evaluation outcome was not found in the exact scope');
|
|
1101
|
+
}
|
|
1102
|
+
const scopeKey = canonicalEvolutionScope(scope.workspace, scope.preset);
|
|
1103
|
+
if (!Object.isFrozen(receipt) || !Object.isFrozen(receipt.scope)
|
|
1104
|
+
|| !Object.isFrozen(receipt.projection)
|
|
1105
|
+
|| receipt.triggerOutcomeId !== evaluationId || receipt.scopeKey !== scopeKey
|
|
1106
|
+
|| receipt.scope.workspace !== scope.workspace
|
|
1107
|
+
|| receipt.scope.preset !== scope.preset
|
|
1108
|
+
|| !Number.isSafeInteger(receipt.scopeWatermark) || receipt.scopeWatermark < 1
|
|
1109
|
+
|| (receipt.projection.subjectKind !== 'automation-run'
|
|
1110
|
+
&& receipt.projection.subjectKind !== 'outcome')
|
|
1111
|
+
|| hostText(receipt.projection.subjectRef, 'projection.subjectRef', 1_000)
|
|
1112
|
+
!== receipt.projection.subjectRef
|
|
1113
|
+
|| !Number.isSafeInteger(receipt.projection.version) || receipt.projection.version < 1
|
|
1114
|
+
|| receipt.projection.version > 1_000_000_000
|
|
1115
|
+
|| !/^[a-f\d]{64}$/u.test(receipt.projection.digest)
|
|
1116
|
+
|| (receipt.projection.disposition !== 'upsert'
|
|
1117
|
+
&& receipt.projection.disposition !== 'retract')
|
|
1118
|
+
|| evaluationLearningProjectionDigest(receipt) !== receipt.projection.digest) {
|
|
1119
|
+
throw new AssistantEvolutionError('forbidden', 'Evaluation returned a mismatched trusted receipt');
|
|
1120
|
+
}
|
|
1121
|
+
if (receipt.execution !== undefined && (!Object.isFrozen(receipt.execution)
|
|
1122
|
+
|| !Object.isFrozen(receipt.execution.source)
|
|
1123
|
+
|| !Object.isFrozen(receipt.execution.evaluator)
|
|
1124
|
+
|| !Object.isFrozen(receipt.execution.evidence)
|
|
1125
|
+
|| receipt.execution.evidence.some(entry => !Object.isFrozen(entry)))) {
|
|
1126
|
+
throw new AssistantEvolutionError('forbidden', 'Evaluation execution component is mutable');
|
|
1127
|
+
}
|
|
1128
|
+
if (receipt.objective !== undefined && (!Object.isFrozen(receipt.objective)
|
|
1129
|
+
|| !Object.isFrozen(receipt.objective.source)
|
|
1130
|
+
|| !Object.isFrozen(receipt.objective.evaluator)
|
|
1131
|
+
|| !Object.isFrozen(receipt.objective.evidence)
|
|
1132
|
+
|| receipt.objective.evidence.some(entry => !Object.isFrozen(entry)))) {
|
|
1133
|
+
throw new AssistantEvolutionError('forbidden', 'Evaluation objective component is mutable');
|
|
1134
|
+
}
|
|
1135
|
+
if (receipt.projection.disposition === 'upsert'
|
|
1136
|
+
&& (receipt.objective === undefined
|
|
1137
|
+
|| (receipt.objective.status !== 'achieved' && receipt.objective.status !== 'not-achieved')
|
|
1138
|
+
|| receipt.projection.evidenceOutcomeId !== receipt.objective.outcomeId)) {
|
|
1139
|
+
throw new AssistantEvolutionError('forbidden', 'an upsert task revision requires one exact binary objective component');
|
|
1140
|
+
}
|
|
1141
|
+
return receipt;
|
|
1142
|
+
}
|
|
1143
|
+
applyTrustedTaskLearningReceipt(scope, receipt) {
|
|
1144
|
+
const scopeKey = canonicalEvolutionScope(scope.workspace, scope.preset);
|
|
1145
|
+
if (receipt.projection.disposition === 'retract') {
|
|
1146
|
+
return this.store.applyTaskLearningProjection({
|
|
1147
|
+
scopeKey,
|
|
1148
|
+
scopeWatermark: receipt.scopeWatermark,
|
|
1149
|
+
subjectKind: receipt.projection.subjectKind,
|
|
1150
|
+
subjectRef: receipt.projection.subjectRef,
|
|
1151
|
+
version: receipt.projection.version,
|
|
1152
|
+
digest: receipt.projection.digest,
|
|
1153
|
+
disposition: 'retract',
|
|
1154
|
+
situation: receipt.situation,
|
|
1155
|
+
occurredAt: receipt.execution?.occurredAt ?? receipt.objective?.occurredAt ?? this.now(),
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
const objective = receipt.objective;
|
|
1159
|
+
const verified = this.verifiedAutomationAttribution(receipt, scopeKey);
|
|
1160
|
+
if (verified?.executionKind === 'host') {
|
|
1161
|
+
throw new AssistantEvolutionError('forbidden', 'Host runbook outcomes are not behavioural learning subjects');
|
|
1162
|
+
}
|
|
1163
|
+
return this.store.applyTaskLearningProjection({
|
|
1164
|
+
scopeKey,
|
|
1165
|
+
scopeWatermark: receipt.scopeWatermark,
|
|
1166
|
+
subjectKind: receipt.projection.subjectKind,
|
|
1167
|
+
subjectRef: receipt.projection.subjectRef,
|
|
1168
|
+
version: receipt.projection.version,
|
|
1169
|
+
digest: receipt.projection.digest,
|
|
1170
|
+
disposition: 'upsert',
|
|
1171
|
+
situation: receipt.situation,
|
|
1172
|
+
outcome: objective.status === 'achieved' ? 'succeeded' : 'failed',
|
|
1173
|
+
detail: `authoritative Evaluation objective: ${objective.status}`,
|
|
1174
|
+
evidenceRef: objective.outcomeId,
|
|
1175
|
+
occurredAt: receipt.execution?.occurredAt ?? objective.occurredAt,
|
|
1176
|
+
...(verified?.attribution === undefined ? {} : verified.attribution),
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
verifiedAutomationAttribution(receipt, scopeKey) {
|
|
1180
|
+
if (receipt.projection.subjectKind !== 'automation-run') {
|
|
1181
|
+
return undefined;
|
|
1182
|
+
}
|
|
1183
|
+
const execution = receipt.execution;
|
|
1184
|
+
const runReferences = execution?.evidence.filter(entry => entry.kind === 'automation-run') ?? [];
|
|
1185
|
+
if (execution === undefined || runReferences.length !== 1
|
|
1186
|
+
|| runReferences[0].ref !== receipt.projection.subjectRef
|
|
1187
|
+
|| !receipt.situation.startsWith('automation:')
|
|
1188
|
+
|| execution.source.kind !== 'automation'
|
|
1189
|
+
|| execution.source.id !== 'assistant-automations'
|
|
1190
|
+
|| execution.evaluator.id !== 'assistant-automations'
|
|
1191
|
+
|| !/^(?:terminal|host-runbook)-v[1-9][0-9]*$/u.test(execution.evaluator.version)) {
|
|
1192
|
+
throw new AssistantEvolutionError('forbidden', 'Evaluation receipt has no exact authoritative Automation execution component');
|
|
1193
|
+
}
|
|
1194
|
+
const automationId = receipt.situation.slice('automation:'.length);
|
|
1195
|
+
if (automationId === '') {
|
|
1196
|
+
throw new AssistantEvolutionError('forbidden', 'Evaluation receipt has an invalid automation situation');
|
|
1197
|
+
}
|
|
1198
|
+
const reference = runReferences[0];
|
|
1199
|
+
const resolver = this.context.get('assistantAutomations');
|
|
1200
|
+
if (resolver === undefined || typeof resolver.resolveQualityEvidence !== 'function'
|
|
1201
|
+
|| typeof resolver.validateQualityEvidence !== 'function') {
|
|
1202
|
+
throw new AssistantEvolutionError('forbidden', 'authoritative assistantAutomations production proof is unavailable');
|
|
1203
|
+
}
|
|
1204
|
+
let proof;
|
|
1205
|
+
let valid = false;
|
|
1206
|
+
try {
|
|
1207
|
+
proof = resolver.resolveQualityEvidence({
|
|
1208
|
+
automationId,
|
|
1209
|
+
runId: reference.ref,
|
|
1210
|
+
expectedScope: { workspace: receipt.scope.workspace, preset: receipt.scope.preset },
|
|
1211
|
+
expectedSituation: receipt.situation,
|
|
1212
|
+
expectedOccurredAt: execution.occurredAt,
|
|
1213
|
+
evidenceRef: { kind: 'automation-run', ref: reference.ref },
|
|
1214
|
+
});
|
|
1215
|
+
valid = proof !== undefined && resolver.validateQualityEvidence(proof);
|
|
1216
|
+
}
|
|
1217
|
+
catch {
|
|
1218
|
+
throw new AssistantEvolutionError('forbidden', 'assistantAutomations rejected the production quality proof');
|
|
1219
|
+
}
|
|
1220
|
+
if (!valid || proof === undefined || !Object.isFrozen(proof)
|
|
1221
|
+
|| (proof.executionKind !== 'agent' && proof.executionKind !== 'host')
|
|
1222
|
+
|| proof.automationId !== automationId || proof.runId !== reference.ref
|
|
1223
|
+
|| proof.scope.workspace !== receipt.scope.workspace
|
|
1224
|
+
|| proof.scope.preset !== receipt.scope.preset
|
|
1225
|
+
|| proof.situation !== receipt.situation || proof.occurredAt !== execution.occurredAt
|
|
1226
|
+
|| (proof.status === 'timed_out' ? 'timed-out' : proof.status) !== execution.status
|
|
1227
|
+
|| proof.evidenceRef.kind !== 'automation-run' || proof.evidenceRef.ref !== reference.ref) {
|
|
1228
|
+
throw new AssistantEvolutionError('forbidden', 'assistantAutomations returned a mismatched production quality proof');
|
|
1229
|
+
}
|
|
1230
|
+
const hasRule = proof.ruleId !== undefined;
|
|
1231
|
+
const hasGuidanceVersion = proof.guidanceVersion !== undefined;
|
|
1232
|
+
// A normal Agent automation always has a session id. Before the first rule
|
|
1233
|
+
// exists there is intentionally no guidance attribution; that baseline is
|
|
1234
|
+
// what makes bootstrap learning possible.
|
|
1235
|
+
if (proof.executionKind === 'host') {
|
|
1236
|
+
if (proof.sessionId !== undefined || hasRule || hasGuidanceVersion) {
|
|
1237
|
+
throw new AssistantEvolutionError('forbidden', 'Host automation proof contains Agent attribution');
|
|
1238
|
+
}
|
|
1239
|
+
return Object.freeze({
|
|
1240
|
+
executionKind: 'host',
|
|
1241
|
+
learningSubjectRef: JSON.stringify(['automation-run', proof.runId]),
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
if (!hasRule && !hasGuidanceVersion) {
|
|
1245
|
+
return Object.freeze({
|
|
1246
|
+
executionKind: 'agent',
|
|
1247
|
+
learningSubjectRef: JSON.stringify(['automation-run', proof.runId]),
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
if (proof.sessionId === undefined || !hasRule || !hasGuidanceVersion) {
|
|
1251
|
+
throw new AssistantEvolutionError('forbidden', 'automation guidance attribution proof is incomplete');
|
|
1252
|
+
}
|
|
1253
|
+
const exposure = this.store.captureGuidanceExposure(proof.sessionId, scopeKey, receipt.situation);
|
|
1254
|
+
if (exposure === undefined || exposure.ruleId !== proof.ruleId
|
|
1255
|
+
|| exposure.guidanceVersion !== proof.guidanceVersion
|
|
1256
|
+
|| execution.occurredAt < exposure.exposedAt) {
|
|
1257
|
+
throw new AssistantEvolutionError('forbidden', 'automation guidance attribution does not match an exact Evolution exposure');
|
|
1258
|
+
}
|
|
1259
|
+
return Object.freeze({
|
|
1260
|
+
executionKind: 'agent',
|
|
1261
|
+
learningSubjectRef: JSON.stringify(['automation-run', proof.runId]),
|
|
1262
|
+
attribution: Object.freeze({ ruleId: exposure.ruleId, guidanceVersion: exposure.guidanceVersion }),
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
normalizedRollbackInput(input) {
|
|
1266
|
+
this.assertActive();
|
|
1267
|
+
if (!this.config.autonomousRollback) {
|
|
1268
|
+
throw new AssistantEvolutionError('forbidden', 'autonomous evolution rollback is disabled');
|
|
1269
|
+
}
|
|
1270
|
+
const ruleId = hostText(input.ruleId, 'ruleId', 200);
|
|
1271
|
+
if (!/^rule-[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u
|
|
1272
|
+
.test(ruleId)) {
|
|
1273
|
+
throw new AssistantEvolutionError('invalid-input', 'ruleId must be an immutable server-issued rule ID');
|
|
1274
|
+
}
|
|
1275
|
+
if (!Number.isSafeInteger(input.expectedVersion) || input.expectedVersion < 1
|
|
1276
|
+
|| input.expectedVersion > 1_000_000_000) {
|
|
1277
|
+
throw new AssistantEvolutionError('invalid-input', 'expectedVersion must be a positive safe integer');
|
|
1278
|
+
}
|
|
1279
|
+
return { ruleId, expectedVersion: input.expectedVersion };
|
|
1280
|
+
}
|
|
1281
|
+
rollbackForScope(scopeKey, input) {
|
|
1282
|
+
return this.store.rollbackRule({
|
|
1283
|
+
scopeKey,
|
|
1284
|
+
ruleId: input.ruleId,
|
|
1285
|
+
expectedVersion: input.expectedVersion,
|
|
1286
|
+
window: this.config.evaluationWindow,
|
|
1287
|
+
minSample: this.config.minSample,
|
|
1288
|
+
retireFailureRate: this.config.retireFailureRate,
|
|
1289
|
+
evidenceSampleLimit: this.config.maxEvidenceSamples,
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
authorizeHost(input, action, resourceId) {
|
|
1293
|
+
this.assertActive();
|
|
1294
|
+
const scope = exactEvolutionHostScope(input.scope);
|
|
1295
|
+
const principal = hostText(input.principal, 'principal', 500);
|
|
1296
|
+
const operationId = hostText(input.operationId, 'operationId', 500);
|
|
1297
|
+
const canonicalResourceId = hostText(resourceId, 'resourceId', 500);
|
|
1298
|
+
const scopeKey = canonicalEvolutionScope(scope.workspace, scope.preset);
|
|
1299
|
+
const idempotencyDigest = createHash('sha256').update(JSON.stringify([
|
|
1300
|
+
HOST_RECOVERY_BACKGROUND_ID,
|
|
1301
|
+
scopeKey,
|
|
1302
|
+
principal,
|
|
1303
|
+
action,
|
|
1304
|
+
canonicalResourceId,
|
|
1305
|
+
operationId,
|
|
1306
|
+
])).digest('hex');
|
|
1307
|
+
const decision = this.policy.authorize({
|
|
1308
|
+
subject: {
|
|
1309
|
+
kind: 'background',
|
|
1310
|
+
id: HOST_RECOVERY_BACKGROUND_ID,
|
|
1311
|
+
workspace: scope.workspace,
|
|
1312
|
+
principal,
|
|
1313
|
+
},
|
|
1314
|
+
action,
|
|
1315
|
+
resource: { kind: 'evolution', id: canonicalResourceId },
|
|
1316
|
+
context: { initiator: 'background' },
|
|
1317
|
+
}, { idempotencyKey: `evolution-host:${idempotencyDigest}` });
|
|
1318
|
+
if (decision.effect !== 'allow') {
|
|
1319
|
+
throw new AssistantEvolutionError('forbidden', `policy denied Host evolution ${action}`);
|
|
1320
|
+
}
|
|
1321
|
+
return scopeKey;
|
|
1322
|
+
}
|
|
1323
|
+
authorizeSupervisedGrowthAnalyst(agent, action, resourceId) {
|
|
1324
|
+
const scopeKey = this.authorize(agent, action, resourceId);
|
|
1325
|
+
const execution = agent.ctx
|
|
1326
|
+
.get('assistantAutomationExecution');
|
|
1327
|
+
if (typeof execution !== 'object' || execution === null || Array.isArray(execution)
|
|
1328
|
+
|| !Object.isFrozen(execution)
|
|
1329
|
+
|| Object.keys(execution).sort().join(',') !== 'automationId,mode,occurrenceId') {
|
|
1330
|
+
throw new AssistantEvolutionError('forbidden', 'adoption analyst requires the immutable Automation execution context');
|
|
1331
|
+
}
|
|
1332
|
+
const installed = execution;
|
|
1333
|
+
if (installed.mode !== 'production'
|
|
1334
|
+
|| installed.automationId !== SUPERVISED_GROWTH_ANALYST_AUTOMATION_ID
|
|
1335
|
+
|| typeof installed.occurrenceId !== 'string') {
|
|
1336
|
+
throw new AssistantEvolutionError('forbidden', 'adoption analyst is restricted to its exact production Automation');
|
|
1337
|
+
}
|
|
1338
|
+
const occurrenceId = hostText(installed.occurrenceId, 'occurrenceId', 500);
|
|
1339
|
+
if (occurrenceId !== installed.occurrenceId) {
|
|
1340
|
+
throw new AssistantEvolutionError('forbidden', 'analyst occurrence identity is not canonical');
|
|
1341
|
+
}
|
|
1342
|
+
return Object.freeze({ scopeKey, occurrenceId });
|
|
1343
|
+
}
|
|
481
1344
|
/** Guidance block for the current active rules, as injected into a session. */
|
|
482
1345
|
guidance(agent) {
|
|
483
1346
|
this.assertActive();
|
|
@@ -493,8 +1356,32 @@ export class AssistantEvolutionService extends Service {
|
|
|
493
1356
|
});
|
|
494
1357
|
}
|
|
495
1358
|
injectGuidance(agent) {
|
|
496
|
-
if (!this.active
|
|
1359
|
+
if (!this.active)
|
|
497
1360
|
return;
|
|
1361
|
+
const execution = agent.ctx
|
|
1362
|
+
.get('assistantAutomationExecution');
|
|
1363
|
+
// Missing execution context is a normal foreground session. If a Host marks
|
|
1364
|
+
// the Agent as automation-scoped, only production may receive guidance or
|
|
1365
|
+
// write an exposure receipt. Malformed future modes fail closed as preview.
|
|
1366
|
+
let exactAutomationSituation;
|
|
1367
|
+
if (execution !== undefined) {
|
|
1368
|
+
if (typeof execution !== 'object' || execution === null || Array.isArray(execution)
|
|
1369
|
+
|| Object.keys(execution).sort().join(',') !== 'automationId,mode,occurrenceId')
|
|
1370
|
+
return;
|
|
1371
|
+
const installed = execution;
|
|
1372
|
+
if (installed.mode !== 'production' || typeof installed.automationId !== 'string'
|
|
1373
|
+
|| typeof installed.occurrenceId !== 'string')
|
|
1374
|
+
return;
|
|
1375
|
+
let automationId;
|
|
1376
|
+
try {
|
|
1377
|
+
automationId = hostText(installed.automationId, 'automationId', 500);
|
|
1378
|
+
hostText(installed.occurrenceId, 'occurrenceId', 500);
|
|
1379
|
+
}
|
|
1380
|
+
catch {
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
exactAutomationSituation = `automation:${automationId}`;
|
|
1384
|
+
}
|
|
498
1385
|
const workspace = agent.session.header.cwd;
|
|
499
1386
|
const preset = agent.session.header.agentPreset;
|
|
500
1387
|
if (workspace === undefined || !isAbsolute(workspace) || preset === undefined || preset.trim() === '')
|
|
@@ -506,15 +1393,34 @@ export class AssistantEvolutionService extends Service {
|
|
|
506
1393
|
return;
|
|
507
1394
|
const scopeKey = canonicalEvolutionScope(workspace, preset);
|
|
508
1395
|
const sessionId = String(agent.session.id);
|
|
1396
|
+
const exposedCount = this.store.countGuidanceExposures(sessionId, scopeKey);
|
|
1397
|
+
const remaining = Math.max(0, this.config.maxInjectedRules - exposedCount);
|
|
1398
|
+
// Before Automation installs its execution context the session is
|
|
1399
|
+
// indistinguishable from foreground. With a multi-rule budget, reserve one
|
|
1400
|
+
// slot so the exact post-setup rule can still fit inside the global cap.
|
|
1401
|
+
const reserved = execution === undefined && this.config.maxInjectedRules > 1 ? 1 : 0;
|
|
1402
|
+
const injectionLimit = Math.max(0, remaining - reserved);
|
|
1403
|
+
if (injectionLimit === 0)
|
|
1404
|
+
return;
|
|
509
1405
|
const unseen = this.store.listRules(scopeKey, 'active').filter(rule => !this.store.hasGuidanceExposure(sessionId, scopeKey, rule.id, rule.generation));
|
|
510
|
-
|
|
1406
|
+
// A production Automation's exact rule must be selected before the global
|
|
1407
|
+
// max-rules slice. This is also safe when the post-setup hook follows an
|
|
1408
|
+
// earlier session-start: durable exposures remove already injected rules,
|
|
1409
|
+
// so the hook can still inject the previously omitted exact rule.
|
|
1410
|
+
const prioritized = exactAutomationSituation === undefined
|
|
1411
|
+
? unseen
|
|
1412
|
+
: [
|
|
1413
|
+
...unseen.filter(rule => rule.situation === exactAutomationSituation),
|
|
1414
|
+
...unseen.filter(rule => rule.situation !== exactAutomationSituation),
|
|
1415
|
+
];
|
|
1416
|
+
const snapshot = buildGuidanceSnapshot(prioritized, {
|
|
511
1417
|
maxBytes: this.config.maxGuidanceBytes,
|
|
512
|
-
maxRules:
|
|
1418
|
+
maxRules: injectionLimit,
|
|
1419
|
+
...(exactAutomationSituation === undefined
|
|
1420
|
+
? {} : { prioritySituation: exactAutomationSituation }),
|
|
513
1421
|
});
|
|
514
|
-
if (snapshot.text === '')
|
|
515
|
-
this.injected.add(agent);
|
|
1422
|
+
if (snapshot.text === '')
|
|
516
1423
|
return;
|
|
517
|
-
}
|
|
518
1424
|
agent.inject(createUserMessage({
|
|
519
1425
|
content: [{ type: 'text', text: snapshot.text }],
|
|
520
1426
|
source: { kind: 'plugin', plugin: 'assistant-evolution' },
|
|
@@ -528,7 +1434,6 @@ export class AssistantEvolutionService extends Service {
|
|
|
528
1434
|
guidanceVersion: rule.generation,
|
|
529
1435
|
});
|
|
530
1436
|
}
|
|
531
|
-
this.injected.add(agent);
|
|
532
1437
|
}
|
|
533
1438
|
authorize(agent, action, resourceId) {
|
|
534
1439
|
this.assertActive();
|