@animalabs/agent-framework 0.22.0 → 0.22.1

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.
Files changed (147) hide show
  1. package/README.md +11 -4
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +3 -1
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/api/server.d.ts.map +1 -1
  6. package/dist/src/api/server.js +41 -4
  7. package/dist/src/api/server.js.map +1 -1
  8. package/dist/src/framework.d.ts +319 -20
  9. package/dist/src/framework.d.ts.map +1 -1
  10. package/dist/src/framework.js +1500 -182
  11. package/dist/src/framework.js.map +1 -1
  12. package/dist/src/index.d.ts +2 -2
  13. package/dist/src/index.d.ts.map +1 -1
  14. package/dist/src/index.js.map +1 -1
  15. package/dist/src/mcpl/index.d.ts +2 -0
  16. package/dist/src/mcpl/index.d.ts.map +1 -1
  17. package/dist/src/mcpl/index.js.map +1 -1
  18. package/dist/src/mcpl/reference-fetcher.d.ts +2 -1
  19. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  20. package/dist/src/mcpl/reference-fetcher.js +20 -9
  21. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  22. package/dist/src/mcpl/references.d.ts +29 -6
  23. package/dist/src/mcpl/references.d.ts.map +1 -1
  24. package/dist/src/mcpl/references.js +57 -16
  25. package/dist/src/mcpl/references.js.map +1 -1
  26. package/dist/src/mcpl/server-connection.d.ts +28 -1
  27. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  28. package/dist/src/mcpl/server-connection.js +32 -4
  29. package/dist/src/mcpl/server-connection.js.map +1 -1
  30. package/dist/src/module-registry.d.ts +9 -3
  31. package/dist/src/module-registry.d.ts.map +1 -1
  32. package/dist/src/module-registry.js +3 -2
  33. package/dist/src/module-registry.js.map +1 -1
  34. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  35. package/dist/src/modules/workspace/index.js +46 -4
  36. package/dist/src/modules/workspace/index.js.map +1 -1
  37. package/dist/src/modules/workspace/observe.d.ts +13 -3
  38. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  39. package/dist/src/modules/workspace/observe.js +24 -4
  40. package/dist/src/modules/workspace/observe.js.map +1 -1
  41. package/dist/src/modules/workspace/reconcile.d.ts +14 -0
  42. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  43. package/dist/src/modules/workspace/reconcile.js +10 -2
  44. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  45. package/dist/src/operator-log.d.ts +41 -0
  46. package/dist/src/operator-log.d.ts.map +1 -1
  47. package/dist/src/operator-log.js.map +1 -1
  48. package/dist/src/provider-waits.d.ts +216 -0
  49. package/dist/src/provider-waits.d.ts.map +1 -0
  50. package/dist/src/provider-waits.js +482 -0
  51. package/dist/src/provider-waits.js.map +1 -0
  52. package/dist/src/refusal-reactions.d.ts +14 -12
  53. package/dist/src/refusal-reactions.d.ts.map +1 -1
  54. package/dist/src/refusal-reactions.js +14 -12
  55. package/dist/src/refusal-reactions.js.map +1 -1
  56. package/dist/src/tool-result-guard.d.ts +30 -13
  57. package/dist/src/tool-result-guard.d.ts.map +1 -1
  58. package/dist/src/tool-result-guard.js +71 -25
  59. package/dist/src/tool-result-guard.js.map +1 -1
  60. package/dist/src/types/events.d.ts +12 -0
  61. package/dist/src/types/events.d.ts.map +1 -1
  62. package/dist/src/types/module.d.ts +12 -4
  63. package/dist/src/types/module.d.ts.map +1 -1
  64. package/dist/src/types/trace.d.ts +14 -3
  65. package/dist/src/types/trace.d.ts.map +1 -1
  66. package/dist/test/discord-awareness-outbox.test.js +3 -0
  67. package/dist/test/discord-awareness-outbox.test.js.map +1 -1
  68. package/dist/test/explicit-prose-routing.test.js +0 -2
  69. package/dist/test/explicit-prose-routing.test.js.map +1 -1
  70. package/dist/test/framework.test.js +160 -1
  71. package/dist/test/framework.test.js.map +1 -1
  72. package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -1
  73. package/dist/test/helpers/coalescing-fixture.js +3 -2
  74. package/dist/test/helpers/coalescing-fixture.js.map +1 -1
  75. package/dist/test/inference-exhaustion-rewind.test.js +142 -14
  76. package/dist/test/inference-exhaustion-rewind.test.js.map +1 -1
  77. package/dist/test/inference-failure-bounds.test.d.ts +2 -0
  78. package/dist/test/inference-failure-bounds.test.d.ts.map +1 -0
  79. package/dist/test/inference-failure-bounds.test.js +1201 -0
  80. package/dist/test/inference-failure-bounds.test.js.map +1 -0
  81. package/dist/test/late-events-after-stop.test.d.ts +2 -0
  82. package/dist/test/late-events-after-stop.test.d.ts.map +1 -0
  83. package/dist/test/late-events-after-stop.test.js +122 -0
  84. package/dist/test/late-events-after-stop.test.js.map +1 -0
  85. package/dist/test/mcpl-awareness-barrier.test.js +141 -0
  86. package/dist/test/mcpl-awareness-barrier.test.js.map +1 -1
  87. package/dist/test/mcpl-coalescing.test.js +78 -16
  88. package/dist/test/mcpl-coalescing.test.js.map +1 -1
  89. package/dist/test/mcpl-config-diagnostics.test.js +6 -3
  90. package/dist/test/mcpl-config-diagnostics.test.js.map +1 -1
  91. package/dist/test/mcpl-manifest-changed.test.js +2 -1
  92. package/dist/test/mcpl-manifest-changed.test.js.map +1 -1
  93. package/dist/test/mcpl-reference-fetcher.test.js +232 -4
  94. package/dist/test/mcpl-reference-fetcher.test.js.map +1 -1
  95. package/dist/test/mcpl-reference-fields.test.js +69 -2
  96. package/dist/test/mcpl-reference-fields.test.js.map +1 -1
  97. package/dist/test/mcpl-ws-transport.test.js +103 -0
  98. package/dist/test/mcpl-ws-transport.test.js.map +1 -1
  99. package/dist/test/ops-alert.test.js +2 -1
  100. package/dist/test/ops-alert.test.js.map +1 -1
  101. package/dist/test/placed-reaction-baseline.test.d.ts +2 -0
  102. package/dist/test/placed-reaction-baseline.test.d.ts.map +1 -0
  103. package/dist/test/placed-reaction-baseline.test.js +312 -0
  104. package/dist/test/placed-reaction-baseline.test.js.map +1 -0
  105. package/dist/test/provider-acceleration-cooldown.test.js +5 -1
  106. package/dist/test/provider-acceleration-cooldown.test.js.map +1 -1
  107. package/dist/test/provider-waits.test.d.ts +2 -0
  108. package/dist/test/provider-waits.test.d.ts.map +1 -0
  109. package/dist/test/provider-waits.test.js +2196 -0
  110. package/dist/test/provider-waits.test.js.map +1 -0
  111. package/dist/test/reaction-baseline-platform.test.d.ts +2 -0
  112. package/dist/test/reaction-baseline-platform.test.d.ts.map +1 -0
  113. package/dist/test/reaction-baseline-platform.test.js +124 -0
  114. package/dist/test/reaction-baseline-platform.test.js.map +1 -0
  115. package/dist/test/refusal-reaction-baseline.test.js +10 -3
  116. package/dist/test/refusal-reaction-baseline.test.js.map +1 -1
  117. package/dist/test/save-recent-image.test.js +138 -2
  118. package/dist/test/save-recent-image.test.js.map +1 -1
  119. package/dist/test/surgery-marks-receipt.test.d.ts +2 -0
  120. package/dist/test/surgery-marks-receipt.test.d.ts.map +1 -0
  121. package/dist/test/surgery-marks-receipt.test.js +540 -0
  122. package/dist/test/surgery-marks-receipt.test.js.map +1 -0
  123. package/dist/test/tool-result-guard.test.js +221 -7
  124. package/dist/test/tool-result-guard.test.js.map +1 -1
  125. package/dist/test/workspace-three-way.test.js +115 -0
  126. package/dist/test/workspace-three-way.test.js.map +1 -1
  127. package/dist/tsconfig.tsbuildinfo +1 -1
  128. package/package.json +1 -1
  129. package/src/agent.ts +4 -1
  130. package/src/api/server.ts +43 -4
  131. package/src/framework.ts +1538 -189
  132. package/src/index.ts +7 -2
  133. package/src/mcpl/index.ts +2 -0
  134. package/src/mcpl/reference-fetcher.ts +18 -8
  135. package/src/mcpl/references.ts +57 -13
  136. package/src/mcpl/server-connection.ts +55 -4
  137. package/src/module-registry.ts +17 -9
  138. package/src/modules/workspace/index.ts +44 -4
  139. package/src/modules/workspace/observe.ts +31 -5
  140. package/src/modules/workspace/reconcile.ts +23 -2
  141. package/src/operator-log.ts +30 -0
  142. package/src/provider-waits.ts +523 -0
  143. package/src/refusal-reactions.ts +14 -12
  144. package/src/tool-result-guard.ts +72 -24
  145. package/src/types/events.ts +12 -0
  146. package/src/types/module.ts +12 -4
  147. package/src/types/trace.ts +14 -3
@@ -118,6 +118,15 @@ export interface PassResult {
118
118
  reports: Map<string, PathReport>;
119
119
  /** Regions the pass couldn't observe that matter to its scope. */
120
120
  incomplete: Array<{ path: string; reason: string }>;
121
+ /**
122
+ * What the walk passed over by design and reported nowhere else, counted by
123
+ * reason: entries the mount's ignore list covers, when nothing beneath them
124
+ * is tracked (a tracked one is reported under `incomplete`). An ignored
125
+ * directory counts once, everything in it included. Only listings the walk
126
+ * kept count: a directory changed during the walk, or one past the file
127
+ * cap, is reported under `incomplete` instead.
128
+ */
129
+ passedOver: Map<string, number>;
121
130
  /** For a directory scope: the subdirectories disk shows directly beneath it. */
122
131
  dirs: string[];
123
132
  /**
@@ -156,6 +165,11 @@ export interface PassOptions {
156
165
  adopt?: (path: string) => boolean;
157
166
  /** Walk cap for directory scopes. */
158
167
  cap?: number;
168
+ /**
169
+ * The scope was named by a path sync: ignore patterns that cover it don't
170
+ * apply beneath it (see walkScope). A listing's scope keeps them.
171
+ */
172
+ named?: boolean;
159
173
  /**
160
174
  * The branch the caller asked on (a path sync). If another is selected when
161
175
  * the pass gathers, it decides nothing: an adoption asked for on one branch
@@ -604,7 +618,7 @@ async function gather(store: JsStore, agreement: DiskAgreement, mount: MountRunt
604
618
  for (const [p] of agreement.paths(mount.name, scope.dir)) if (inScope(p, scope)) candidates.add(p);
605
619
  for (const [p] of mount.intents.list(scope.dir)) if (inScope(p, scope)) candidates.add(p);
606
620
  if (rootUnavailable === null) {
607
- walk = await walkScope(mount.view, mount.rootReal, scope.dir, scope.recursive, opts.cap ?? WALK_CAP);
621
+ walk = await walkScope(mount.view, mount.rootReal, scope.dir, scope.recursive, opts.cap ?? WALK_CAP, { named: opts.named });
608
622
  for (const f of walk.files) if (inScope(f, scope)) candidates.add(f);
609
623
  // Ignored names are listed only when something tracks them (above).
610
624
  for (const [p, why] of walk.others) if (why !== 'ignored by the mount' && inScope(p, scope)) candidates.add(p);
@@ -672,7 +686,7 @@ export async function reconcilePass(
672
686
  // Evidence a failed barrier left unsynced is settled before anything is
673
687
  // decided from it; if the barrier fails again, so does the pass.
674
688
  if (agreement.needsBarrier) agreement.barrier();
675
- const result: PassResult = { ops: [], discarded: [], newConflicts: [], reports: new Map(), incomplete: [], dirs: [] };
689
+ const result: PassResult = { ops: [], discarded: [], newConflicts: [], reports: new Map(), incomplete: [], passedOver: new Map(), dirs: [] };
676
690
 
677
691
  // Enumerate on one branch and observe disk (async). The decision below must
678
692
  // describe the same branch the candidates came from: a branch switch during
@@ -805,6 +819,13 @@ export async function reconcilePass(
805
819
  for (const region of walk.incomplete) {
806
820
  const hides = tracked.some((p) => p === region.path || p.startsWith(region.path + '/'));
807
821
  if (region.kind === 'error' || region.kind === 'cap' || hides) result.incomplete.push({ path: region.path, reason: region.reason });
822
+ // An ignored name that hides nothing tracked is reported nowhere else:
823
+ // counted, so a sync that says 0 also says what it didn't look at
824
+ // (agent-framework #276). A symlink region isn't counted: the link
825
+ // itself is a candidate the pass reports by path, taken through the
826
+ // link when it's a file on a mount that follows symlinks, and
827
+ // otherwise listed under `skipped`.
828
+ else if (region.kind === 'ignored') result.passedOver.set(region.reason, (result.passedOver.get(region.reason) ?? 0) + 1);
808
829
  }
809
830
  }
810
831
  return result;
@@ -118,6 +118,36 @@ export function capIds(ids: string[]): { ids: string[]; count: number; truncated
118
118
  : { ids, count: ids.length };
119
119
  }
120
120
 
121
+ /**
122
+ * What a live surgery did about Discord awareness marks, reported apart from
123
+ * the body change itself. A surgery that returns has applied its body change;
124
+ * this says only whether marks were scheduled:
125
+ *
126
+ * - `none` — no marks were scheduled: the change removed no addressable
127
+ * Discord message, or the awareness outbox is disabled (no
128
+ * `discordAwarenessOutboxPath` and no `storePath`). It is not proof that
129
+ * no addressable Discord message was removed: the surgery's `removedRefs`
130
+ * (`discordRefs` in the operator log) say whether any was.
131
+ * - `queued` — the outbox batch is durably active, and its delivery was
132
+ * started in the background. If starting it fails (reconciliation reading
133
+ * or writing the ledger), a `discord-awareness-delivery` ops alert reports
134
+ * the failure, the batch stays durably active, and the next sync,
135
+ * reconnect or start delivers it. It is not a delivery claim: per-message
136
+ * outcomes stay in the outbox ledger.
137
+ * - `not-scheduled` — the body change landed, but recording the batch as
138
+ * active failed, so it was retired from the ledger: none of its marks will
139
+ * be delivered.
140
+ * - `unresolved` — the ledger could record neither the activation nor the
141
+ * retirement. `batchId` names a retained batch whose scheduling outcome is
142
+ * unresolved: any later reconciliation or restart that can read it may
143
+ * promote and deliver it, and nothing here promises otherwise.
144
+ */
145
+ export type SurgeryMarkerReceipt =
146
+ | { status: 'none'; queued: 0 }
147
+ | { status: 'queued'; queued: number; batchId: string }
148
+ | { status: 'not-scheduled'; queued: 0; error: string }
149
+ | { status: 'unresolved'; queued: 0; batchId: string; error: string };
150
+
121
151
  /** Thrown by live surgery methods when a request cannot be honored. `code`
122
152
  * lets surfaces distinguish "agent busy — quiesce first" from bad input. */
123
153
  export class OperatorActionError extends Error {
@@ -0,0 +1,523 @@
1
+ /**
2
+ * Provider waits: a provider's stated `retry-after`, kept as a deadline that
3
+ * outlives the process.
4
+ *
5
+ * When a provider answers "retry after N", calling the same model again
6
+ * before then adds traffic the provider has said will fail. The framework
7
+ * therefore records the wait per (agent, model) and admits no call to that
8
+ * model for that agent until it passes. The wait is a fact about the provider,
9
+ * not about the conversation, so it is kept in typed Chronicle records
10
+ * (RecordJournal): a restart honours it, and a branch switch or rollback
11
+ * does not rewind it.
12
+ *
13
+ * - A later, shorter wait for the same (agent, model) never shortens an
14
+ * outstanding longer one: the binding deadline is the maximum. Requests
15
+ * that finish at different times can carry different hints.
16
+ * - A negative wait is already over (a provider's "retry now", or an HTTP
17
+ * date already past), -Infinity included: it binds nothing, the same as 0.
18
+ * - A wait that cannot be represented as an instant (NaN, +Infinity, or past
19
+ * the last instant a Date can hold) becomes an indefinite wait rather than
20
+ * a shorter one, held until explicitly released.
21
+ * - A wait ends when its instant passes (nothing is written), or when an
22
+ * operator releases it (recorded). Selecting a different model is not a
23
+ * release: that model proceeds, and the old model's wait still applies if it
24
+ * is selected again before the wait passes.
25
+ *
26
+ * One writer per record type (RecordJournal's contract): the framework that
27
+ * owns the store.
28
+ */
29
+
30
+ import type { JsStore } from '@animalabs/chronicle';
31
+ import { RecordJournal } from './record-journal.js';
32
+ import { safeSlice } from './safe-slice.js';
33
+
34
+ export const PROVIDER_WAIT_RECORD_TYPE = 'framework/provider-wait';
35
+
36
+ /** Checkpoint the reduced waits after this many entries, bounding replay at open. */
37
+ const CHECKPOINT_EVERY = 64;
38
+ /** The last instant a JavaScript Date can represent (ECMA-262 time value range). */
39
+ const MAX_DATE_MS = 8.64e15;
40
+
41
+ export interface ProviderWait {
42
+ agent: string;
43
+ model: string;
44
+ /** Epoch ms before which no call is admitted; null holds until released. */
45
+ until: number | null;
46
+ /** Why, in the provider's words (bounded by the caller). */
47
+ reason: string;
48
+ /** When the binding wait was recorded. */
49
+ setAt: number;
50
+ }
51
+
52
+ type ProviderWaitEntry =
53
+ | { kind: 'set'; agent: string; model: string; until: number | null; reason: string; at: number }
54
+ | { kind: 'released'; agent: string; model: string | null; by: string; at: number };
55
+
56
+ interface ProviderWaitSnapshot {
57
+ waits: ProviderWait[];
58
+ }
59
+
60
+ /**
61
+ * The instant a stated wait ends, or null when it cannot be represented:
62
+ * the wait is then held until released, never shortened. A negative wait
63
+ * ends at `now`: it is already over, never indefinite.
64
+ */
65
+ export function waitDeadline(retryAfterMs: unknown, now: number): number | null {
66
+ if (typeof retryAfterMs !== 'number' || Number.isNaN(retryAfterMs)) return null;
67
+ // Over before it can't be represented: -Infinity is as negative as any.
68
+ if (retryAfterMs <= 0) return Math.ceil(now);
69
+ if (!Number.isFinite(retryAfterMs)) return null;
70
+ const until = Math.ceil(now + Math.max(0, retryAfterMs));
71
+ if (!Number.isSafeInteger(until) || until > MAX_DATE_MS) return null;
72
+ return until;
73
+ }
74
+
75
+ const keyOf = (agent: string, model: string): string => `${agent}\u0000${model}`;
76
+
77
+ /** Whether `next` binds longer than `current` (null is longer than any instant). */
78
+ function outlasts(next: number | null, current: number | null): boolean {
79
+ if (current === null) return false;
80
+ if (next === null) return true;
81
+ return next > current;
82
+ }
83
+
84
+ function reduce(waits: Map<string, ProviderWait>, entry: ProviderWaitEntry): void {
85
+ if (entry.kind === 'set') {
86
+ const key = keyOf(entry.agent, entry.model);
87
+ const current = waits.get(key);
88
+ if (current && !outlasts(entry.until, current.until)) return;
89
+ waits.set(key, { agent: entry.agent, model: entry.model, until: entry.until, reason: entry.reason, setAt: entry.at });
90
+ return;
91
+ }
92
+ for (const [key, wait] of waits) {
93
+ if (wait.agent === entry.agent && (entry.model === null || wait.model === entry.model)) waits.delete(key);
94
+ }
95
+ }
96
+
97
+ export interface ProviderWaitChange {
98
+ /** The binding wait after this call; for a wait already over with nothing
99
+ * binding, that wait as stated, which binds nothing and isn't recorded. */
100
+ wait: ProviderWait;
101
+ /** False when an outstanding longer (or indefinite) wait already binds, or
102
+ * when the wait is already over and nothing binds. */
103
+ changed: boolean;
104
+ }
105
+
106
+ /**
107
+ * One wait a release lifted. `release` says how far that reaches:
108
+ * - 'recorded': the release is durably recorded, so a restart keeps it;
109
+ * - 'pending': it binds in this process, but its record has not landed yet
110
+ * and is offered again at the next act (a restart before then restores the
111
+ * wait). Once the journal can't be written again (an append that threw
112
+ * inside the store), it is offered no more in this process;
113
+ * - 'in-process override': the hold that stands for unreadable recorded
114
+ * waits, lifted for this process only (a restart that still cannot read
115
+ * them holds again). The release itself is still offered to the journal,
116
+ * so once the history can be read it applies there, in order.
117
+ */
118
+ export interface ProviderWaitRelease {
119
+ wait: ProviderWait;
120
+ release: 'recorded' | 'pending' | 'in-process override';
121
+ }
122
+
123
+ /** How often a binding act this process could not record is offered to the journal again, absent another act. */
124
+ const RETRY_WRITE_MS = 30_000;
125
+ /** How often unreadable recorded waits are read again, by default (`retryReadMs`). */
126
+ const RETRY_READ_MS = 30_000;
127
+ /**
128
+ * The model that stands for every model: how list() shows the hold for
129
+ * unreadable records, and what release() takes as "every model" (the same as
130
+ * omitting it). No provider model is named that.
131
+ */
132
+ export const EVERY_MODEL = '*';
133
+
134
+ /** A control character (C0, C1, U+2028, U+2029): in a log line, one can start
135
+ * a line that reads as the framework's own. */
136
+ const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
137
+
138
+ /** Text from a caller or a provider, with each control character a space, so
139
+ * it can't forge a log line where it is printed. */
140
+ export function withoutControls(text: string): string {
141
+ return text.replace(new RegExp(CONTROL_CHARACTER.source, 'g'), ' ');
142
+ }
143
+
144
+ /**
145
+ * Whether a release's `model`, as an operator surface receives it, names a
146
+ * scope: omitted or EVERY_MODEL for every model, any other nonempty string
147
+ * without a control character for that model. Anything else is malformed and
148
+ * is refused, never taken as "every model" (and never logged).
149
+ */
150
+ export function isReleaseModel(model: unknown): model is string | undefined {
151
+ return model === undefined || (typeof model === 'string' && model !== '' && !CONTROL_CHARACTER.test(model));
152
+ }
153
+
154
+ const errorText = (error: unknown): string => (error instanceof Error ? error.message : String(error));
155
+ const live = (wait: ProviderWait | undefined, now: number): ProviderWait | undefined =>
156
+ wait && (wait.until === null || wait.until > now) ? wait : undefined;
157
+
158
+ // What a record must hold to be read as provider waits: every field the
159
+ // reduction, or a reader of its waits, uses. A record that parses but lacks
160
+ // one is unreadable (fail closed), never read as "no waits".
161
+ const fieldsOf = (value: unknown): Record<string, unknown> | undefined =>
162
+ typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
163
+ const isText = (value: unknown): value is string => typeof value === 'string';
164
+ /** A number a Date can hold (NaN and the infinities fail the comparison). */
165
+ const isInstant = (value: unknown): value is number => typeof value === 'number' && Math.abs(value) <= MAX_DATE_MS;
166
+ const isDeadline = (value: unknown): value is number | null => value === null || isInstant(value);
167
+
168
+ function isWait(value: unknown): value is ProviderWait {
169
+ const wait = fieldsOf(value);
170
+ return wait !== undefined && isText(wait.agent) && isText(wait.model) && isDeadline(wait.until) &&
171
+ isText(wait.reason) && isInstant(wait.setAt);
172
+ }
173
+
174
+ /** A release's `by` and `at` are never read back, so they are not required. */
175
+ function isEntry(value: unknown): value is ProviderWaitEntry {
176
+ const entry = fieldsOf(value);
177
+ if (entry === undefined || !isText(entry.agent)) return false;
178
+ if (entry.kind === 'set') {
179
+ return isText(entry.model) && isDeadline(entry.until) && isText(entry.reason) && isInstant(entry.at);
180
+ }
181
+ return entry.kind === 'released' && (entry.model === null || isText(entry.model));
182
+ }
183
+
184
+ function isSnapshot(value: unknown): value is ProviderWaitSnapshot {
185
+ const waits = fieldsOf(value)?.waits;
186
+ if (!Array.isArray(waits) || !waits.every(isWait)) return false;
187
+ // The writer keeps one wait per (agent, model). A repeated key has no
188
+ // reading that is sure to keep the longer wait.
189
+ return new Set(waits.map((wait) => keyOf(wait.agent, wait.model))).size === waits.length;
190
+ }
191
+
192
+ export class ProviderWaits {
193
+ /**
194
+ * The reduction of exactly what the journal holds: its latest checkpoint and
195
+ * every entry after it, as loaded or as appended and synced. A checkpoint
196
+ * records this, so it covers the same ordered acts the journal does.
197
+ */
198
+ private durable = new Map<string, ProviderWait>();
199
+ /**
200
+ * Acts of this process not yet known to be durably recorded, in the order
201
+ * they were made. They bind here from the moment they are made, and are
202
+ * offered to the journal, in order, at every later act until one lands.
203
+ * Once the journal can't be written again (an append that threw inside the
204
+ * store: RecordJournal.needsReopen), they are offered no more: the act that
205
+ * threw may still be in the store's log, where the next start finds it,
206
+ * and every later one holds in this process only.
207
+ */
208
+ private readonly pending: ProviderWaitEntry[] = [];
209
+ /** What binds in this process: `durable` with `pending` applied in order. */
210
+ private view = new Map<string, ProviderWait>();
211
+ /**
212
+ * Set while the recorded waits cannot be read. Their absence is then
213
+ * unknown, not "no waits": every (agent, model) is held (fail closed) until
214
+ * they can be read or an operator releases the agent's waits. Releases made
215
+ * meanwhile are kept in `waived` (agent, model or EVERY_MODEL) for this
216
+ * process; a restart that still cannot read them holds again.
217
+ */
218
+ private unreadable: { error: string; at: number; lastTry: number; waived: Set<string> } | null = null;
219
+ private lastWriteTry = 0;
220
+ /**
221
+ * How often unreadable recorded waits are read again. The hold that stands
222
+ * for them ends without any act (the records read again), so whoever holds
223
+ * on a wait with no deadline asks active() again this often.
224
+ */
225
+ readonly retryReadMs: number;
226
+ private readonly store: JsStore | null;
227
+ private readonly journal: RecordJournal<ProviderWaitEntry, ProviderWaitSnapshot> | null;
228
+ private readonly now: () => number;
229
+ private readonly log: (line: string) => void;
230
+ private readonly onUnreadable: (error: string) => void;
231
+
232
+ /**
233
+ * `store` null keeps waits in memory only (a framework without a store has
234
+ * no restart to survive). `onUnreadable` hears each time the recorded waits
235
+ * become unreadable, once per episode (a restart that still cannot read them
236
+ * is a new one): every (agent, model) is then held until they read again or
237
+ * an operator releases, and a cause that persists (a malformed or newer
238
+ * record) never reads again, so a person has to hear of it.
239
+ */
240
+ constructor(store: JsStore | null, opts: {
241
+ now?: () => number; log?: (line: string) => void; retryReadMs?: number; onUnreadable?: (error: string) => void;
242
+ } = {}) {
243
+ this.now = opts.now ?? Date.now;
244
+ this.log = opts.log ?? ((line) => console.error(line));
245
+ this.onUnreadable = opts.onUnreadable ?? (() => {});
246
+ this.retryReadMs = opts.retryReadMs ?? RETRY_READ_MS;
247
+ this.store = store;
248
+ this.journal = store ? new RecordJournal<ProviderWaitEntry, ProviderWaitSnapshot>(store, { type: PROVIDER_WAIT_RECORD_TYPE }) : null;
249
+ this.load();
250
+ }
251
+
252
+ /**
253
+ * The wait binding (agent, model) now, if any. While the recorded waits
254
+ * cannot be read, an indefinite wait saying so, unless an operator released
255
+ * this agent's waits since.
256
+ */
257
+ active(agent: string, model: string): ProviderWait | undefined {
258
+ this.retry();
259
+ if (this.unreadable && !this.waived(agent, model)) return this.unreadableWait(agent, model);
260
+ const wait = live(this.view.get(keyOf(agent, model)), this.now());
261
+ return wait ? { ...wait } : undefined;
262
+ }
263
+
264
+ /**
265
+ * Whether a recorded wait binds this agent now, unreadable history aside:
266
+ * the agent of a wait the listing shows, even one the framework no longer
267
+ * has (a disposed conversation fork), which a release can then name.
268
+ */
269
+ recorded(agent: string): boolean {
270
+ const now = this.now();
271
+ for (const wait of this.view.values()) if (wait.agent === agent && live(wait, now)) return true;
272
+ return false;
273
+ }
274
+
275
+ /**
276
+ * Every wait binding now, for one agent or all. While the recorded waits
277
+ * cannot be read, it leads with an indefinite wait on EVERY_MODEL saying
278
+ * so (for the agent asked about, or for EVERY_MODEL agents).
279
+ */
280
+ list(agent?: string): ProviderWait[] {
281
+ this.retry();
282
+ const result: ProviderWait[] = [];
283
+ if (this.unreadable && (agent === undefined || !this.waived(agent, EVERY_MODEL))) {
284
+ result.push(this.unreadableWait(agent ?? EVERY_MODEL, EVERY_MODEL));
285
+ }
286
+ const now = this.now();
287
+ for (const wait of this.view.values()) {
288
+ if (agent !== undefined && wait.agent !== agent) continue;
289
+ if (live(wait, now)) result.push({ ...wait });
290
+ }
291
+ return result;
292
+ }
293
+
294
+ /** Record a provider's stated wait for (agent, model). */
295
+ set(agent: string, model: string, retryAfterMs: unknown, reason: string): ProviderWaitChange {
296
+ const at = this.now();
297
+ // Decide against what the journal now holds: an earlier ambiguous write
298
+ // is reconciled first.
299
+ this.reconcile();
300
+ const entry: ProviderWaitEntry = { kind: 'set', agent, model, until: waitDeadline(retryAfterMs, at), reason, at };
301
+ const before = live(this.view.get(keyOf(agent, model)), at);
302
+ // A wait already over (0, or a "retry now") binds nothing: with nothing
303
+ // binding either, there is nothing to record.
304
+ if (before === undefined && entry.until !== null && entry.until <= at) {
305
+ this.flush();
306
+ return { wait: { agent, model, until: entry.until, reason, setAt: at }, changed: false };
307
+ }
308
+ const changed = before === undefined || outlasts(entry.until, before.until);
309
+ if (changed) this.apply(entry);
310
+ // An unchanged binding is not proof it was recorded: offer what is pending.
311
+ this.flush();
312
+ return { wait: { ...this.view.get(keyOf(agent, model))! }, changed };
313
+ }
314
+
315
+ /**
316
+ * Release (agent, model)'s wait, or every wait of the agent when `model` is
317
+ * omitted; recorded. While the recorded waits cannot be read, it also lifts
318
+ * the hold that stands for them, exactly as far as the release names (that
319
+ * model, or every model), in this process only. Each lifted wait says how
320
+ * far its release reaches (ProviderWaitRelease). A release that finds
321
+ * nothing binding lifts and records nothing, and lists nothing. That
322
+ * includes a replay of an earlier release. It still offers this process's
323
+ * unrecorded acts to the journal again.
324
+ */
325
+ release(agent: string, model: string | undefined, by: string): ProviderWaitRelease[] {
326
+ // EVERY_MODEL, as list() shows it, names every model: the same release
327
+ // as omitting the model, in this process and in the record alike.
328
+ if (model === EVERY_MODEL) model = undefined;
329
+ this.reconcile();
330
+ const now = this.now();
331
+ const released = [...this.view.values()]
332
+ .filter((wait) => wait.agent === agent && (model === undefined || wait.model === model) && live(wait, now))
333
+ .map((wait) => ({ ...wait }));
334
+ const target = model ?? EVERY_MODEL;
335
+ let override: ProviderWait | undefined;
336
+ if (this.unreadable && !this.waived(agent, target)) {
337
+ override = this.unreadableWait(agent, target);
338
+ this.unreadable.waived.add(keyOf(agent, target));
339
+ }
340
+ if (released.length === 0 && !override) {
341
+ // Nothing binds to lift, so this release lists nothing. It may be a
342
+ // replay of one whose record hasn't landed ('pending'): offer this
343
+ // process's unrecorded acts to the journal again now.
344
+ this.flush();
345
+ return [];
346
+ }
347
+ const entry: ProviderWaitEntry = { kind: 'released', agent, model: model ?? null, by, at: now };
348
+ this.apply(entry);
349
+ this.flush();
350
+ const recorded = !this.pending.includes(entry);
351
+ return [
352
+ ...(override ? [{ wait: override, release: 'in-process override' as const }] : []),
353
+ ...released.map((wait) => ({ wait, release: recorded ? 'recorded' as const : 'pending' as const })),
354
+ ];
355
+ }
356
+
357
+ private waived(agent: string, model: string): boolean {
358
+ const waived = this.unreadable?.waived;
359
+ return waived !== undefined && (waived.has(keyOf(agent, EVERY_MODEL)) || waived.has(keyOf(agent, model)));
360
+ }
361
+
362
+ private unreadableWait(agent: string, model: string): ProviderWait {
363
+ return {
364
+ agent, model, until: null, setAt: this.unreadable!.at,
365
+ reason: `recorded provider waits could not be read (${this.unreadable!.error}); held until they can be read or an operator releases this agent's waits`,
366
+ };
367
+ }
368
+
369
+ /** Make an act bind in this process, ahead of its record. */
370
+ private apply(entry: ProviderWaitEntry): void {
371
+ this.pending.push(entry);
372
+ reduce(this.view, entry);
373
+ }
374
+
375
+ /** Rebuild what binds from the journal's reduction and this process's pending acts, in order. */
376
+ private rebuildView(): void {
377
+ const view = new Map<string, ProviderWait>();
378
+ for (const [key, wait] of this.durable) view.set(key, { ...wait });
379
+ for (const entry of this.pending) reduce(view, entry);
380
+ this.view = view;
381
+ }
382
+
383
+ /**
384
+ * Read the journal into `durable`. A failure leaves the history unreadable
385
+ * (fail closed, reported once), never empty. So does a record that parses
386
+ * but cannot be read as provider waits: a checkpoint also stands for every
387
+ * entry before it, so a malformed one read as empty would drop them all.
388
+ */
389
+ private load(): boolean {
390
+ if (!this.journal) return true;
391
+ const now = this.now();
392
+ try {
393
+ const { snapshot, entries } = this.journal.load();
394
+ // RecordJournal shows a checkpoint whose snapshot is null as no
395
+ // checkpoint at all (it still skips the entries it covers); this writer
396
+ // never writes one, so finding such a record means it is malformed.
397
+ const malformedCheckpoint = snapshot === null
398
+ ? this.store!.getRecordIdsByType(this.journal.checkpointType).length > 0
399
+ : !isSnapshot(snapshot);
400
+ if (malformedCheckpoint) throw new Error('a provider-wait checkpoint is malformed');
401
+ for (const { id, entry } of entries) {
402
+ if (!isEntry(entry)) throw new Error(`provider-wait record ${id} is malformed`);
403
+ }
404
+ const loaded = new Map<string, ProviderWait>();
405
+ for (const wait of snapshot?.waits ?? []) loaded.set(keyOf(wait.agent, wait.model), { ...wait });
406
+ for (const { entry } of entries) reduce(loaded, entry);
407
+ this.durable = loaded;
408
+ } catch (error) {
409
+ if (this.unreadable) {
410
+ this.unreadable.lastTry = now;
411
+ } else {
412
+ this.unreadable = { error: safeSlice(errorText(error), 0, 300), at: now, lastTry: now, waived: new Set() };
413
+ this.log(`[provider-wait] recorded provider waits could not be read (${this.unreadable.error}): ` +
414
+ 'no provider call is admitted until they can be read or an operator releases an agent\'s waits ' +
415
+ '(release-provider-wait); inspection is unaffected');
416
+ try { this.onUnreadable(this.unreadable.error); } catch { /* reporting never changes the hold */ }
417
+ }
418
+ return false;
419
+ }
420
+ const wasUnreadable = this.unreadable !== null;
421
+ this.unreadable = null;
422
+ this.rebuildView();
423
+ // What binds is the journal's reduction with this process's unrecorded
424
+ // acts applied after it: a release made meanwhile lifts what it names.
425
+ if (wasUnreadable) this.log(`[provider-wait] recorded provider waits are readable again; ${loaded(this.view, now)} bind`);
426
+ return true;
427
+ }
428
+
429
+ /** Reconcile an ambiguous write before deciding anything against the journal. */
430
+ private reconcile(): void {
431
+ if (this.journal?.needsReconcile) this.load();
432
+ }
433
+
434
+ /** Offer unrecorded acts and unreadable history again, at most every so often, absent other acts. */
435
+ private retry(): void {
436
+ const now = this.now();
437
+ if (this.unreadable && now - this.unreadable.lastTry >= this.retryReadMs) this.load();
438
+ if (this.pending.length > 0 && now - this.lastWriteTry >= RETRY_WRITE_MS) {
439
+ this.reconcile();
440
+ this.flush();
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Record pending acts in order, each durable before the next: a lost wait
446
+ * would admit an early call after a crash. The first that fails stays
447
+ * pending with every act after it (they still bind in this process) and is
448
+ * reported; an ambiguous one is reconciled before the next attempt, so an
449
+ * act may be recorded twice, which reduces the same. An append or
450
+ * checkpoint that throws inside the store leaves the journal unwritable
451
+ * until the store is reopened (RecordJournal.needsReopen), so nothing is
452
+ * written after it: that is said once, where it happens, and the acts stay
453
+ * pending, binding in this process. A checkpoint records the
454
+ * journal's own reduction, never this process's unrecorded acts, and never
455
+ * while the history is unreadable (it would cover entries that were never
456
+ * reduced).
457
+ */
458
+ private flush(): void {
459
+ if (!this.journal) {
460
+ for (const entry of this.pending.splice(0)) reduce(this.durable, entry);
461
+ return;
462
+ }
463
+ this.lastWriteTry = this.now();
464
+ while (this.pending.length > 0) {
465
+ if (this.journal.needsReconcile && !this.load()) return;
466
+ if (this.journal.needsReconcile) return;
467
+ // Offering again would only fail again; reportUnwritable said so when
468
+ // the write threw.
469
+ if (this.journal.needsReopen) return;
470
+ const entry = this.pending[0]!;
471
+ try {
472
+ this.journal.append(entry, { durable: true });
473
+ } catch (error) {
474
+ if (this.journal.needsReopen) {
475
+ this.reportUnwritable(error, `${entry.kind === 'set' ? 'a wait' : 'a release'} for agent=${entry.agent} model=${entry.model ?? EVERY_MODEL}`);
476
+ return;
477
+ }
478
+ this.log(`[provider-wait] could not record ${entry.kind} for agent=${entry.agent} model=${entry.model ?? EVERY_MODEL} ` +
479
+ `(${this.pending.length} act(s) pending); it holds in this process and is offered again at the next act: ${safeSlice(errorText(error), 0, 300)}`);
480
+ return;
481
+ }
482
+ this.pending.shift();
483
+ reduce(this.durable, entry);
484
+ }
485
+ if (this.unreadable || this.journal.needsReopen || this.journal.entriesSinceCheckpoint < CHECKPOINT_EVERY) return;
486
+ const now = this.now();
487
+ try {
488
+ this.journal.checkpoint({ waits: [...this.durable.values()].filter((wait) => live(wait, now)) }, { durable: true });
489
+ } catch (error) {
490
+ if (this.journal.needsReopen) {
491
+ this.reportUnwritable(error, 'a checkpoint');
492
+ return;
493
+ }
494
+ this.log(`[provider-wait] could not checkpoint provider waits; the journal still replays in full: ${safeSlice(errorText(error), 0, 300)}`);
495
+ }
496
+ }
497
+
498
+ /**
499
+ * Say that the journal can't be written again: the store's append threw
500
+ * inside the store, and only a reopen (as at the next start) reads its log
501
+ * back. Called where that first happens; flush() writes nothing after it,
502
+ * so it is said once for the journal's life. What `writing` names may still
503
+ * be in the store's log, and the next start loads it if so. A stated wait
504
+ * still holds here, which is always safe; a later act loses only its
505
+ * record, so after a restart the agent's next call goes to the provider,
506
+ * and a wait it states again starts a new hold. No alert: a failed write
507
+ * is log-only.
508
+ */
509
+ private reportUnwritable(error: unknown, writing: string): void {
510
+ this.log(`[provider-wait] nothing more is written to the provider-wait journal until the store is reopened, ` +
511
+ `as at the next start: the store's append of ${writing} failed (${safeSlice(errorText(error), 0, 300)}), and that ` +
512
+ `record may still be in the store's log, where the next start finds it. Every wait and release after it holds ` +
513
+ `in this process only (${this.pending.length} act(s) pending now). After a restart, such a wait no longer binds: ` +
514
+ 'the agent\'s next call to that model goes to the provider, and a wait the provider states again starts a new hold. ' +
515
+ 'Such a release no longer lifts its wait');
516
+ }
517
+ }
518
+
519
+ function loaded(waits: Map<string, ProviderWait>, now: number): number {
520
+ let count = 0;
521
+ for (const wait of waits.values()) if (live(wait, now)) count++;
522
+ return count;
523
+ }
@@ -1,17 +1,20 @@
1
1
  /**
2
2
  * Refusal-reaction markers — the single source of truth for the emoji the
3
3
  * framework places on a message when inference is refused (see
4
- * AgentFramework.reactToRefusal), exported so host composition can derive
5
- * a protective suppression baseline from the exact set the framework emits.
4
+ * AgentFramework.reactToRefusal).
6
5
  *
7
6
  * These annotations are placed by the framework on the resident's account
8
7
  * and must never re-enter a resident's context as reaction events — that is
9
8
  * the self-amplifying loop behind the 8/3 Mythos incident. The Discord
10
- * adapter suppresses them when its operator config or the host-injected
11
- * `DISCORD_SUPPRESSED_REACTIONS_BASELINE` names them; keeping the emitted
12
- * set and the exported baseline as one constant is what makes drift between
13
- * "what we stamp" and "what we suppress" structurally impossible, rather
14
- * than a promise kept in two files.
9
+ * adapter suppresses them when its operator config or
10
+ * `DISCORD_SUPPRESSED_REACTIONS_BASELINE` names them. That variable's
11
+ * protective default, which the framework injects into stdio children, is
12
+ * `framework.getPlacedReactionBaseline()` on an instance: these refusal
13
+ * markers plus the awareness markers. REFUSAL_REACTION_BASELINE below is the
14
+ * refusal set only. Keeping the emitted refusal set and the exported one as
15
+ * one constant is what makes drift between "what we stamp" and "what we
16
+ * suppress" structurally impossible, rather than a promise kept in two
17
+ * files.
15
18
  */
16
19
 
17
20
  /** Refusal category → Discord reaction emoji. Unknown categories get the
@@ -27,11 +30,10 @@ export const REFUSAL_REACTIONS: Readonly<Record<string, string>> = {
27
30
  /** Marker used when the refusal category has no dedicated emoji. */
28
31
  export const REFUSAL_REACTION_FALLBACK = '🛑';
29
32
 
30
- /** Every marker the framework can emit — the category map plus the
31
- * fallback, deduplicated, in stable declaration order. This IS the
32
- * protective baseline: host composition serializes it (comma-joined) into
33
- * `DISCORD_SUPPRESSED_REACTIONS_BASELINE` for adapters that render
34
- * reactions. */
33
+ /** Every refusal marker the framework can emit: the category map plus the
34
+ * fallback, deduplicated in stable declaration order. For the deployment's
35
+ * complete protective default, including awareness markers, use
36
+ * `framework.getPlacedReactionBaseline()` on an instance. */
35
37
  export const REFUSAL_REACTION_BASELINE: readonly string[] = [
36
38
  ...new Set([...Object.values(REFUSAL_REACTIONS), REFUSAL_REACTION_FALLBACK]),
37
39
  ];