@stratusagent/core 0.11.3 → 0.11.5

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/dist/index.js CHANGED
@@ -214,7 +214,19 @@ export const LISTENS_MODES = ['mentions', 'thread', 'judge'];
214
214
  export const isListensMode = (value) => typeof value === 'string' && LISTENS_MODES.includes(value);
215
215
  export class AgentRegistry {
216
216
  agents = new Map();
217
+ /**
218
+ * `*` in a `delegates` list means any agent on the roster, so an agent
219
+ * with that id could never be granted alone — `delegates: ['*']` would
220
+ * open the whole roster to reach it. The soul loader refuses the id;
221
+ * this refuses it for a definition built in code, since the wildcard's
222
+ * meaning is decided here, where the roster lives, and not by which
223
+ * path put the agent on it.
224
+ */
217
225
  register(agent) {
226
+ if (agent.id === '*') {
227
+ throw new Error('Invalid agent id: "*". * is the delegates wildcard (delegates: [\'*\'] means any agent on the roster), '
228
+ + 'so no agent may have it as an id — give this agent another id.');
229
+ }
218
230
  this.agents.set(agent.id, agent);
219
231
  return agent;
220
232
  }
@@ -577,6 +589,9 @@ const matchesGlobbedAllowlist = (name, allowlist, globSuffix) => allowlist.some(
577
589
  }
578
590
  return entry.endsWith(globSuffix) && name.startsWith(entry.slice(0, -1));
579
591
  });
592
+ /** Every kind, in the order the tool description lists them. */
593
+ export const MEMORY_ENTRY_KINDS = ['semantic', 'episodic', 'procedural', 'preference'];
594
+ export const isMemoryEntryKind = (value) => typeof value === 'string' && MEMORY_ENTRY_KINDS.includes(value);
580
595
  /**
581
596
  * An entry's label as every consumer reads it: the recorded one, or
582
597
  * `unknown` when none was recorded. Never `agent` by default — see
@@ -595,12 +610,40 @@ export const MEMORY_ENTRY_MAX_BYTES = 4096;
595
610
  * several cap-size entries still fit (see `MEMORY_ENTRY_MAX_BYTES`).
596
611
  */
597
612
  export const MEMORY_READ_MAX_BYTES = 16384;
613
+ /**
614
+ * The recall tool's name, defined here because the prompt's topic index
615
+ * tells the agent what to call — a block advertising a tool by a name the
616
+ * registry does not use would be a dead pointer. `@stratusagent/agents`
617
+ * builds the tool itself and re-exports this as `RECALL_TOOL_NAME`.
618
+ */
619
+ export const MEMORY_RECALL_TOOL_NAME = 'memory.recall';
598
620
  /** Results a `search` returns when the caller names no limit. */
599
621
  export const MEMORY_RECALL_DEFAULT_LIMIT = 10;
600
622
  /** Ceiling a `search` limit is clamped to, whatever the caller asked for. */
601
623
  export const MEMORY_RECALL_MAX_LIMIT = 50;
602
- /** How many recent entries the runner injects into the system prompt. */
603
- export const MEMORY_INJECTION_LIMIT = 20;
624
+ /**
625
+ * The pinned core's hard cap, in UTF-8 bytes of content. It **refuses
626
+ * rather than evicts**, on the same reasoning as the per-entry cap: a
627
+ * pinned set that silently drops its oldest member is a pin that does not
628
+ * mean anything.
629
+ */
630
+ export const MEMORY_PINNED_MAX_BYTES = 2048;
631
+ /**
632
+ * The topic index's budget, in UTF-8 bytes of rendered block. Roughly a
633
+ * thousand tokens: enough to tell the agent the shape of its own store,
634
+ * which is what turns `memory.recall` from a guess into a targeted read.
635
+ */
636
+ export const MEMORY_INDEX_MAX_BYTES = 4096;
637
+ /**
638
+ * The recency tail — deliberately smaller than the twenty entries that
639
+ * were the whole of injection before the pinned core and the topic index
640
+ * existed, so a cold store with nothing pinned and nothing indexed still
641
+ * behaves roughly as it did, and a warm one spends its budget on the two
642
+ * blocks that are correlated with what the agent knows.
643
+ */
644
+ export const MEMORY_RECENCY_INJECTION_LIMIT = 8;
645
+ /** Byte budget for the recency tail, applied like every other bounded read. */
646
+ export const MEMORY_RECENCY_MAX_BYTES = 4096;
604
647
  const utf8Encoder = new TextEncoder();
605
648
  /** UTF-8 size of an entry's content — the unit both memory caps are stated in. */
606
649
  export const memoryContentByteLength = (content) => utf8Encoder.encode(content).length;
@@ -611,6 +654,32 @@ export const assertMemoryContentWithinCap = (content) => {
611
654
  throw new Error(`Memory entries are capped at ${MEMORY_ENTRY_MAX_BYTES} UTF-8 bytes and this fact is ${bytes}. Nothing was stored — remember a shorter fact instead.`);
612
655
  }
613
656
  };
657
+ /**
658
+ * How many entities one fact may be filed under, and how much room their
659
+ * names get. `about` is model-written and would otherwise be the one
660
+ * unbounded field on an entry: the per-entry cap covers `content` alone, so
661
+ * without this a single `memory.remember` could write a megabyte of entity
662
+ * names into the record and into every store's search index. The prompt's
663
+ * topic block has its own budget and would survive it; the record would
664
+ * not, and the record is the thing that has to stay hand-editable.
665
+ *
666
+ * Small on purpose. A fact about a dozen things is a fact that has not been
667
+ * written down properly yet.
668
+ */
669
+ export const MEMORY_ABOUT_MAX_KEYS = 12;
670
+ export const MEMORY_ABOUT_MAX_BYTES = 512;
671
+ /** Refuses, like the content cap, and for the same reason: a half-filed fact is worse than a refused one. */
672
+ export const assertMemoryAboutWithinCap = (about) => {
673
+ if (about.length > MEMORY_ABOUT_MAX_KEYS) {
674
+ throw new Error(`A memory entry may name at most ${MEMORY_ABOUT_MAX_KEYS} entities in "about" and this one names ${about.length}. `
675
+ + 'Nothing was stored — file it under the few that matter, or remember it as more than one fact.');
676
+ }
677
+ const bytes = about.reduce((sum, key) => sum + memoryContentByteLength(key), 0);
678
+ if (bytes > MEMORY_ABOUT_MAX_BYTES) {
679
+ throw new Error(`The "about" keys of a memory entry are capped at ${MEMORY_ABOUT_MAX_BYTES} UTF-8 bytes and these are ${bytes}. `
680
+ + 'Nothing was stored — an entity name is a name, not a description.');
681
+ }
682
+ };
614
683
  /**
615
684
  * The tokenizer both store implementations share: NFC-normalized,
616
685
  * case-folded, split on anything that is not a Unicode letter or digit.
@@ -620,18 +689,124 @@ export const assertMemoryContentWithinCap = (content) => {
620
689
  */
621
690
  export const tokenizeMemoryText = (text) => text.normalize('NFC').toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
622
691
  /**
623
- * Whether content matches a tokenized query: every query token must be
624
- * present as a whole token. A query that tokenized to nothing matches
625
- * nothing — recall with no searchable terms is a question that was not
626
- * asked, not a request for everything.
692
+ * An entry's `about` keys, cleaned the one way every consumer reads them:
693
+ * trimmed, empty ones dropped, duplicates collapsed case-insensitively
694
+ * with the first spelling kept. A hand-edited line whose `about` is not an
695
+ * array of strings contributes nothing rather than throwing — the record
696
+ * is the operator's to edit, and a bad key is not worth losing a fact over.
627
697
  */
628
- export const memoryQueryMatches = (content, queryTokens) => {
698
+ export const memoryEntryAbout = (entry) => {
699
+ if (!Array.isArray(entry.about)) {
700
+ return [];
701
+ }
702
+ const seen = new Set();
703
+ const keys = [];
704
+ for (const raw of entry.about) {
705
+ if (typeof raw !== 'string') {
706
+ continue;
707
+ }
708
+ const key = raw.trim();
709
+ // Folded the way the tokenizer normalizes, so two spellings of one
710
+ // entity are one key here as they are one token to search.
711
+ const folded = key.normalize('NFC').toLowerCase();
712
+ if (key.length === 0 || seen.has(folded)) {
713
+ continue;
714
+ }
715
+ seen.add(folded);
716
+ keys.push(key);
717
+ }
718
+ return keys;
719
+ };
720
+ /**
721
+ * Everything about an entry a query is matched against: its content and its
722
+ * `about` keys, through the shared tokenizer.
723
+ *
724
+ * `about` participating is the point rather than an edge. An entry reading
725
+ * "it now uses Postgres" with `about: ["deploy pipeline"]` is listed under
726
+ * that topic in the prompt's index, and a store matching content alone
727
+ * would then miss the very query the listing invites — an alias in `about`
728
+ * against a pronoun in `content` is the case the field exists to serve.
729
+ * Written once here so the FTS `tokens` column and the in-memory filter
730
+ * cannot disagree about what a store holds.
731
+ */
732
+ export const memoryEntryTokens = (entry) => [
733
+ ...tokenizeMemoryText(entry.content),
734
+ ...memoryEntryAbout(entry).flatMap(tokenizeMemoryText),
735
+ ];
736
+ /**
737
+ * Whether an entry matches a tokenized query: every query token must be
738
+ * present as a whole token of its content or of one of its `about` keys. A
739
+ * query that tokenized to nothing matches nothing — recall with no
740
+ * searchable terms is a question that was not asked, not a request for
741
+ * everything.
742
+ */
743
+ export const memoryQueryMatches = (entry, queryTokens) => {
629
744
  if (queryTokens.length === 0) {
630
745
  return false;
631
746
  }
632
- const tokens = new Set(tokenizeMemoryText(content));
747
+ const tokens = new Set(memoryEntryTokens(entry));
633
748
  return queryTokens.every((token) => tokens.has(token));
634
749
  };
750
+ // A bound that is not a parseable instant is no bound — a hand-edited line
751
+ // reading `validUntil: "soon"` must not quietly retire the fact it is on.
752
+ const validityInstant = (value) => {
753
+ if (typeof value !== 'string') {
754
+ return undefined;
755
+ }
756
+ const parsed = Date.parse(value);
757
+ return Number.isNaN(parsed) ? undefined : parsed;
758
+ };
759
+ /**
760
+ * One rule, both bounds: a fact holds from `validFrom` (inclusive) until
761
+ * `validUntil` (exclusive). Pure in the entry and the clock, so both store
762
+ * implementations compute the same answer from the record alone.
763
+ */
764
+ export const memoryValidityAt = (entry, at) => {
765
+ const now = at.getTime();
766
+ const from = validityInstant(entry.validFrom);
767
+ if (from !== undefined && now < from) {
768
+ return 'not-yet-valid';
769
+ }
770
+ const until = validityInstant(entry.validUntil);
771
+ if (until !== undefined && now >= until) {
772
+ return 'expired';
773
+ }
774
+ return 'current';
775
+ };
776
+ /** Shorthand for the only status that is true now. */
777
+ export const isMemoryEntryCurrent = (entry, at) => memoryValidityAt(entry, at) === 'current';
778
+ /**
779
+ * The ids retired by supersession at a given instant, over one agent's
780
+ * un-tombstoned entries.
781
+ *
782
+ * An entry is retired exactly while a successor of it is *current*, which
783
+ * is the only reading that keeps supersession and validity from cancelling
784
+ * each other: a successor dated from next Monday retiring its predecessor
785
+ * today would leave neither fact available as true now — the old one
786
+ * retired, the new one excluded as not-yet-valid — so the agent forgets
787
+ * something it still believes and gains nothing for it.
788
+ *
789
+ * Two successors of the same entry both retire it, in either order, and
790
+ * both stay live. The invariant is the *retirement*, not a unique
791
+ * replacement: each successor is a fact the agent wrote, and retiring one
792
+ * to make the other unique would delete a write nobody asked to retract.
793
+ * Two live successors that disagree are a contradiction in content, which
794
+ * is the maintenance pass's job and not a storage race.
795
+ *
796
+ * Callers pass the agent's un-tombstoned entries, so forgetting a successor
797
+ * releases the entry it retired — a revision the agent took back leaves the
798
+ * fact it replaced standing, which is the recoverable direction in a store
799
+ * whose whole posture is that nothing is lost.
800
+ */
801
+ export const supersededMemoryIdsAt = (entries, at) => {
802
+ const retired = new Set();
803
+ for (const entry of entries) {
804
+ if (typeof entry.supersedes === 'string' && entry.supersedes.length > 0 && isMemoryEntryCurrent(entry, at)) {
805
+ retired.add(entry.supersedes);
806
+ }
807
+ }
808
+ return retired;
809
+ };
635
810
  /**
636
811
  * Recall order — newest first, ties on `createdAt` broken by entry id
637
812
  * ascending. The tie-break is not about frequency: without one, two
@@ -652,6 +827,8 @@ export const compareMemoryChronology = (a, b) => {
652
827
  }
653
828
  return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
654
829
  };
830
+ export const MEMORY_RANKING_STRATEGIES = ['recency', 'relevance', 'hybrid'];
831
+ export const isMemoryRankingStrategy = (value) => typeof value === 'string' && MEMORY_RANKING_STRATEGIES.includes(value);
655
832
  /** Clamp a caller-chosen search limit into the store's own bounds. */
656
833
  export const clampMemoryRecallLimit = (limit) => {
657
834
  if (limit === undefined || Number.isNaN(limit)) {
@@ -702,59 +879,395 @@ export const boundMemoryRead = (candidates, limit, maxBytes = MEMORY_READ_MAX_BY
702
879
  * presentation order are different things; conflating them is the bug this
703
880
  * helper exists so nobody writes three times.
704
881
  */
705
- export const boundMemoryList = (candidates, limit) => {
706
- const bounded = boundMemoryRead(candidates, Math.max(1, Math.floor(limit)));
882
+ export const boundMemoryList = (candidates, limit, maxBytes = MEMORY_READ_MAX_BYTES) => {
883
+ const bounded = boundMemoryRead(candidates, Math.max(1, Math.floor(limit)), maxBytes);
707
884
  bounded.entries.sort(compareMemoryChronology);
708
885
  return bounded;
709
886
  };
887
+ /**
888
+ * The pinned set the record produces, and the rule both stores replay it
889
+ * by: **pins take effect in the order their records appear in the file,
890
+ * until the cap, and the remainder are recorded but inert.**
891
+ *
892
+ * The write path refusing an over-cap pin is the common case and stays the
893
+ * rule. This is for the race it cannot see: the CLI and the daemon build
894
+ * separate stores over the same file and coordinate by `O_APPEND` alone, so
895
+ * two processes pinning near the limit can both read the same total, both
896
+ * accept, and both append. Replay gives them one effective set instead of
897
+ * an injected slice that either overruns its budget or drops a pin by
898
+ * whichever order it happened to read.
899
+ *
900
+ * **Append order, not `(createdAt, id)`.** A timestamp is the wrong key for
901
+ * a budget: a pin written later with a skewed-earlier clock would sort
902
+ * ahead of a pin that is already effective and consume the budget out from
903
+ * under it, making an accepted pin inert after the fact and contradicting
904
+ * the refusal the cap promises. `O_APPEND` already supplies a total order
905
+ * no later writer can insert itself into. Recall ordering keeps its
906
+ * `(createdAt, id)` rule — presenting facts newest-first is about meaning,
907
+ * allocating a scarce budget is about arrival.
908
+ *
909
+ * Validity is deliberately *not* consulted here. The budget is a property
910
+ * of the record, so it must not change when a clock ticks past a
911
+ * `validUntil`; an out-of-window pin holds its place and is dropped from
912
+ * the rendered core by `selectMemoryInjection`, which is where "true now"
913
+ * is decided.
914
+ */
915
+ export const applyMemoryPinBudget = (pinnedIds, contentBytesFor, maxBytes = MEMORY_PINNED_MAX_BYTES) => {
916
+ const effective = [];
917
+ const inert = [];
918
+ let bytes = 0;
919
+ for (const id of pinnedIds) {
920
+ const size = contentBytesFor(id);
921
+ if (size === undefined) {
922
+ // The entry is gone from the live set — forgotten, or never there.
923
+ // Its pin record stands in the file and costs nothing.
924
+ continue;
925
+ }
926
+ if (inert.length > 0 || bytes + size > maxBytes) {
927
+ // Once one pin overruns, everything behind it is inert too: "the
928
+ // remainder" is a prefix rule, so an effective set never depends on
929
+ // how large the *next* pin happens to be.
930
+ inert.push(id);
931
+ continue;
932
+ }
933
+ effective.push(id);
934
+ bytes += size;
935
+ }
936
+ return { effective, inert, bytes };
937
+ };
938
+ /**
939
+ * The topic index, built from `about` keys over the entries a store holds:
940
+ * not facts, but what the agent *knows about*. Counts and last-updated,
941
+ * ordered by count descending then name, so the block is stable across
942
+ * turns and the biggest topics survive the budget.
943
+ *
944
+ * Shared rather than per-store for the usual reason: two implementations
945
+ * building this differently would advertise different stores over the same
946
+ * record.
947
+ */
948
+ export const collectMemoryTopics = (entries) => {
949
+ const topics = new Map();
950
+ for (const entry of entries) {
951
+ const trust = memoryEntryTrust(entry);
952
+ for (const key of memoryEntryAbout(entry)) {
953
+ // NFC before folding, the way `tokenizeMemoryText` normalizes: a
954
+ // composed and a decomposed `café` are one topic to search, and two
955
+ // topics here would render as duplicates with their counts split and
956
+ // spend the block's budget twice on the same thing.
957
+ const folded = key.normalize('NFC').toLowerCase();
958
+ const existing = topics.get(folded);
959
+ if (existing === undefined) {
960
+ topics.set(folded, { name: key, count: 1, lastUpdatedAt: entry.createdAt, trust });
961
+ continue;
962
+ }
963
+ existing.count += 1;
964
+ if (entry.createdAt > existing.lastUpdatedAt) {
965
+ existing.lastUpdatedAt = entry.createdAt;
966
+ }
967
+ existing.trust = leastTrusted(existing.trust, trust);
968
+ }
969
+ }
970
+ return [...topics.values()].sort((a, b) => b.count !== a.count ? b.count - a.count : a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
971
+ };
972
+ /** Empty in all three blocks — what a host with no memory store injects. */
973
+ export const emptyMemoryInjection = () => ({ pinned: [], topics: [], recent: [] });
974
+ // The blank line between the intro and a region, and between regions.
975
+ const TOPIC_BLOCK_SEPARATOR_BYTES = 2;
976
+ /**
977
+ * Bound the three blocks against their budgets and keep them disjoint: a
978
+ * pinned fact already in context is not worth a second copy in the tail,
979
+ * and the tail is the block that shrinks, never the pinned core. The
980
+ * pinned set is never pushed out by volume — that is the whole reason it
981
+ * is a separate budget rather than a ranking bonus.
982
+ */
983
+ export const selectMemoryInjection = (input) => {
984
+ const pinned = boundMemoryRead([...input.pinned], input.pinned.length, MEMORY_PINNED_MAX_BYTES)
985
+ .entries.sort(compareMemoryChronology);
986
+ const pinnedIds = new Set(pinned.map((entry) => entry.id));
987
+ const recent = boundMemoryList(input.recent.filter((entry) => !pinnedIds.has(entry.id)), MEMORY_RECENCY_INJECTION_LIMIT, MEMORY_RECENCY_MAX_BYTES).entries;
988
+ const topics = [];
989
+ // Measured as the block is *rendered*, not as the topics are stored:
990
+ // every control character expands six-for-one on the way out, and the
991
+ // intro and the per-trust region headings are part of what the prompt
992
+ // pays for. The scaffolding is reserved up front from the labels present
993
+ // in the whole candidate list, which can only over-reserve — selecting
994
+ // fewer topics never adds a region.
995
+ let scaffoldBytes = memoryContentByteLength(MEMORY_INDEX_INTRO) + TOPIC_BLOCK_SEPARATOR_BYTES;
996
+ for (const trust of new Set(input.topics.map((topic) => topic.trust))) {
997
+ scaffoldBytes += memoryContentByteLength(memoryRegionHeading(trust)) + TOPIC_BLOCK_SEPARATOR_BYTES;
998
+ }
999
+ let topicBytes = scaffoldBytes;
1000
+ for (const topic of input.topics) {
1001
+ const size = memoryContentByteLength(renderMemoryTopicLine(topic)) + 1;
1002
+ // A line no selection could ever admit — an `about` key long enough to
1003
+ // fill the block beside the scaffolding on its own, which import and a
1004
+ // hand-edited record both allow — is skipped rather than allowed to end
1005
+ // the scan, the same way `boundMemoryRead` skips an entry over its
1006
+ // budget. Breaking on it would cost the whole topic index over one
1007
+ // oversized name, and the index is what turns `memory.recall` from a
1008
+ // guess into a targeted read.
1009
+ if (scaffoldBytes + size > MEMORY_INDEX_MAX_BYTES) {
1010
+ continue;
1011
+ }
1012
+ // Merely running out of room does end it, also like `boundMemoryRead`:
1013
+ // the block stays the top of the ranking rather than whatever happened
1014
+ // to pack into the remainder.
1015
+ if (topicBytes + size > MEMORY_INDEX_MAX_BYTES) {
1016
+ break;
1017
+ }
1018
+ topics.push(topic);
1019
+ topicBytes += size;
1020
+ }
1021
+ return { pinned, topics, recent };
1022
+ };
1023
+ /**
1024
+ * Read the three blocks out of a store. The policy — what counts as
1025
+ * current, what the budgets are, which block a fact lands in — lives in
1026
+ * `selectMemoryInjection` and the store's own `list`, so a store that
1027
+ * implements none of the optional methods still injects a recency tail and
1028
+ * nothing is special-cased at the call site.
1029
+ */
1030
+ export const buildMemoryInjection = async (store, agentId) => {
1031
+ const [pinned, topics, recent] = await Promise.all([
1032
+ store.pinned ? store.pinned(agentId) : Promise.resolve([]),
1033
+ store.topics ? store.topics(agentId) : Promise.resolve([]),
1034
+ store.list(agentId, { limit: MEMORY_RECENCY_INJECTION_LIMIT }).then((result) => result.entries),
1035
+ ]);
1036
+ return selectMemoryInjection({ pinned, topics, recent });
1037
+ };
1038
+ /**
1039
+ * The three blocks as a request may carry them: a bare array is the recency
1040
+ * tail alone, which is what `memory` meant before pinning and the topic
1041
+ * index existed and is still what a host building a request by hand may
1042
+ * pass.
1043
+ */
1044
+ export const asMemoryInjection = (memory) => {
1045
+ if (memory === undefined) {
1046
+ return emptyMemoryInjection();
1047
+ }
1048
+ return Array.isArray(memory)
1049
+ ? { pinned: [], topics: [], recent: [...memory] }
1050
+ : memory;
1051
+ };
1052
+ /** Every entry a turn injects, in the order they reach the prompt — what taints the session. */
1053
+ export const memoryInjectionEntries = (injection) => {
1054
+ const blocks = asMemoryInjection(injection);
1055
+ return [...blocks.pinned, ...blocks.recent];
1056
+ };
1057
+ /**
1058
+ * The refusal an over-cap pin says, naming the cap. An operator who cannot
1059
+ * see why a pin bounced will assume it worked, which is the failure the
1060
+ * refuse-never-evict rule exists to avoid in the first place.
1061
+ */
1062
+ export const pinnedCapRefusal = (heldBytes, wantedBytes) => `The pinned core is capped at ${MEMORY_PINNED_MAX_BYTES} UTF-8 bytes and holds ${heldBytes}; `
1063
+ + `this entry needs ${wantedBytes}. Nothing was pinned and nothing was dropped — unpin something first.`;
1064
+ /**
1065
+ * The other refusal, and the one a byte total cannot express: an inert pin
1066
+ * already stands in the record.
1067
+ *
1068
+ * `applyMemoryPinBudget` is a **prefix** rule, so once one pin overruns the
1069
+ * cap nothing recorded after it takes effect, whatever its size. A write
1070
+ * path that tested only the effective byte total would therefore accept a
1071
+ * small pin, append it, and report success for something `pinned` omits on
1072
+ * the very next read — an eviction wearing the refusal's clothes, which is
1073
+ * the one thing the cap promises cannot happen. The blocking id is named
1074
+ * because it is the only thing the operator can act on: the effective set
1075
+ * is under the cap and unpinning from *it* frees nothing.
1076
+ */
1077
+ export const pinnedInertRefusal = (blockingId) => `The pinned core is a prefix of the pins in the order they were made, and the pin on ${blockingId} `
1078
+ + `overran the ${MEMORY_PINNED_MAX_BYTES}-byte cap — nothing recorded after it takes effect. `
1079
+ + `Nothing was pinned: unpin ${blockingId} first.`;
1080
+ /**
1081
+ * The write-path half of the per-agent boundary, shared by every store: an
1082
+ * id a new record names must resolve to a live entry the caller owns
1083
+ * *before* anything is appended.
1084
+ *
1085
+ * This is a security requirement rather than input hygiene. A store's
1086
+ * forgotten set is computed from the record without an agent filter, so a
1087
+ * writer that skipped this would let agent A retire agent B's memory by
1088
+ * naming its id — gone from B's `list`, its `search`, and its prompt, with
1089
+ * B's own `forget` never called.
1090
+ */
1091
+ export const assertSupersedableMemoryEntry = (live, entryId) => {
1092
+ if (!live.some((entry) => entry.id === entryId)) {
1093
+ throw new Error(`No live memory entry with id ${entryId} belongs to this agent, so nothing was superseded and nothing was stored. `
1094
+ + 'memory.recall shows the ids you can supersede.');
1095
+ }
1096
+ };
1097
+ /**
1098
+ * An entry's optional fields as they are safe to read back from a record
1099
+ * nobody validated — a hand-edited line, an imported file, a newer build's
1100
+ * output. A field of the wrong shape is dropped rather than thrown over:
1101
+ * the record is the operator's to edit, and a bad `about` is not worth
1102
+ * losing a fact for. Written once so the file store's parser and the import
1103
+ * path cannot disagree about what a line means.
1104
+ */
1105
+ export const memoryEntryFields = (value) => {
1106
+ const raw = value;
1107
+ const about = memoryEntryAbout(value);
1108
+ return {
1109
+ ...(isMemoryEntryKind(raw.kind) ? { kind: raw.kind } : {}),
1110
+ ...(about.length > 0 ? { about } : {}),
1111
+ ...(typeof raw.validFrom === 'string' ? { validFrom: raw.validFrom } : {}),
1112
+ ...(typeof raw.validUntil === 'string' ? { validUntil: raw.validUntil } : {}),
1113
+ ...(typeof raw.supersedes === 'string' && raw.supersedes.length > 0 ? { supersedes: raw.supersedes } : {}),
1114
+ };
1115
+ };
1116
+ /**
1117
+ * One entry as an import should store it: re-keyed to the importing agent,
1118
+ * optional fields validated, and `usage` dropped — usage is an observation
1119
+ * about reading this install did, never something a file carries in.
1120
+ */
1121
+ export const importableMemoryEntry = (entry, agentId) => ({
1122
+ id: entry.id,
1123
+ agentId,
1124
+ content: entry.content,
1125
+ createdAt: entry.createdAt,
1126
+ ...memoryEntryFields(entry),
1127
+ ...(entry.metadata ? { metadata: entry.metadata } : {}),
1128
+ ...(isTrustLevel(entry.trust) ? { trust: entry.trust } : {}),
1129
+ ...(entry.origin ? { origin: entry.origin } : {}),
1130
+ });
710
1131
  export class InMemoryAgentMemoryStore {
711
1132
  entries = new Map();
1133
+ /**
1134
+ * The pin lane, per agent, in append order — the same total order the
1135
+ * file store gets from `O_APPEND`, and for the same reason: a budget is
1136
+ * allocated by arrival, never by timestamp. A pin already held is not
1137
+ * re-appended, so re-pinning cannot move an entry to the back.
1138
+ */
1139
+ pins = new Map();
1140
+ /**
1141
+ * Keyed by (agent, entry), never by entry alone: `importEntries`
1142
+ * preserves ids while re-keying entries, so two agents legitimately hold
1143
+ * the same id and one agent's reads must not move the other's counters.
1144
+ */
1145
+ usage = new Map();
1146
+ /**
1147
+ * Every id this store holds, minted or imported. `append` skips past the
1148
+ * ones already taken, because an import that preserved `a:memory:1` and
1149
+ * left the counter at zero would mint that id again — and two entries
1150
+ * sharing an id are indistinguishable to tombstones, supersession, pins,
1151
+ * and usage alike.
1152
+ */
1153
+ takenIds = new Set();
712
1154
  counter = 0;
713
1155
  now;
714
1156
  // `now` is a test seam: the ordering tie-break only shows itself when two
715
- // entries share a createdAt, which a real clock makes non-deterministic.
1157
+ // entries share a createdAt, which a real clock makes non-deterministic —
1158
+ // and so does anything asserting a validity window against a fixed date.
716
1159
  constructor(options = {}) {
717
1160
  this.now = options.now ?? (() => new Date());
718
1161
  }
719
- async append(agentId, content, metadata, provenance) {
1162
+ async append(agentId, content, options = {}) {
720
1163
  assertMemoryContentWithinCap(content);
721
- this.counter += 1;
1164
+ // Resolved against this agent's own live set before anything is
1165
+ // recorded: the per-agent boundary is the access model the whole store
1166
+ // rests on, and a successor naming a stranger's id would retire it.
1167
+ if (options.supersedes !== undefined) {
1168
+ assertSupersedableMemoryEntry(this.live(agentId, this.now()), options.supersedes);
1169
+ }
1170
+ const about = memoryEntryAbout(options);
1171
+ assertMemoryAboutWithinCap(about);
1172
+ let id;
1173
+ do {
1174
+ this.counter += 1;
1175
+ id = `${agentId}:memory:${this.counter}`;
1176
+ } while (this.takenIds.has(id));
1177
+ this.takenIds.add(id);
722
1178
  const entry = {
723
- id: `${agentId}:memory:${this.counter}`,
1179
+ id,
724
1180
  agentId,
725
1181
  content,
726
1182
  createdAt: this.now().toISOString(),
727
- ...(metadata ? { metadata } : {}),
728
- ...(provenance ? { trust: provenance.trust } : {}),
729
- ...(provenance?.origin ? { origin: provenance.origin } : {}),
1183
+ ...(options.kind !== undefined ? { kind: options.kind } : {}),
1184
+ ...(about.length > 0 ? { about } : {}),
1185
+ ...(options.validFrom !== undefined ? { validFrom: options.validFrom } : {}),
1186
+ ...(options.validUntil !== undefined ? { validUntil: options.validUntil } : {}),
1187
+ ...(options.supersedes !== undefined ? { supersedes: options.supersedes } : {}),
1188
+ ...(options.metadata ? { metadata: options.metadata } : {}),
1189
+ ...(options.provenance ? { trust: options.provenance.trust } : {}),
1190
+ ...(options.provenance?.origin ? { origin: options.provenance.origin } : {}),
730
1191
  };
731
1192
  const existing = this.entries.get(agentId) ?? [];
732
1193
  existing.push(entry);
733
1194
  this.entries.set(agentId, existing);
734
1195
  return { ...entry };
735
1196
  }
736
- live(agentId) {
737
- return (this.entries.get(agentId) ?? [])
1197
+ /** Not tombstoned and not superseded by a successor that is current at `at`. */
1198
+ live(agentId, at) {
1199
+ const kept = (this.entries.get(agentId) ?? [])
738
1200
  .filter((entry) => entry.forgottenAt === undefined)
739
1201
  .map(({ forgottenAt: _unused, ...entry }) => entry);
1202
+ const superseded = supersededMemoryIdsAt(kept, at);
1203
+ return kept.filter((entry) => !superseded.has(entry.id));
740
1204
  }
741
1205
  async list(agentId, options = {}) {
742
- const live = this.live(agentId);
1206
+ const at = this.now();
1207
+ const live = options.validity === 'all'
1208
+ ? this.live(agentId, at)
1209
+ : this.live(agentId, at).filter((entry) => isMemoryEntryCurrent(entry, at));
743
1210
  if (options.limit === undefined) {
744
1211
  return { entries: live.sort(compareMemoryChronology), truncated: false };
745
1212
  }
746
1213
  return boundMemoryList(live, options.limit);
747
1214
  }
748
- async search(agentId, query, limit) {
1215
+ async search(agentId, query, options = {}) {
749
1216
  const tokens = tokenizeMemoryText(query);
1217
+ // `recency` is the only ordering this store implements; a caller asking
1218
+ // for another gets it, and is told so, rather than an error.
1219
+ const strategy = 'recency';
750
1220
  if (tokens.length === 0) {
751
- return { entries: [], truncated: false };
1221
+ return { entries: [], truncated: false, strategy };
1222
+ }
1223
+ const matches = this.live(agentId, this.now()).filter((entry) => memoryQueryMatches(entry, tokens));
1224
+ const bounded = boundMemoryRead(matches, clampMemoryRecallLimit(options.limit));
1225
+ // Counted, then reported, for the entries actually returned — usage is
1226
+ // meant to say what reached a prompt, and only a `search` carries it:
1227
+ // `list` reads the record, where these counters deliberately are not.
1228
+ const counted = this.noteRecalled(agentId, bounded.entries);
1229
+ return {
1230
+ entries: bounded.entries.map((entry) => {
1231
+ const usage = counted.get(entry.id);
1232
+ return usage === undefined ? entry : { ...entry, usage };
1233
+ }),
1234
+ truncated: bounded.truncated,
1235
+ strategy,
1236
+ };
1237
+ }
1238
+ // Usage counters are an observation about reading, not a fact the agent
1239
+ // learned: they live beside the record here the way they live in the file
1240
+ // store's index, and nothing ranks or deletes on them yet.
1241
+ usageKey(agentId, entryId) {
1242
+ return `${agentId}\u0000${entryId}`;
1243
+ }
1244
+ noteRecalled(agentId, entries) {
1245
+ const at = this.now().toISOString();
1246
+ const counted = new Map();
1247
+ for (const entry of entries) {
1248
+ const key = this.usageKey(agentId, entry.id);
1249
+ const previous = this.usage.get(key);
1250
+ const usage = { recallCount: (previous?.recallCount ?? 0) + 1, lastRecalledAt: at };
1251
+ this.usage.set(key, usage);
1252
+ counted.set(entry.id, usage);
1253
+ }
1254
+ return counted;
1255
+ }
1256
+ /**
1257
+ * The entry a mutation may touch: live, which means un-tombstoned *and*
1258
+ * not superseded. Resolving against the raw array instead would let an
1259
+ * agent forget a predecessor a current successor retired — and forgetting
1260
+ * that successor is documented to release it, which a tombstone on the
1261
+ * predecessor would then silently prevent.
1262
+ */
1263
+ mutable(agentId, entryId) {
1264
+ if (!this.live(agentId, this.now()).some((entry) => entry.id === entryId)) {
1265
+ return undefined;
752
1266
  }
753
- const matches = this.live(agentId).filter((entry) => memoryQueryMatches(entry.content, tokens));
754
- return boundMemoryRead(matches, clampMemoryRecallLimit(limit));
1267
+ return (this.entries.get(agentId) ?? []).find((candidate) => candidate.id === entryId && candidate.forgottenAt === undefined);
755
1268
  }
756
1269
  async forget(agentId, entryId) {
757
- const entry = (this.entries.get(agentId) ?? []).find((candidate) => candidate.id === entryId && candidate.forgottenAt === undefined);
1270
+ const entry = this.mutable(agentId, entryId);
758
1271
  if (!entry) {
759
1272
  return false;
760
1273
  }
@@ -765,13 +1278,89 @@ export class InMemoryAgentMemoryStore {
765
1278
  return (this.entries.get(agentId) ?? []).map((entry) => ({ ...entry })).sort(compareMemoryChronology);
766
1279
  }
767
1280
  async reassertTrust(agentId, entryId, trust) {
768
- const entry = (this.entries.get(agentId) ?? []).find((candidate) => candidate.id === entryId && candidate.forgottenAt === undefined);
1281
+ const entry = this.mutable(agentId, entryId);
769
1282
  if (!entry) {
770
1283
  return false;
771
1284
  }
772
1285
  entry.trust = trust;
773
1286
  return true;
774
1287
  }
1288
+ /**
1289
+ * Allocated over un-tombstoned entries, never the live view: the budget
1290
+ * is a property of the record and must not move when a successor's
1291
+ * window opens or a clock ticks. Sizing it from the live set would free a
1292
+ * superseded pin's bytes, admit a later pin into the space, and make that
1293
+ * later pin inert the moment the successor was forgotten.
1294
+ */
1295
+ pinBudget(agentId) {
1296
+ const allocated = new Map((this.entries.get(agentId) ?? [])
1297
+ .filter((entry) => entry.forgottenAt === undefined)
1298
+ .map(({ forgottenAt: _unused, ...entry }) => [entry.id, entry]));
1299
+ const budget = applyMemoryPinBudget(this.pins.get(agentId) ?? [], (id) => (allocated.has(id) ? memoryContentByteLength(allocated.get(id).content) : undefined));
1300
+ return { effective: budget.effective, inert: budget.inert, bytes: budget.bytes, allocated };
1301
+ }
1302
+ async pin(agentId, entryId) {
1303
+ const { effective, inert, bytes, allocated } = this.pinBudget(agentId);
1304
+ // Pinnable means live; the budget above is a different question.
1305
+ const entry = this.live(agentId, this.now()).find((candidate) => candidate.id === entryId);
1306
+ if (!entry || !allocated.has(entryId)) {
1307
+ throw new Error(`No live memory entry with id ${entryId} belongs to this agent — nothing was pinned.`);
1308
+ }
1309
+ if (effective.includes(entryId)) {
1310
+ return { pinned: true, bytes };
1311
+ }
1312
+ if (inert[0] !== undefined) {
1313
+ return { pinned: false, reason: pinnedInertRefusal(inert[0]), bytes };
1314
+ }
1315
+ const size = memoryContentByteLength(entry.content);
1316
+ if (bytes + size > MEMORY_PINNED_MAX_BYTES) {
1317
+ return { pinned: false, reason: pinnedCapRefusal(bytes, size), bytes };
1318
+ }
1319
+ this.pins.set(agentId, [...(this.pins.get(agentId) ?? []), entryId]);
1320
+ return { pinned: true, bytes: bytes + size };
1321
+ }
1322
+ async unpin(agentId, entryId) {
1323
+ const pins = this.pins.get(agentId) ?? [];
1324
+ if (!pins.includes(entryId)) {
1325
+ return false;
1326
+ }
1327
+ this.pins.set(agentId, pins.filter((id) => id !== entryId));
1328
+ return true;
1329
+ }
1330
+ async pinned(agentId, options = {}) {
1331
+ const at = this.now();
1332
+ const { effective, allocated } = this.pinBudget(agentId);
1333
+ const pins = effective.map((id) => allocated.get(id)).filter((entry) => entry !== undefined);
1334
+ if (options.include === 'allocated') {
1335
+ return pins.sort(compareMemoryChronology);
1336
+ }
1337
+ const live = new Set(this.live(agentId, at).map((entry) => entry.id));
1338
+ return pins
1339
+ .filter((entry) => live.has(entry.id) && isMemoryEntryCurrent(entry, at))
1340
+ .sort(compareMemoryChronology);
1341
+ }
1342
+ async topics(agentId) {
1343
+ const at = this.now();
1344
+ return collectMemoryTopics(this.live(agentId, at).filter((entry) => isMemoryEntryCurrent(entry, at)));
1345
+ }
1346
+ async importEntries(agentId, entries) {
1347
+ const existing = this.entries.get(agentId) ?? [];
1348
+ const held = new Set(existing.map((entry) => entry.id));
1349
+ const skipped = [];
1350
+ let imported = 0;
1351
+ for (const entry of entries) {
1352
+ if (held.has(entry.id)) {
1353
+ skipped.push(entry.id);
1354
+ continue;
1355
+ }
1356
+ held.add(entry.id);
1357
+ this.takenIds.add(entry.id);
1358
+ existing.push({ ...importableMemoryEntry(entry, agentId) });
1359
+ imported += 1;
1360
+ }
1361
+ this.entries.set(agentId, existing);
1362
+ return { imported, skipped };
1363
+ }
775
1364
  }
776
1365
  /**
777
1366
  * The allowlist half of every `CredentialResolver`, so a second
@@ -860,6 +1449,16 @@ export const totalTokenUsage = (records) => {
860
1449
  }
861
1450
  return reported ? total : undefined;
862
1451
  };
1452
+ /**
1453
+ * The transcript a provider should send: the request's window when it has
1454
+ * one, the whole session otherwise.
1455
+ *
1456
+ * One implementation because there is one rule, and because the wrong
1457
+ * answer is silent — an adapter reading `session.messages` directly still
1458
+ * works, it just ignores the floor and sends the request that was already
1459
+ * refused.
1460
+ */
1461
+ export const transcriptOf = (request) => request.messages ?? request.session.messages;
863
1462
  /**
864
1463
  * What a scrubbed subprocess inherits by name when its operator granted
865
1464
  * nothing else — the replacement-environment treatment `tool-shell`
@@ -978,6 +1577,36 @@ export const promptWasDelivered = (error) => typeof error === 'object' && error
978
1577
  * followed more often than one read an hour ago.
979
1578
  */
980
1579
  export const UNADDRESSED_TURN_NOTE = 'Nobody said that to you, and nobody is waiting on you. Reply only if you have something these people would want from you and do not have — an answer to a question left open, a correction to something wrong, a thing you were asked to watch for. Otherwise reply with nothing at all: no acknowledgement, no summary, no offer to help. Saying nothing is usually the right answer here.';
1580
+ /**
1581
+ * What the last turn of a message is told, once the message has used every
1582
+ * tool turn `maxTurns` allows it.
1583
+ *
1584
+ * The alternative is what this replaced: the turn failed with "exceeded the
1585
+ * maximum of 8 provider turns", every step it had taken sat in the
1586
+ * transcript unsummarized, and the person got an error for work that had
1587
+ * mostly succeeded. A limit on how far one message may run is a check-in
1588
+ * point, not a failure, so the agent says where it got to and the next
1589
+ * message carries on — the session keeps everything the turns did.
1590
+ *
1591
+ * Sent as the newest message of the request's window, never saved: it is
1592
+ * the runtime speaking for one call, and a transcript that kept it would
1593
+ * read to every later turn as something the person said.
1594
+ */
1595
+ export const TURN_LIMIT_NOTE = 'You have used every step this message allows, so you cannot call any more tools on it. Reply now, in words: say what you did, what you found, and what is left. If the work is unfinished, end by saying that replying "continue" picks it up from here.';
1596
+ /** The wrap-up note as a message, for the request's window only. */
1597
+ const turnLimitNoteFor = (session) => ({
1598
+ id: `${session.id}:turn-limit`,
1599
+ role: 'user',
1600
+ content: TURN_LIMIT_NOTE,
1601
+ createdAt: new Date().toISOString(),
1602
+ });
1603
+ /**
1604
+ * What a turn that could not wrap up fails with. Names the setting because
1605
+ * the person reading it is the one who can raise it, and "exceeded the
1606
+ * maximum" alone left them to find it in the docs.
1607
+ */
1608
+ const turnLimitMessage = (maxTurns) => `Session exceeded the maximum of ${maxTurns} provider turns. `
1609
+ + 'Raise maxTurns in ~/.stratus/config.json for agents that do long multi-step work, or reply to carry on from here.';
981
1610
  /** Reads the checkpoint off a session, if it is parked. */
982
1611
  export const readPendingApproval = (session) => {
983
1612
  const raw = session.metadata?.[PENDING_APPROVAL_METADATA_KEY];
@@ -1052,6 +1681,174 @@ export class InMemorySessionStore {
1052
1681
  .map((session) => session.id);
1053
1682
  }
1054
1683
  }
1684
+ // ---- contribution kinds a plugin registers besides tools ------------------
1685
+ /**
1686
+ * The providers the host builds itself. Here, in the dependency-free
1687
+ * package, because two packages that never import each other need the one
1688
+ * list: `@stratusagent/state` resolves a selection against it, and
1689
+ * `@stratusagent/plugins` refuses a manifest that declares one of these as
1690
+ * a provider of its own — `provider: anthropic` in a soul has to keep
1691
+ * meaning the first-party adapter, and a plugin that could shadow it would
1692
+ * be a plugin that could receive every key the operator stored for it.
1693
+ */
1694
+ export const BUILTIN_PROVIDER_NAMES = ['demo', 'openai', 'anthropic', 'codex'];
1695
+ /**
1696
+ * The names a trusted config's `executor` and `memoryStore` keys mean the
1697
+ * built-ins by — the local child-process executor and the file store —
1698
+ * and so the names a plugin may not register: the host's selection
1699
+ * special-cases them and would never look a contribution up under them.
1700
+ * Here for the same reason the provider list is: the manifest parser and
1701
+ * the selection logic live in packages that never import each other.
1702
+ */
1703
+ export const BUILTIN_EXECUTOR_NAME = 'local';
1704
+ export const BUILTIN_MEMORY_STORE_NAME = 'file';
1705
+ /**
1706
+ * A memory store contributed by a plugin. Every `AgentMemoryStore` method
1707
+ * takes the agent id, and that is what makes a contributed store
1708
+ * per-agent-resolvable rather than process-global: a plugin that closed
1709
+ * over one agent at setup would hand every agent the first agent's
1710
+ * memories, the way a `tool-fs` that cached its roots would hand every
1711
+ * agent the first agent's files.
1712
+ */
1713
+ /**
1714
+ * The revision of `AgentMemoryStore` a contributed store is built against.
1715
+ *
1716
+ * Bumped when the contract changes shape in a way a plugin compiled against
1717
+ * the old one cannot satisfy — which it *silently* cannot, because the
1718
+ * change that matters here is the shape of an argument rather than the
1719
+ * number of them. A v1 store's `search(agentId, query, limit)` still
1720
+ * accepts three arguments and still returns entries when the host hands it
1721
+ * an options object; it just binds `{ limit: 10 }` where a number belonged,
1722
+ * and answers with an unbounded read or none at all. No runtime check can
1723
+ * tell the two apart from the function, so the plugin declares it.
1724
+ *
1725
+ * - **1** — `append(agentId, content, metadata?, provenance?)` and
1726
+ * `search(agentId, query, limit?)`, the shape before 29.
1727
+ * - **2** — `append(agentId, content, options?)` and
1728
+ * `search(agentId, query, options?)`, plus the optional `pin`, `unpin`,
1729
+ * `pinned`, `topics`, and `importEntries`.
1730
+ */
1731
+ export const MEMORY_STORE_CONTRACT_VERSION = 2;
1732
+ /**
1733
+ * A name-keyed registry for one contribution kind — the counterpart of
1734
+ * `ToolRegistry` for providers, memory stores, and executors. Like it, a
1735
+ * bare `Map.set`: it keeps no provenance and refuses nothing, and a plugin
1736
+ * never touches one directly. The manifest-bound view in
1737
+ * `@stratusagent/plugins` is what refuses an undeclared name or a
1738
+ * collision; this is where a whole plugin's contributions land once it has
1739
+ * loaded.
1740
+ */
1741
+ export class ContributionRegistry {
1742
+ entries = new Map();
1743
+ register(name, value) {
1744
+ this.entries.set(name, value);
1745
+ }
1746
+ get(name) {
1747
+ return this.entries.get(name);
1748
+ }
1749
+ has(name) {
1750
+ return this.entries.has(name);
1751
+ }
1752
+ names() {
1753
+ return [...this.entries.keys()];
1754
+ }
1755
+ list() {
1756
+ return [...this.entries.values()];
1757
+ }
1758
+ }
1759
+ /** The (agent, kind) key a channel registration claims. */
1760
+ export const channelClaimKey = (kind, agentId) => `${kind}:${agentId}`;
1761
+ /**
1762
+ * The registry for contributed channels. Keyed by (agent, kind) rather than
1763
+ * by name, because that is what collides — see `ChannelContribution` — and
1764
+ * listed as adapters, because that is what a host starts. Same discipline
1765
+ * as the other registries: no refusal here, the view refuses.
1766
+ */
1767
+ export class ChannelRegistry {
1768
+ contributions = [];
1769
+ /** Undefined marks a claim the host made for an adapter it wired itself. */
1770
+ claims = new Map();
1771
+ register(contribution) {
1772
+ this.contributions.push(contribution);
1773
+ for (const agentId of contribution.agents) {
1774
+ this.claims.set(channelClaimKey(contribution.adapter.name, agentId), contribution);
1775
+ }
1776
+ }
1777
+ /**
1778
+ * Claim (agent, kind) pairs the host carries itself, so a plugin
1779
+ * registering the same kind for the same agent collides with the host's
1780
+ * adapter exactly as it would with another plugin's — two adapters on
1781
+ * one agent's Slack would each answer every message.
1782
+ */
1783
+ claim(kind, agentIds) {
1784
+ for (const agentId of agentIds) {
1785
+ if (!this.claims.has(channelClaimKey(kind, agentId))) {
1786
+ this.claims.set(channelClaimKey(kind, agentId), undefined);
1787
+ }
1788
+ }
1789
+ }
1790
+ /** Whether some adapter — the host's or a plugin's — already carries this agent on this kind. */
1791
+ claimed(kind, agentId) {
1792
+ return this.claims.has(channelClaimKey(kind, agentId));
1793
+ }
1794
+ list() {
1795
+ return [...this.contributions];
1796
+ }
1797
+ }
1798
+ /**
1799
+ * A memory store that resolves which store serves an agent **per call**.
1800
+ *
1801
+ * The seam a host selects a contributed store through. The runner and the
1802
+ * memory tools are built before any plugin has loaded, so the store they
1803
+ * hold has to be one that can change its mind — and an agent-by-agent
1804
+ * answer is what keeps a contributed store from being process-global.
1805
+ * Every optional method is forwarded only when the chosen store has it: a
1806
+ * store that omitted one cannot do that thing, and this must not claim
1807
+ * otherwise. The routed store declares them all, because which store
1808
+ * serves an agent is not known until the call — so the honest answer for a
1809
+ * store that lacks one is the same answer the seam's own default gives
1810
+ * (nothing pinned, no topics, no re-assertion).
1811
+ */
1812
+ export const createRoutedMemoryStore = (storeFor) => ({
1813
+ append: (agentId, content, options) => storeFor(agentId).append(agentId, content, options),
1814
+ list: (agentId, options) => storeFor(agentId).list(agentId, options),
1815
+ search: (agentId, query, options) => storeFor(agentId).search(agentId, query, options),
1816
+ forget: (agentId, entryId) => storeFor(agentId).forget(agentId, entryId),
1817
+ audit: (agentId) => storeFor(agentId).audit(agentId),
1818
+ async reassertTrust(agentId, entryId, trust) {
1819
+ const store = storeFor(agentId);
1820
+ if (!store.reassertTrust) {
1821
+ return false;
1822
+ }
1823
+ return store.reassertTrust(agentId, entryId, trust);
1824
+ },
1825
+ async pin(agentId, entryId) {
1826
+ const store = storeFor(agentId);
1827
+ if (!store.pin) {
1828
+ return { pinned: false, reason: 'This memory store does not support pinning.', bytes: 0 };
1829
+ }
1830
+ return store.pin(agentId, entryId);
1831
+ },
1832
+ async unpin(agentId, entryId) {
1833
+ const store = storeFor(agentId);
1834
+ return store.unpin ? store.unpin(agentId, entryId) : false;
1835
+ },
1836
+ async pinned(agentId, options) {
1837
+ const store = storeFor(agentId);
1838
+ return store.pinned ? store.pinned(agentId, options) : [];
1839
+ },
1840
+ async topics(agentId) {
1841
+ const store = storeFor(agentId);
1842
+ return store.topics ? store.topics(agentId) : [];
1843
+ },
1844
+ async importEntries(agentId, entries) {
1845
+ const store = storeFor(agentId);
1846
+ if (!store.importEntries) {
1847
+ throw new Error('This memory store cannot import entries.');
1848
+ }
1849
+ return store.importEntries(agentId, entries);
1850
+ },
1851
+ });
1055
1852
  export class PluginRegistry {
1056
1853
  plugins = [];
1057
1854
  register(plugin) {
@@ -1356,26 +2153,26 @@ export const memoryRegionHeading = (trust) => {
1356
2153
  }
1357
2154
  };
1358
2155
  /**
1359
- * The memory block, one region per trust label present, most trusted
1360
- * first, entries in the order given within each.
1361
- *
1362
- * The invariant this enforces: nothing in the prompt derived from an entry
1363
- * renders under a higher label than that entry carries. Stated over the
1364
- * whole ordering rather than over `external` alone, because `unknown`
1365
- * stopped being only a legacy artifact the moment an unauthorized sender's
1366
- * message started producing it. An entry with no label renders in the
1367
- * `unknown` region — never as the agent's own conclusion.
2156
+ * Unicode's `Bidi_Control` property as a character-class fragment: the
2157
+ * marks, the Arabic letter mark, the embeddings and overrides, and the
2158
+ * isolates. Exported so every escaper spells out the same set — text that
2159
+ * reads one way to a person and another to the model is the same trick
2160
+ * whether it arrives in a memory entry, a Slack name, or a tool description.
1368
2161
  */
2162
+ export const BIDI_CONTROL_CHARACTERS = '\\u061c\\u200e\\u200f\\u202a-\\u202e\\u2066-\\u2069';
2163
+ const CONTROL_CHARACTERS = new RegExp(`[\\u0000-\\u001f\\u007f-\\u009f\\u2028\\u2029${BIDI_CONTROL_CHARACTERS}]`, 'g');
1369
2164
  /**
1370
2165
  * `text` on one line, every control character spelled out the way JSON
1371
- * does (`\n`, `\u001b`), C1 controls and the Unicode line separators
1372
- * included. The memory block and `stratus memory list` both frame an entry
1373
- * by its line — `- ` under a region heading, an indent under an id — and a
1374
- * fact holding a newline could otherwise start a line of its own that reads
1375
- * as a heading, the trusted region's heading among them. Escaped rather
1376
- * than dropped, so what is shown is still what is stored.
1377
- */
1378
- export const escapeControlCharacters = (text) => text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, (character) => {
2166
+ * does (`\n`, `\u001b`), C1 controls, the Unicode line separators and
2167
+ * the bidi controls included. The memory block and `stratus memory list`
2168
+ * both frame an entry by its line — `- ` under a region heading, an indent
2169
+ * under an id — and a fact holding a newline could otherwise start a line
2170
+ * of its own that reads as a heading, the trusted region's heading among
2171
+ * them; a right-to-left override would make the line read differently to
2172
+ * a person than to the model. Escaped rather than dropped, so what is
2173
+ * shown is still what is stored.
2174
+ */
2175
+ export const escapeControlCharacters = (text) => text.replace(CONTROL_CHARACTERS, (character) => {
1379
2176
  switch (character) {
1380
2177
  case '\n': return '\\n';
1381
2178
  case '\r': return '\\r';
@@ -1383,24 +2180,82 @@ export const escapeControlCharacters = (text) => text.replace(/[\u0000-\u001f\u0
1383
2180
  default: return `\\u${character.codePointAt(0).toString(16).padStart(4, '0')}`;
1384
2181
  }
1385
2182
  });
1386
- export const renderMemorySection = (memory) => {
1387
- if (!memory || memory.length === 0) {
1388
- return undefined;
1389
- }
2183
+ /**
2184
+ * A run of lines grouped one region per trust label present, most trusted
2185
+ * first, in the order given within each.
2186
+ *
2187
+ * The invariant this enforces: nothing in the prompt derived from an entry
2188
+ * renders under a higher label than that entry carries. Stated over the
2189
+ * whole ordering rather than over `external` alone, because `unknown`
2190
+ * stopped being only a legacy artifact the moment an unauthorized sender's
2191
+ * message started producing it. An entry with no label renders in the
2192
+ * `unknown` region — never as the agent's own conclusion. It applies to the
2193
+ * topic index as much as to facts: an entity name an `external` entry
2194
+ * supplied is still a stranger's words.
2195
+ */
2196
+ const renderTrustRegions = (items, trustOf, lineOf) => {
1390
2197
  const regions = [];
1391
2198
  for (const trust of TRUST_LEVELS) {
1392
- const entries = memory.filter((entry) => memoryEntryTrust(entry) === trust);
1393
- if (entries.length === 0) {
2199
+ const matching = items.filter((item) => trustOf(item) === trust);
2200
+ if (matching.length === 0) {
1394
2201
  continue;
1395
2202
  }
1396
- // One line per fact, so the region a fact sits under is the region it
2203
+ // One line per item, so the region a fact sits under is the region it
1397
2204
  // was filed under: a page's text that ends in a newline and a copy of
1398
2205
  // the operator's heading would otherwise open a forged trusted region
1399
2206
  // inside the external one.
1400
- const facts = entries.map((entry) => `- ${escapeControlCharacters(entry.content)}`).join('\n');
1401
- regions.push(`${memoryRegionHeading(trust)}\n${facts}`);
2207
+ regions.push(`${memoryRegionHeading(trust)}\n${matching.map(lineOf).join('\n')}`);
2208
+ }
2209
+ return regions;
2210
+ };
2211
+ /** What the pinned core and the topic index say they are, before their regions. */
2212
+ const MEMORY_PINNED_INTRO = 'Kept in front of you on purpose — the facts you or your operator pinned:';
2213
+ const MEMORY_INDEX_INTRO = `What your long-term memory holds, by topic — a count and when each last changed, not the facts themselves. Use ${MEMORY_RECALL_TOOL_NAME} with a topic name to read them:`;
2214
+ const MEMORY_RECENT_INTRO = 'Recently remembered:';
2215
+ // Both the name and the date are escaped, because both come off an entry
2216
+ // and a hand-edited `createdAt` is as capable of carrying a newline as a
2217
+ // fact is — a topic line that opened a line of its own could forge the
2218
+ // heading of a more trusted region, which is the whole reason this block
2219
+ // renders one item per line.
2220
+ const renderMemoryTopicLine = (topic) => `- ${escapeControlCharacters(topic.name)} (${topic.count} fact${topic.count === 1 ? '' : 's'},`
2221
+ + ` last ${escapeControlCharacters(topic.lastUpdatedAt.slice(0, 10))})`;
2222
+ /**
2223
+ * The memory block: the pinned core, the topic index, and the recency tail,
2224
+ * each grouped into trust regions, joined into **one** section.
2225
+ *
2226
+ * One section rather than three is a requirement rather than a formatting
2227
+ * preference. An adapter that places the volatile section separately finds
2228
+ * it with `parts.find((part) => part.kind === 'memory')` and strips it with
2229
+ * the matching filter, so three sections sharing the kind would send the
2230
+ * first to the tail and drop the other two from the request entirely —
2231
+ * and three new kinds would leave them on the cached prefix, which is
2232
+ * exactly what memory was moved off.
2233
+ *
2234
+ * A bare array is accepted and read as the recency tail alone: that is what
2235
+ * `memory` meant before the three blocks existed, and a provider test or a
2236
+ * host building a request by hand should not have to know about pinning.
2237
+ */
2238
+ export const renderMemorySection = (memory) => {
2239
+ if (memory === undefined) {
2240
+ return undefined;
1402
2241
  }
1403
- return regions.join('\n\n');
2242
+ const injection = asMemoryInjection(memory);
2243
+ const blocks = [];
2244
+ const fact = (entry) => `- ${escapeControlCharacters(entry.content)}`;
2245
+ const push = (intro, regions) => {
2246
+ if (regions.length > 0) {
2247
+ blocks.push(`${intro}\n\n${regions.join('\n\n')}`);
2248
+ }
2249
+ };
2250
+ push(MEMORY_PINNED_INTRO, renderTrustRegions(injection.pinned, memoryEntryTrust, fact));
2251
+ push(MEMORY_INDEX_INTRO, renderTrustRegions(injection.topics, (topic) => topic.trust, renderMemoryTopicLine));
2252
+ // The tail keeps the bare heading it has always had when it is the only
2253
+ // block, so a cold store's prompt reads exactly as it did before.
2254
+ if (injection.recent.length > 0) {
2255
+ const regions = renderTrustRegions(injection.recent, memoryEntryTrust, fact);
2256
+ blocks.push(blocks.length === 0 ? regions.join('\n\n') : `${MEMORY_RECENT_INTRO}\n\n${regions.join('\n\n')}`);
2257
+ }
2258
+ return blocks.length > 0 ? blocks.join('\n\n') : undefined;
1404
2259
  };
1405
2260
  /**
1406
2261
  * The skills block: one line per enabled skill — id, name, and the
@@ -1606,6 +2461,163 @@ const refuseUnaddressedImages = (input) => {
1606
2461
  throw new Error('A turn nobody asked for cannot carry images: an image enters the prompt ahead of the frame that marks the message as somebody else\'s. Name the attachment in the message instead.');
1607
2462
  }
1608
2463
  };
2464
+ /**
2465
+ * Thrown by a provider when the request did not fit the model's context
2466
+ * window — the transcript, not the answer, is what was too long.
2467
+ *
2468
+ * A distinct type because it is the one provider failure the kernel can do
2469
+ * something about. Everything else a provider throws is final; this one
2470
+ * says "the same request will never work, a shorter one might", and the
2471
+ * runner answers by sending less history (see `raiseContextFloor`).
2472
+ *
2473
+ * Providers opt in. Recognising it means matching a vendor's own wording
2474
+ * for a 400, which only the adapter can do — and a harness provider that
2475
+ * manages its own context (`provider-claude-code`, `provider-codex`) has
2476
+ * nothing to report, because the transcript it sends is not ours to
2477
+ * shorten. A provider that never throws this behaves exactly as it did.
2478
+ */
2479
+ export class ContextOverflowError extends Error {
2480
+ constructor(message) {
2481
+ super(message);
2482
+ this.name = 'ContextOverflowError';
2483
+ }
2484
+ }
2485
+ /**
2486
+ * How many of a session's oldest messages are no longer sent to the
2487
+ * provider.
2488
+ *
2489
+ * Durable, and **monotonic** like the trust label: a transcript only grows,
2490
+ * so a floor that has had to rise once would have to rise again on the next
2491
+ * turn, and re-discovering that costs a rejected request every time. Raised
2492
+ * only by `raiseContextFloor`, never lowered — a conversation does not get
2493
+ * shorter.
2494
+ *
2495
+ * It bounds what is *sent*, never what is stored. The transcript is the
2496
+ * record of what happened and stays whole on disk; this is a window onto
2497
+ * its tail. `stratus session rollover` is still the way to actually leave a
2498
+ * conversation behind.
2499
+ */
2500
+ export const CONTEXT_FLOOR_METADATA_KEY = 'contextFloor';
2501
+ /** The stored floor, or 0 for a session that has never overflowed. */
2502
+ export const contextFloorOf = (session) => {
2503
+ const stored = session.metadata?.[CONTEXT_FLOOR_METADATA_KEY];
2504
+ return typeof stored === 'number' && Number.isInteger(stored) && stored > 0 ? stored : 0;
2505
+ };
2506
+ /**
2507
+ * The note that stands in for the messages a floor cut away, prefixed to
2508
+ * the oldest message the model still sees.
2509
+ *
2510
+ * Said rather than done silently, for the reason every other cut in this
2511
+ * codebase is announced: a model that cannot tell a conversation that
2512
+ * started here from one that was trimmed will answer as though the earlier
2513
+ * turns never happened, and confidently.
2514
+ */
2515
+ export const droppedTurnsNote = (count) => `[${count} earlier message${count === 1 ? '' : 's'} in this conversation ${count === 1 ? 'is' : 'are'} not shown: `
2516
+ + 'it grew past what fits in one request. Say so if you are asked about something from before this point, '
2517
+ + 'rather than answering as though the conversation started here.]';
2518
+ /**
2519
+ * The index of the first message at or after `from` that a request may
2520
+ * begin with, or undefined when there is none.
2521
+ *
2522
+ * A window has to start on a `user` message: the wire format requires it,
2523
+ * and more importantly a `tool` result whose `tool_use` was cut away is a
2524
+ * request every provider rejects — which would turn one overflow into a
2525
+ * permanent failure of a different kind. The runner only ever writes a
2526
+ * user message at a turn boundary (`observe` refuses to splice one into a
2527
+ * turn in flight, exactly so this holds), so a user message is always a
2528
+ * safe cut and no pair is ever split.
2529
+ */
2530
+ const nextTurnBoundary = (messages, from) => {
2531
+ for (let index = Math.max(0, from); index < messages.length; index += 1) {
2532
+ if (messages[index]?.role === 'user') {
2533
+ return index;
2534
+ }
2535
+ }
2536
+ return undefined;
2537
+ };
2538
+ /**
2539
+ * The index of the LAST message at or after `from` that a request may begin
2540
+ * with, or undefined when there is none — `nextTurnBoundary`'s counterpart,
2541
+ * for narrowing as far as the transcript allows rather than to a midpoint
2542
+ * that may have no boundary after it.
2543
+ */
2544
+ const lastTurnBoundary = (messages, from) => {
2545
+ for (let index = messages.length - 1; index >= Math.max(0, from); index -= 1) {
2546
+ if (messages[index]?.role === 'user') {
2547
+ return index;
2548
+ }
2549
+ }
2550
+ return undefined;
2551
+ };
2552
+ /**
2553
+ * The messages above `session`'s floor, with the
2554
+ * note in place of what was cut. The session itself is untouched — the
2555
+ * copy is shallow and deliberately shares `metadata`, because providers
2556
+ * keep replay state there (the Anthropic raw-turn cache) and a turn that
2557
+ * wrote into a copy would lose it.
2558
+ *
2559
+ * Returns the session unchanged when the floor is 0, so a conversation that
2560
+ * has never overflowed is not copied on every turn.
2561
+ */
2562
+ export const messagesWithinContextFloor = (session) => {
2563
+ const floor = contextFloorOf(session);
2564
+ if (floor <= 0 || floor >= session.messages.length) {
2565
+ return session.messages;
2566
+ }
2567
+ const start = nextTurnBoundary(session.messages, floor);
2568
+ if (start === undefined || start === 0) {
2569
+ return session.messages;
2570
+ }
2571
+ const kept = session.messages.slice(start);
2572
+ const first = kept[0];
2573
+ // Prefixed onto the oldest surviving message rather than added as one of
2574
+ // its own: an extra message would be a turn the transcript never had, and
2575
+ // every count derived from the messages — ids, `latestTurnReply` — reads
2576
+ // the stored list anyway.
2577
+ //
2578
+ // Messages rather than a shortened Session, and that is the whole point:
2579
+ // `request.session` is persistable — the harness providers hand it to
2580
+ // `executeHostedToolCall` and the fallback wrapper saves it — so a
2581
+ // session-shaped view would be written back over the real transcript and
2582
+ // lose everything below the floor for good.
2583
+ return [{ ...first, content: `${droppedTurnsNote(start)}\n\n${first.content}` }, ...kept.slice(1)];
2584
+ };
2585
+ /**
2586
+ * Raise the floor so the next attempt sends less, and report whether there
2587
+ * was anything left to give up.
2588
+ *
2589
+ * Halves the remaining window each time rather than trimming a fixed
2590
+ * amount: nothing here knows how many tokens a message is, and an overflow
2591
+ * says only "too big", not "too big by this much" — so the cheap thing is
2592
+ * to converge in a handful of attempts rather than to creep towards the
2593
+ * answer one turn at a time, paying a rejected request for each step.
2594
+ *
2595
+ * Returns false when the window is already down to the newest turn. That is
2596
+ * the floor's floor: a single turn that does not fit is a message larger
2597
+ * than the model can read, and no amount of dropping history fixes it.
2598
+ */
2599
+ export const raiseContextFloor = (session) => {
2600
+ const floor = contextFloorOf(session);
2601
+ const remaining = session.messages.length - floor;
2602
+ if (remaining <= 1) {
2603
+ return false;
2604
+ }
2605
+ const target = floor + Math.max(1, Math.floor(remaining / 2));
2606
+ // The midpoint first, then the newest boundary there is. They differ when
2607
+ // the turn in flight is more than half of what is left — many tool calls
2608
+ // and results under one user message — and then nothing sits at or after
2609
+ // the midpoint, while an earlier boundary above the floor still drops
2610
+ // real history. Reporting "the newest turn is too large" without having
2611
+ // tried the newest turn alone is the case this second look exists for.
2612
+ const start = nextTurnBoundary(session.messages, target) ?? lastTurnBoundary(session.messages, floor + 1);
2613
+ // Nothing above the floor: the tail is one turn and there is nothing
2614
+ // further to drop.
2615
+ if (start === undefined || start <= floor) {
2616
+ return false;
2617
+ }
2618
+ session.metadata = { ...(session.metadata ?? {}), [CONTEXT_FLOOR_METADATA_KEY]: start };
2619
+ return true;
2620
+ };
1609
2621
  /**
1610
2622
  * Thrown when a run is stopped by its abort signal. The session ends up
1611
2623
  * `failed` with this error's message as `lastError`, so an aborted turn is
@@ -1640,7 +2652,18 @@ const throwIfAborted = (signal) => {
1640
2652
  throw abortErrorFor(signal);
1641
2653
  }
1642
2654
  };
1643
- const DEFAULT_MAX_TURNS = 8;
2655
+ /**
2656
+ * Tool turns one message may take before it wraps up. The harness providers
2657
+ * spend the same number as their own inner budget, so they import it rather
2658
+ * than keep a copy.
2659
+ *
2660
+ * 8 until the wrap-up turn existed, and 8 was the whole budget for one Slack
2661
+ * message: an agent with a shell spent it on a handful of commands and
2662
+ * failed with nothing said. With a wrap-up turn the ceiling is where a long
2663
+ * task checks in rather than where it dies, and 40 is enough for real
2664
+ * multi-step work while still bounding what a runaway loop can spend.
2665
+ */
2666
+ export const DEFAULT_MAX_TURNS = 40;
1644
2667
  export class AgentRunner {
1645
2668
  bus;
1646
2669
  store;
@@ -2010,8 +3033,16 @@ export class AgentRunner {
2010
3033
  // permitted turn buy the whole allowance again.
2011
3034
  for (let turn = resumeFrom?.turn ?? 1;; turn += 1) {
2012
3035
  throwIfAborted(signal);
2013
- if (turn > this.maxTurns) {
2014
- throw new Error(`Session exceeded the maximum of ${this.maxTurns} provider turns.`);
3036
+ // One turn past the ceiling, and only one: the wrap-up, which may
3037
+ // not call a tool, so a turn that reaches `maxTurns + 2` is a
3038
+ // provider that ignored `toolChoice` and was already failed below.
3039
+ const wrappingUp = turn > this.maxTurns;
3040
+ // A recovered call is a tool turn too, so one parked past the
3041
+ // ceiling — `maxTurns` lowered while it waited — is refused as it
3042
+ // was before wrap-up existed, not run on the one call reserved for
3043
+ // saying what happened.
3044
+ if (turn > this.maxTurns + 1 || (wrappingUp && pendingEntry)) {
3045
+ throw new Error(turnLimitMessage(this.maxTurns));
2015
3046
  }
2016
3047
  if (pendingEntry) {
2017
3048
  // The whole recovered queue — the parked call first, then the
@@ -2030,20 +3061,25 @@ export class AgentRunner {
2030
3061
  await this.runToolCalls(session, recovered, signal, carriedParkedAt, turn);
2031
3062
  continue;
2032
3063
  }
2033
- // A bounded slice — the most recent entries, chronological — not the
2034
- // whole store. Everything older reaches the model through
2035
- // `memory.recall`; injecting it all is what gave the store a horizon
2036
- // measured in weeks.
3064
+ // Three bounded blocks, not the whole store and not a recency slice
3065
+ // either: the pinned core the agent keeps whatever the conversation
3066
+ // is, a topic index telling it what it knows about, and a short
3067
+ // tail. Everything else reaches the model through `memory.recall`,
3068
+ // which the index exists to aim.
2037
3069
  const memory = this.memory
2038
- ? (await this.memory.list(session.agent.id, { limit: MEMORY_INJECTION_LIMIT })).entries
2039
- : [];
3070
+ ? await buildMemoryInjection(this.memory, session.agent.id)
3071
+ : emptyMemoryInjection();
2040
3072
  // What enters the context taints the session, not only what a tool
2041
3073
  // returned: an `external` entry arriving here is a stranger's text
2042
3074
  // in the prompt, and without this the store launders its own
2043
3075
  // contents on the next restart — a fresh session reads the fact,
2044
- // restates it, remembers it, and the copy is `agent`.
2045
- if (memory.length > 0) {
2046
- await this.taint(session, leastTrusted(...memory.map(memoryEntryTrust)), 'memory');
3076
+ // restates it, remembers it, and the copy is `agent`. The topic
3077
+ // index counts too — an entity name an `external` entry supplied is
3078
+ // still a stranger's words reaching the prompt.
3079
+ const injected = memoryInjectionEntries(memory);
3080
+ const injectedTrust = leastTrusted(...injected.map(memoryEntryTrust), ...memory.topics.map((topic) => topic.trust));
3081
+ if (injected.length > 0 || memory.topics.length > 0) {
3082
+ await this.taint(session, injectedTrust, 'memory');
2047
3083
  }
2048
3084
  // Deltas re-emit on the bus through a serial chain that is drained
2049
3085
  // before the final provider.response goes out — a delta arriving
@@ -2058,32 +3094,110 @@ export class AgentRunner {
2058
3094
  // makes underneath it. Allocated before the call because the sink
2059
3095
  // fires during it.
2060
3096
  const turnId = this.nextTurnId(session);
2061
- // Sink-reported usage is exclusive for the call. An adapter that
2062
- // reports its internal attempts through the sink has already counted
2063
- // the last one, and reading the response's field as well would bill
2064
- // that attempt twice.
2065
- let sinkReported = false;
2066
- const onUsage = (usage) => {
2067
- sinkReported = true;
2068
- this.recordUsage(session, turnId, usage);
2069
- };
2070
- const response = await this.options.provider.generate({
2071
- session,
2072
- ...(tools.length > 0 ? { tools } : {}),
2073
- ...(memory.length > 0 ? { memory } : {}),
2074
- ...(enabledSkills.length > 0 ? { skills: enabledSkills } : {}),
2075
- ...(this.streaming ? { onDelta } : {}),
2076
- onUsage,
2077
- ...(signal ? { signal } : {}),
2078
- });
2079
- // Recorded before the abort check, deliberately: a turn cancelled
2080
- // between the response arriving and this loop noticing still spent
2081
- // those tokens, and the catch below saves the session.
2082
- if (!sinkReported && response.usage) {
2083
- this.recordUsage(session, turnId, response.usage);
3097
+ // Sending less history until it fits, rather than failing for good.
3098
+ //
3099
+ // A transcript that has outgrown the context window is the one
3100
+ // provider rejection that repeats: the session is durable, so the
3101
+ // next message replays the same too-long request and is refused
3102
+ // the same way, and the conversation is dead under an id its
3103
+ // channel still routes to. A Slack DM is one session for the life
3104
+ // of the install, so "start a new one" is not available to the
3105
+ // person typing into it.
3106
+ //
3107
+ // The floor is raised on the session and saved, so the next turn
3108
+ // begins where this one ended up instead of paying a rejected
3109
+ // request to rediscover it. `sessionWithinContextFloor` is what
3110
+ // actually shortens the request; the provider only says that it
3111
+ // did not fit.
3112
+ let response;
3113
+ for (;;) {
3114
+ const floored = messagesWithinContextFloor(session);
3115
+ // The note rides in the window only, so the session never holds
3116
+ // it; see `TURN_LIMIT_NOTE`.
3117
+ const window = wrappingUp ? [...floored, turnLimitNoteFor(session)] : floored;
3118
+ // Sink-reported usage is exclusive for the call: an adapter that
3119
+ // reports its internal attempts through the sink has already
3120
+ // counted the last one, and reading the response's field as well
3121
+ // would bill that attempt twice.
3122
+ //
3123
+ // Per ATTEMPT, not per turn, because the sink's exclusivity is
3124
+ // scoped to one `generate` (see `ProviderRequest.onUsage`) and a
3125
+ // retry below is another one. Shared across them, an overflowing
3126
+ // attempt that reported its billed tokens through the sink would
3127
+ // suppress the response field of the shorter attempt that then
3128
+ // succeeded — recording the call that failed and not the one that
3129
+ // answered. The turn id is deliberately not per attempt: one
3130
+ // Stratus turn, however many provider calls it took to fit.
3131
+ let sinkReported = false;
3132
+ const onUsage = (usage) => {
3133
+ sinkReported = true;
3134
+ this.recordUsage(session, turnId, usage);
3135
+ };
3136
+ try {
3137
+ response = await this.options.provider.generate({
3138
+ // The real session, always: providers persist it. The window
3139
+ // rides beside it.
3140
+ session,
3141
+ ...(window === floored && floored.length === session.messages.length ? {} : { messages: window }),
3142
+ ...(tools.length > 0 ? { tools } : {}),
3143
+ ...(wrappingUp && tools.length > 0 ? { toolChoice: 'none' } : {}),
3144
+ ...(injected.length > 0 || memory.topics.length > 0 ? { memory } : {}),
3145
+ ...(enabledSkills.length > 0 ? { skills: enabledSkills } : {}),
3146
+ ...(this.streaming ? { onDelta } : {}),
3147
+ onUsage,
3148
+ ...(signal ? { signal } : {}),
3149
+ });
3150
+ // Recorded before the abort check below, deliberately: a turn
3151
+ // cancelled between the response arriving and this loop
3152
+ // noticing still spent those tokens, and the catch that ends
3153
+ // the turn saves the session.
3154
+ if (!sinkReported && response.usage) {
3155
+ this.recordUsage(session, turnId, response.usage);
3156
+ }
3157
+ break;
3158
+ }
3159
+ catch (error) {
3160
+ // Not an overflow, or nothing left to drop: the turn fails, and
3161
+ // the message says which — a single turn too big for the model
3162
+ // is a different problem from a conversation that got long, and
3163
+ // the remedies do not overlap.
3164
+ if (!(error instanceof ContextOverflowError)) {
3165
+ throw error;
3166
+ }
3167
+ throwIfAborted(signal);
3168
+ const before = contextFloorOf(session);
3169
+ if (!raiseContextFloor(session)) {
3170
+ throw new Error(`${error.message} The newest turn on its own is already larger than this model can read, `
3171
+ + 'so dropping earlier messages cannot help: shorten the message, cut the tool output it '
3172
+ + 'carries, or move to a model with a bigger context window.');
3173
+ }
3174
+ // Durable before the retry, not after it. A daemon that dies
3175
+ // mid-recovery must not come back and replay the request that
3176
+ // was just refused; the floor is the one thing learned here and
3177
+ // it costs a rejected call to learn again.
3178
+ await this.store.save(session);
3179
+ // Whatever the abandoned attempt streamed has to be discarded,
3180
+ // or a renderer fuses the two answers — the same contract the
3181
+ // fallback wrapper's reset delta honours.
3182
+ if (this.streaming) {
3183
+ await onDelta({ type: 'reset', reason: 'retry' });
3184
+ }
3185
+ await this.bus.emit({
3186
+ type: 'session.context-trimmed',
3187
+ sessionId: session.id,
3188
+ droppedMessages: contextFloorOf(session) - before,
3189
+ floor: contextFloorOf(session),
3190
+ });
3191
+ }
2084
3192
  }
2085
3193
  throwIfAborted(signal);
2086
3194
  await deltaChain;
3195
+ // Checked before anything of the response is saved: a call made on
3196
+ // the turn that may not make one is never run, and recording it
3197
+ // would leave a call in the transcript with no result to answer it.
3198
+ if (wrappingUp && response.parts.some((part) => part.type === 'tool-call')) {
3199
+ throw new Error(turnLimitMessage(this.maxTurns));
3200
+ }
2087
3201
  // Record and SAVE the entire response — text and every tool call —
2088
3202
  // before anything else happens with it. Two consumers depend on
2089
3203
  // that order: provider replay state (the Anthropic raw-turn cache)