@mindot/will 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -22
- package/dist/channels/discord.d.ts +1 -1
- package/dist/channels/whatsapp.d.ts +1 -1
- package/dist/cli.js +10823 -10454
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +562 -226
- package/dist/index.js.map +1 -1
- package/dist/mcp/effectors.d.ts +1 -1
- package/dist/{will-DAW0l-lY.d.ts → will-cS6k4uiJ.d.ts} +470 -84
- package/package.json +1 -1
- package/src/cognition/agency/engines/action.selector.ts +2 -1
- package/src/cognition/agency/engines/reafference.engine.ts +12 -2
- package/src/cognition/agency/reconcile.learning.ts +16 -2
- package/src/cognition/agency/schemas/repertoire.ts +12 -5
- package/src/cognition/config.mirror.entities.ts +1 -1
- package/src/cognition/faculties/executive.engine/engine.ts +136 -58
- package/src/cognition/faculties/executive.engine/facet.ts +10 -2
- package/src/cognition/faculties/executive.engine/prompt.factory.ts +2 -1
- package/src/cognition/index.ts +4 -0
- package/src/cognition/memory/vector.embedder.ts +9 -5
- package/src/cognition/utilities/token.tracker.ts +191 -96
- package/src/host/boot.ts +78 -22
- package/src/index.ts +35 -0
- package/src/llm/index.ts +397 -96
- package/src/llm/routing.ts +198 -0
- package/src/llm/summarizer.ts +5 -1
- package/src/runners/thin-shim.runner.ts +18 -6
- package/src/sdk/will.ts +82 -16
- package/src/stem/guards/identity.coherence.ts +17 -6
- package/src/stem/index.ts +3 -3
- package/src/stem/mind.ts +155 -24
- package/src/stem/policy/arbiter.ts +49 -14
- package/src/stem/policy/rule.table.ts +2 -2
- package/src/stem/tracts/effector.controller.ts +56 -9
package/src/stem/mind.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { validateWillIdentity } from '#stem/guards/identity.guard'
|
|
|
25
25
|
import { OutboxWriter } from '#stem/tracts/outbox.writer'
|
|
26
26
|
import { ExecutiveSummarizer } from '#llm/summarizer'
|
|
27
27
|
import type { LLMProvider } from '#llm/index'
|
|
28
|
+
import { TableRouter, chainRouters, type ModelRouter, type RoutingRule } from '#llm/routing'
|
|
28
29
|
import { resolveProfile } from '#profiles/index'
|
|
29
30
|
import { DefaultVectorMemoryAdapter } from '#memory/vector.adapter'
|
|
30
31
|
import { OpenAICompatibleEmbedder, MockEmbedder } from '#memory/vector.embedder'
|
|
@@ -46,6 +47,7 @@ import { effectorName, type EffectorDeclaration } from '#agency/types'
|
|
|
46
47
|
|
|
47
48
|
import {
|
|
48
49
|
TokenTracker,
|
|
50
|
+
type PriceTable,
|
|
49
51
|
EnergyRegulator,
|
|
50
52
|
SleepPressureRegulator,
|
|
51
53
|
CircadianOscillator,
|
|
@@ -137,12 +139,54 @@ export interface WillModelConfig {
|
|
|
137
139
|
* `apiKey` is held in memory only — it is never mirrored into state entities,
|
|
138
140
|
* session logs, or the PMA.
|
|
139
141
|
*/
|
|
142
|
+
export interface WillProviderConfig {
|
|
143
|
+
/** Credential for this provider. Held in memory only — never state/logs/PMA. */
|
|
144
|
+
apiKey?: string
|
|
145
|
+
/** Base URL override — self-hosted or OpenAI-compatible endpoints. */
|
|
146
|
+
baseUrl?: string
|
|
147
|
+
/**
|
|
148
|
+
* USD per 1M tokens, keyed by model id. Host-owned on purpose: prices change
|
|
149
|
+
* on a vendor's schedule, differ per account, and are ~0 self-hosted, so they
|
|
150
|
+
* cannot be tracked from inside an npm release. These win over the engine's
|
|
151
|
+
* built-in fallback table.
|
|
152
|
+
*
|
|
153
|
+
* Cost is telemetry only — it never enters simulation state — so changing a
|
|
154
|
+
* price can never change what a mind does or break a replay.
|
|
155
|
+
*/
|
|
156
|
+
prices?: PriceTable
|
|
157
|
+
}
|
|
158
|
+
|
|
140
159
|
export interface WillLLMConfig {
|
|
141
160
|
provider?: LLMProvider
|
|
142
161
|
apiKey?: string
|
|
143
162
|
baseUrl?: string
|
|
144
163
|
maxOutputTokens?: number
|
|
145
164
|
timeoutMs?: number
|
|
165
|
+
/**
|
|
166
|
+
* Everything the host knows about each provider — credential, endpoint, and
|
|
167
|
+
* prices — declared once per provider. The single-provider fields above stay
|
|
168
|
+
* the simple path; this map is for hosts reaching more than one.
|
|
169
|
+
*/
|
|
170
|
+
providers?: Partial<Record<LLMProvider, WillProviderConfig>>
|
|
171
|
+
/**
|
|
172
|
+
* Per-call model selection. Omitted (or NULL_ROUTER) means every call uses
|
|
173
|
+
* `model` above, exactly as before the seam existed.
|
|
174
|
+
*
|
|
175
|
+
* The router sees only the call's attribution — what kind of work it is and
|
|
176
|
+
* how much the moment demands — never who is paying or what anything costs.
|
|
177
|
+
* Routes name providers from the `providers` map above; a route to a provider
|
|
178
|
+
* with no credential falls back to the default rather than failing the call.
|
|
179
|
+
*/
|
|
180
|
+
router?: ModelRouter | null
|
|
181
|
+
/**
|
|
182
|
+
* Concrete LLM model id(s) for this Will — a single id for every role, or a
|
|
183
|
+
* per-role map. An explicit WILL_LLM_MODEL env pins the thinking roles
|
|
184
|
+
* (operator single-model deployments); unset roles fall back to `executive`,
|
|
185
|
+
* then the LLMDirector's built-in default. Product-level labels (pricing
|
|
186
|
+
* tiers, model families) live host-side and resolve to concrete ids BEFORE
|
|
187
|
+
* reaching the engine.
|
|
188
|
+
*/
|
|
189
|
+
model?: string | WillModelConfig
|
|
146
190
|
}
|
|
147
191
|
|
|
148
192
|
/** Executive-side resolved roles (embedding is threaded separately). */
|
|
@@ -159,6 +203,91 @@ export interface ExecutiveModelRoles {
|
|
|
159
203
|
* single-model deployment, full stop. Embedding is untouched by the pin
|
|
160
204
|
* (different model family; the embedding stack has its own env).
|
|
161
205
|
*/
|
|
206
|
+
/**
|
|
207
|
+
* Flatten the per-provider `prices` maps into one model→price table.
|
|
208
|
+
*
|
|
209
|
+
* Providers declare their own models, so collisions are not expected; if two
|
|
210
|
+
* do claim the same id, the first declared wins rather than silently taking
|
|
211
|
+
* whichever iterated last.
|
|
212
|
+
*/
|
|
213
|
+
export function mergeProviderPrices(
|
|
214
|
+
providers?: Partial<Record<LLMProvider, WillProviderConfig>>,
|
|
215
|
+
): PriceTable | undefined {
|
|
216
|
+
if( !providers ) return undefined
|
|
217
|
+
const out: PriceTable = {}
|
|
218
|
+
for( const entry of Object.values( providers ) ){
|
|
219
|
+
for( const [ model, price ] of Object.entries( entry?.prices ?? {} ) ){
|
|
220
|
+
if( !( model in out ) ) out[ model ] = price
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return Object.keys( out ).length > 0 ? out : undefined
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Narrow the per-provider map to just what the LLM transport needs — the
|
|
228
|
+
* prices ride to the TokenTracker instead, so a credential map never carries
|
|
229
|
+
* pricing into the call path.
|
|
230
|
+
*/
|
|
231
|
+
export function providerCredentials(
|
|
232
|
+
providers: Partial<Record<LLMProvider, WillProviderConfig>>,
|
|
233
|
+
): Partial<Record<LLMProvider, { apiKey: string; baseUrl?: string }>> {
|
|
234
|
+
const out: Partial<Record<LLMProvider, { apiKey: string; baseUrl?: string }>> = {}
|
|
235
|
+
for( const [ name, entry ] of Object.entries( providers ) ){
|
|
236
|
+
if( !entry?.apiKey ) continue // no key ⇒ unusable; the router falls back
|
|
237
|
+
out[ name as LLMProvider ] = {
|
|
238
|
+
apiKey: entry.apiKey,
|
|
239
|
+
...( entry.baseUrl ? { baseUrl: entry.baseUrl } : {} ),
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return out
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Compile the per-role model map into routing rules — MODEL_ROUTING W7.
|
|
247
|
+
*
|
|
248
|
+
* The role map and the router were two mechanisms answering one question
|
|
249
|
+
* ("which model serves this call?"). The map was implemented by giving each
|
|
250
|
+
* role its own cached `LLMDirector`, which meant role selection happened at
|
|
251
|
+
* *facet-spawn* time while routing happened at *call* time — two answers, one
|
|
252
|
+
* question, and a facet could be pinned to a model its work had since stopped
|
|
253
|
+
* matching. The map is now sugar: it desugars to rules and there is one
|
|
254
|
+
* mechanism left.
|
|
255
|
+
*
|
|
256
|
+
* The mapping is exact, because every role's call sites already tag themselves
|
|
257
|
+
* with the matching axis:
|
|
258
|
+
*
|
|
259
|
+
* | role | rule | call sites |
|
|
260
|
+
* | :--- | :--- | :--- |
|
|
261
|
+
* | `summarizer` | `category: 'summarizer'` | the rolling summariser |
|
|
262
|
+
* | `deliberation` | `function: 'deliberation'` | the deliberation facet + its propose pass |
|
|
263
|
+
* | `conversation` | `function: 'conversation'` and `'outreach'` | the audition facets |
|
|
264
|
+
* | `executive` | — it *is* the default | master, and every unlabelled facet |
|
|
265
|
+
*
|
|
266
|
+
* Roles equal to `executive` emit no rule: a Will on a single model keeps an
|
|
267
|
+
* empty chain and stays byte-identical to one built before this seam existed.
|
|
268
|
+
*
|
|
269
|
+
* Routes carry no provider — a role has never had one. They inherit the Will's
|
|
270
|
+
* default provider, which is exactly what the per-role directors did.
|
|
271
|
+
*/
|
|
272
|
+
export function compileRoleRouter( roles: ExecutiveModelRoles ): ModelRouter | null {
|
|
273
|
+
const { executive, summarizer, deliberation, conversation } = roles
|
|
274
|
+
const distinct = ( model: string | null ): model is string => !!model && model !== executive
|
|
275
|
+
|
|
276
|
+
const rules: RoutingRule[] = []
|
|
277
|
+
if( distinct( summarizer ) )
|
|
278
|
+
rules.push( { category: 'summarizer', route: { model: summarizer } } )
|
|
279
|
+
if( distinct( deliberation ) )
|
|
280
|
+
rules.push( { function: 'deliberation', route: { model: deliberation } } )
|
|
281
|
+
if( distinct( conversation ) ){
|
|
282
|
+
// Outreach is the same voice speaking first — it has always shared the
|
|
283
|
+
// conversation model, and splitting it is the host's call, via a router.
|
|
284
|
+
rules.push( { function: 'conversation', route: { model: conversation } } )
|
|
285
|
+
rules.push( { function: 'outreach', route: { model: conversation } } )
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return rules.length > 0 ? new TableRouter( rules, 'role-map') : null
|
|
289
|
+
}
|
|
290
|
+
|
|
162
291
|
export function resolveModelRoles( model?: string | WillModelConfig ): ExecutiveModelRoles & { embedding: string | null } {
|
|
163
292
|
const map = typeof model === 'string' ? { executive: model } : ( model ?? {} )
|
|
164
293
|
const pin = process.env.WILL_LLM_MODEL
|
|
@@ -223,16 +352,6 @@ export interface WillConfig {
|
|
|
223
352
|
/** Anatomy — 'mind' (default) or the no-LLM 'reflex' shell. */
|
|
224
353
|
anatomy?: Anatomy
|
|
225
354
|
|
|
226
|
-
/**
|
|
227
|
-
* Concrete LLM model id(s) for this Will — a single id for every role, or a
|
|
228
|
-
* per-role map. An explicit WILL_LLM_MODEL env pins the thinking roles
|
|
229
|
-
* (operator single-model deployments); unset roles fall back to `executive`,
|
|
230
|
-
* then the LLMDirector's built-in default. Product-level labels (pricing
|
|
231
|
-
* tiers, model families) live host-side and resolve to concrete ids BEFORE
|
|
232
|
-
* reaching the engine.
|
|
233
|
-
*/
|
|
234
|
-
model?: string | WillModelConfig
|
|
235
|
-
|
|
236
355
|
/**
|
|
237
356
|
* Per-Will LLM transport overrides (provider, BYO apiKey, baseUrl, output
|
|
238
357
|
* cap, timeout). Unset fields fall back to WILL_LLM_* envs. The apiKey never
|
|
@@ -359,17 +478,17 @@ export interface MindAssembly {
|
|
|
359
478
|
outbox: OutboxMessage[]
|
|
360
479
|
}
|
|
361
480
|
|
|
362
|
-
// ──
|
|
481
|
+
// ── Named executive cadences ──────────────────────────────────
|
|
363
482
|
|
|
364
483
|
/**
|
|
365
484
|
* Named executive cadences — ticks between LLM calls. Lower = reasons more often
|
|
366
|
-
* (more responsive,
|
|
367
|
-
* (clamped to `minExecutiveInterval`).
|
|
485
|
+
* (more responsive, more tokens per Will-hour). Callers pick via
|
|
486
|
+
* `config.executiveInterval` (clamped to `minExecutiveInterval`).
|
|
368
487
|
*/
|
|
369
488
|
export const EXECUTIVE_CADENCE = {
|
|
370
|
-
responsive: 30, //
|
|
371
|
-
balanced: 60, //
|
|
372
|
-
economy: 90, //
|
|
489
|
+
responsive: 30, // most attentive, highest spend — opt in via executiveInterval
|
|
490
|
+
balanced: 60, // default
|
|
491
|
+
economy: 90, // least attentive, lowest spend
|
|
373
492
|
} as const
|
|
374
493
|
|
|
375
494
|
|
|
@@ -666,6 +785,10 @@ function _constructCognition(
|
|
|
666
785
|
// (attachTokenTracker), so usage/cost never conflates across Wills and
|
|
667
786
|
// parallel runs stay isolated.
|
|
668
787
|
const tokenTracker = new TokenTracker({
|
|
788
|
+
// Host prices, flattened from the per-provider map. Cost is telemetry only
|
|
789
|
+
// (it never enters state), so this can differ run to run without touching
|
|
790
|
+
// determinism.
|
|
791
|
+
prices: mergeProviderPrices( config.llm?.providers ),
|
|
669
792
|
emitCostEvents: true,
|
|
670
793
|
costWarningThresholdUsd: 0.02,
|
|
671
794
|
willId,
|
|
@@ -708,7 +831,7 @@ function _constructCognition(
|
|
|
708
831
|
|
|
709
832
|
// Per-Will, per-ROLE models (env WILL_LLM_MODEL pins all thinking roles —
|
|
710
833
|
// operator single-model deployments). No tier vocabulary inside the engine.
|
|
711
|
-
const modelRoles = resolveModelRoles( config.model )
|
|
834
|
+
const modelRoles = resolveModelRoles( config.llm?.model )
|
|
712
835
|
|
|
713
836
|
const { embedder, vectorMemory } = _resolveVectorMemory( willId, randomSeed, config.vectorMemoryAdapter, config.disableVectorMemory, tokenTracker, config.testMode, modelRoles.embedding ?? undefined )
|
|
714
837
|
const episodicConsolidator = new EpisodicConsolidator( vectorMemory ? { vectorMemory, ...(embedder ? { embedder } : {}) } : {} )
|
|
@@ -746,13 +869,21 @@ function _constructCognition(
|
|
|
746
869
|
const executiveEngine = new ExecutiveEngine({ executiveInterval, cooldownTicks: 5 })
|
|
747
870
|
|
|
748
871
|
executiveEngine.willId = willId
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
872
|
+
// Narrow the host's per-provider map to credentials for the call path; the
|
|
873
|
+
// prices from that same map went to the TokenTracker above.
|
|
874
|
+
// W7 — the per-role model map compiles into routing rules and joins the
|
|
875
|
+
// host's own router in one chain, so the engine receives a single answer to
|
|
876
|
+
// "which model serves this call?" instead of two. The host's router leads:
|
|
877
|
+
// that is the precedence the two mechanisms already had.
|
|
878
|
+
const roleRouter = compileRoleRouter( modelRoles )
|
|
879
|
+
executiveEngine.llm = config.llm
|
|
880
|
+
? {
|
|
881
|
+
...config.llm,
|
|
882
|
+
...( config.llm.providers ? { credentials: providerCredentials( config.llm.providers ) } : {} ),
|
|
883
|
+
router: chainRouters( config.llm.router, roleRouter ),
|
|
884
|
+
}
|
|
885
|
+
: ( roleRouter ? { router: roleRouter } : null )
|
|
886
|
+
executiveEngine.modelId = modelRoles.executive
|
|
756
887
|
if( config.testMode ) executiveEngine.setTestMode( true )
|
|
757
888
|
executiveEngine.attachWorkingMemory( workingMemory )
|
|
758
889
|
executiveEngine.attachGoalManager( goalManager )
|
|
@@ -33,15 +33,28 @@
|
|
|
33
33
|
export type PolicyDecision = 'allow' | 'deny' | 'escalate'
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
36
|
+
* WHY this denial is final — the distinction that makes a refusal learnable
|
|
37
|
+
* rather than a wall to re-probe forever. Each value selects a different
|
|
38
|
+
* cognitive fate; they are not degrees of one severity.
|
|
38
39
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
40
|
+
* • 'class' — the ACTION ITSELF is never permitted. Suppress the
|
|
41
|
+
* affordance hard, erase any learned envelope, and let go of
|
|
42
|
+
* a commitment currently deliberating toward it.
|
|
43
|
+
* • 'parameter' — the action is fine; THESE ARGUMENTS were not (bound
|
|
44
|
+
* exceeded, wrong target). Narrow the envelope the Will
|
|
45
|
+
* reaches for; the ability stays.
|
|
46
|
+
* • 'context' — the refusal was NOT ABOUT THE ACTION at all (tainted
|
|
47
|
+
* context, unavailable dependency). Touch nothing: no
|
|
48
|
+
* availability delta, no envelope, no competence.
|
|
49
|
+
*
|
|
50
|
+
* POLICY_REAFFERENCE P5 widened this from 'class' | 'instance' after the HELM
|
|
51
|
+
* joint RFC ("Denials That Teach") identified that an instance-scoped refusal
|
|
52
|
+
* splits in two, and that the two halves demand opposite responses. These are
|
|
53
|
+
* OUR names for the distinctions, deliberately not HELM's wire spellings — see
|
|
54
|
+
* the naming-boundary note in .TODO/POLICY_REAFFERENCE.md. A provider adapter
|
|
55
|
+
* translates; this interface stays vendor-neutral.
|
|
43
56
|
*/
|
|
44
|
-
export type DenialFinality = 'class' | '
|
|
57
|
+
export type DenialFinality = 'class' | 'parameter' | 'context'
|
|
45
58
|
|
|
46
59
|
/**
|
|
47
60
|
* The nearest allowed envelope — what WOULD have been permitted. Structured so
|
|
@@ -64,9 +77,8 @@ export interface Verdict {
|
|
|
64
77
|
decision: PolicyDecision
|
|
65
78
|
/** Stable machine-readable code, e.g. 'TARGET_NOT_ALLOWED'. Never prose. */
|
|
66
79
|
reasonCode?: string
|
|
67
|
-
/** Meaningful on 'deny' only. Absent ⇒ treat as '
|
|
68
|
-
*
|
|
69
|
-
* delete an ability from the Will's reach). */
|
|
80
|
+
/** Meaningful on 'deny' only. Absent ⇒ treat as 'parameter' — see
|
|
81
|
+
* `asFinality` for why that, and not 'context', is the safe default. */
|
|
70
82
|
finality?: DenialFinality
|
|
71
83
|
counterfactual?: PolicyCounterfactual
|
|
72
84
|
/** Free-text for logs and host UX. NEVER parsed by cognition. */
|
|
@@ -127,10 +139,33 @@ export function isNullArbiter( arbiter: PolicyArbiter | null | undefined ): bool
|
|
|
127
139
|
return !arbiter || arbiter === NULL_ARBITER
|
|
128
140
|
}
|
|
129
141
|
|
|
142
|
+
/** Normalize a denial's finality. Absent ⇒ 'parameter' (see `asFinality`). */
|
|
143
|
+
export function finalityOf( verdict: Verdict ): DenialFinality {
|
|
144
|
+
return asFinality( verdict.finality )
|
|
145
|
+
}
|
|
146
|
+
|
|
130
147
|
/**
|
|
131
|
-
* Normalize
|
|
132
|
-
*
|
|
148
|
+
* Normalize an UNTYPED finality — one read back off entity metadata, a verdict
|
|
149
|
+
* tape, or a host ack, where the type system cannot help.
|
|
150
|
+
*
|
|
151
|
+
* Every such read goes through here rather than comparing string literals at
|
|
152
|
+
* the call site: the enum is a moving target (it widened once for the HELM
|
|
153
|
+
* joint RFC and may again), and a hand-written `=== 'class'` scattered through
|
|
154
|
+
* cognition is a mis-route that still typechecks.
|
|
155
|
+
*
|
|
156
|
+
* THE DEFAULT IS 'parameter', AND NEVER 'context'. The intuitive reading — an
|
|
157
|
+
* unlabelled denial should do the LEAST, so default to the fate that touches
|
|
158
|
+
* nothing — is wrong, and dangerously so. 'context' means *the mind learns
|
|
159
|
+
* nothing from this denial*, so it re-probes the same wall forever: the exact
|
|
160
|
+
* failure this whole epoch exists to fix, silently re-enabled for any provider
|
|
161
|
+
* that doesn't tag its refusals. 'context' is a claim only a provider that
|
|
162
|
+
* actually knows can make — it must be ASSERTED, never defaulted to.
|
|
163
|
+
* 'parameter' narrows without deleting, which is the honest conservative
|
|
164
|
+
* reading and preserves pre-P5 behaviour for an untagged denial exactly.
|
|
165
|
+
*
|
|
166
|
+
* Legacy 'instance' (the pre-P5 spelling) normalizes to 'parameter' by the same
|
|
167
|
+
* fallback, so tapes and snapshots written before the split replay unchanged.
|
|
133
168
|
*/
|
|
134
|
-
export function
|
|
135
|
-
return
|
|
169
|
+
export function asFinality( raw: unknown ): DenialFinality {
|
|
170
|
+
return raw === 'class' ? 'class' : raw === 'context' ? 'context' : 'parameter'
|
|
136
171
|
}
|
|
@@ -37,7 +37,7 @@ export interface ParamConstraint {
|
|
|
37
37
|
*
|
|
38
38
|
* `require` is meaningful with `decision: 'allow'` only: the scope matched, and
|
|
39
39
|
* these constraints must hold for the allow to stand. A violation flips the
|
|
40
|
-
* verdict to deny — carrying the counterfactual — with finality '
|
|
40
|
+
* verdict to deny — carrying the counterfactual — with finality 'parameter',
|
|
41
41
|
* because the ability itself was permitted and only these arguments were not.
|
|
42
42
|
*
|
|
43
43
|
* A rule with `decision: 'deny'` and no `require` is a flat class-level ban;
|
|
@@ -94,7 +94,7 @@ export class RuleTableArbiter implements PolicyArbiter {
|
|
|
94
94
|
return {
|
|
95
95
|
decision: 'deny',
|
|
96
96
|
reasonCode: rule.reasonCode ?? violation.reasonCode,
|
|
97
|
-
finality: rule.finality ?? '
|
|
97
|
+
finality: rule.finality ?? 'parameter',
|
|
98
98
|
counterfactual: violation.counterfactual,
|
|
99
99
|
}
|
|
100
100
|
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
import { logger } from '#core/logger'
|
|
25
25
|
import { reconcileInvocation } from '#agency/reconcile.learning'
|
|
26
26
|
import { NULL_ARBITER, isNullArbiter } from '#stem/policy/arbiter'
|
|
27
|
-
import type { PolicyArbiter, PolicyInvocation, Verdict } from '#stem/policy/arbiter'
|
|
27
|
+
import type { PolicyArbiter, PolicyInvocation, Verdict, DenialFinality, PolicyCounterfactual } from '#stem/policy/arbiter'
|
|
28
|
+
import { finalityOf, asFinality } from '#stem/policy/arbiter'
|
|
28
29
|
import {
|
|
29
30
|
getVerdictRecorder, getVerdictSource, type PolicyVerdictRecord,
|
|
30
31
|
} from '#stem/policy/verdict.recorder'
|
|
@@ -36,9 +37,38 @@ interface PendingRefusal {
|
|
|
36
37
|
intentId: string
|
|
37
38
|
schema: string
|
|
38
39
|
reasonCode: string
|
|
39
|
-
finality:
|
|
40
|
+
finality: DenialFinality
|
|
41
|
+
/** ENVELOPE_NARROWING P0 — what WOULD have been allowed, carried through to
|
|
42
|
+
* the outcome the mind learns from. Absent on refusals that have no bound to
|
|
43
|
+
* report (a flat ban, a fault, an unanswered escalation). */
|
|
44
|
+
counterfactual?: PolicyCounterfactual
|
|
40
45
|
}
|
|
41
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The verdict a fault produces (POLICY_REAFFERENCE P5, conformance S9).
|
|
49
|
+
*
|
|
50
|
+
* An arbiter that throws or rejects has always failed CLOSED — the effect is
|
|
51
|
+
* withheld — but it used to withhold *silently*, queueing no refusal. The held
|
|
52
|
+
* intent then expired at the executor's AWAIT_TIMEOUT and reconciled as a plain
|
|
53
|
+
* failure, landing on COMPETENCE: a PDP outage taught the mind it was unskilled
|
|
54
|
+
* at something it is perfectly capable of. So a fault now yields a real verdict:
|
|
55
|
+
*
|
|
56
|
+
* • 'deny' — still fail-closed, unchanged. The effect never reaches the world.
|
|
57
|
+
* • 'context' — but it teaches NOTHING. The arbiter being unreachable is not a
|
|
58
|
+
* fact about the ability, so nothing about the ability may move.
|
|
59
|
+
*
|
|
60
|
+
* It goes through `_recordAndApply` rather than straight to the refusal queue so
|
|
61
|
+
* the fault lands on the VERDICT TAPE too. That closes a replay hole: an
|
|
62
|
+
* unrecorded fault left the source with nothing to re-feed, and a source miss
|
|
63
|
+
* reproduces a buffered ALLOW — so a live run that withheld the effect would
|
|
64
|
+
* have replayed as one that dispatched it.
|
|
65
|
+
*/
|
|
66
|
+
const ARBITER_FAULT_VERDICT: Readonly<Verdict> = Object.freeze({
|
|
67
|
+
decision: 'deny' as const,
|
|
68
|
+
reasonCode: 'ARBITER_UNAVAILABLE',
|
|
69
|
+
finality: 'context' as const,
|
|
70
|
+
})
|
|
71
|
+
|
|
42
72
|
/** How long an escalated intent is held awaiting a resolution before it degrades
|
|
43
73
|
* to a refusal — 2× the host-ack timeout, so a human has real time to answer. */
|
|
44
74
|
const ESCALATION_TTL_TICKS = 30
|
|
@@ -136,6 +166,7 @@ export class effectorController {
|
|
|
136
166
|
try { verdict = this._arbiter.evaluate( invocation ) }
|
|
137
167
|
catch( err ){
|
|
138
168
|
logger.error(`[policy] arbiter "${this._arbiter.name}" threw for "${invocation.schema}" — failing closed:`, err )
|
|
169
|
+
this._recordAndApply( instance, payload, invocation, ARBITER_FAULT_VERDICT )
|
|
139
170
|
return
|
|
140
171
|
}
|
|
141
172
|
|
|
@@ -145,7 +176,10 @@ export class effectorController {
|
|
|
145
176
|
// queue drains each tick, so a verdict landing a few ticks late still lands.
|
|
146
177
|
void verdict.then(
|
|
147
178
|
v => this._recordAndApply( instance, payload, invocation, v ),
|
|
148
|
-
err =>
|
|
179
|
+
err => {
|
|
180
|
+
logger.error(`[policy] arbiter "${this._arbiter.name}" rejected for "${invocation.schema}" — failing closed:`, err )
|
|
181
|
+
this._recordAndApply( instance, payload, invocation, ARBITER_FAULT_VERDICT )
|
|
182
|
+
},
|
|
149
183
|
)
|
|
150
184
|
return
|
|
151
185
|
}
|
|
@@ -218,7 +252,8 @@ export class effectorController {
|
|
|
218
252
|
intentId: invocation.intentId,
|
|
219
253
|
schema: invocation.schema,
|
|
220
254
|
reasonCode: verdict.reasonCode ?? 'POLICY_DENIED',
|
|
221
|
-
finality: verdict
|
|
255
|
+
finality: finalityOf( verdict ),
|
|
256
|
+
...( verdict.counterfactual ? { counterfactual: verdict.counterfactual } : {} ),
|
|
222
257
|
})
|
|
223
258
|
this._pendingRefusals.set( instance.config.id, queue )
|
|
224
259
|
return
|
|
@@ -272,7 +307,8 @@ export class effectorController {
|
|
|
272
307
|
this.confirmExecution( instance, refusal.intentId, {
|
|
273
308
|
success: false,
|
|
274
309
|
refused: true,
|
|
275
|
-
finality: refusal.finality
|
|
310
|
+
finality: refusal.finality,
|
|
311
|
+
...( refusal.counterfactual ? { counterfactual: refusal.counterfactual } : {} ),
|
|
276
312
|
description: `refused by policy: ${refusal.reasonCode} (${refusal.finality})`,
|
|
277
313
|
} )
|
|
278
314
|
}
|
|
@@ -317,7 +353,16 @@ export class effectorController {
|
|
|
317
353
|
}
|
|
318
354
|
}
|
|
319
355
|
|
|
320
|
-
/**
|
|
356
|
+
/**
|
|
357
|
+
* Degrade escalations no one answered in time into light refusals (P4).
|
|
358
|
+
*
|
|
359
|
+
* Finality 'parameter' is chosen for its BEHAVIOUR, not its name: silence is
|
|
360
|
+
* not literally an argument problem, but the light-dent-with-recovery it
|
|
361
|
+
* produces is exactly right — a Will whose asks go unanswered should ask
|
|
362
|
+
* progressively less, and should resume asking if someone starts answering.
|
|
363
|
+
* 'class' would be a lie (nobody said never) and 'context' would teach
|
|
364
|
+
* nothing, leaving the mind to escalate forever into an empty room.
|
|
365
|
+
*/
|
|
321
366
|
private _expireEscalations( instance: WillInstance, tick: number ): void {
|
|
322
367
|
const active = this._activeEscalations.get( instance.config.id )
|
|
323
368
|
if( !active || active.size === 0 ) return
|
|
@@ -325,14 +370,14 @@ export class effectorController {
|
|
|
325
370
|
if( tick < esc.expiresAt ) continue
|
|
326
371
|
active.delete( intentId )
|
|
327
372
|
this._clearEscalated( instance, intentId )
|
|
328
|
-
this._queueRefusal( instance, esc.intentId, esc.schema, 'ESCALATION_EXPIRED', '
|
|
373
|
+
this._queueRefusal( instance, esc.intentId, esc.schema, 'ESCALATION_EXPIRED', 'parameter')
|
|
329
374
|
logger.info(`[policy] escalation EXPIRED → refusing "${esc.schema}" intent "${intentId}"`)
|
|
330
375
|
}
|
|
331
376
|
}
|
|
332
377
|
|
|
333
378
|
/** Push a refusal onto the queue drained by _applyRefusals this same tick. */
|
|
334
379
|
private _queueRefusal(
|
|
335
|
-
instance: WillInstance, intentId: string, schema: string, reasonCode: string, finality:
|
|
380
|
+
instance: WillInstance, intentId: string, schema: string, reasonCode: string, finality: DenialFinality,
|
|
336
381
|
): void {
|
|
337
382
|
const queue = this._pendingRefusals.get( instance.config.id ) ?? []
|
|
338
383
|
queue.push({ intentId, schema, reasonCode, finality })
|
|
@@ -419,7 +464,9 @@ export class effectorController {
|
|
|
419
464
|
/** POLICY_REAFFERENCE P2 — set when the ack is a policy refusal, so the
|
|
420
465
|
* ReafferenceEngine routes it to availability rather than competence. */
|
|
421
466
|
refused?: boolean
|
|
422
|
-
finality?:
|
|
467
|
+
finality?: DenialFinality
|
|
468
|
+
/** ENVELOPE_NARROWING P0 — the bound that was exceeded, if the arbiter said. */
|
|
469
|
+
counterfactual?: PolicyCounterfactual
|
|
423
470
|
},
|
|
424
471
|
): void {
|
|
425
472
|
const tick = instance.tickCount
|