@stratusagent/core 0.13.1 → 0.13.3

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
@@ -425,6 +425,37 @@ const originForSession = (tool, session, input) => {
425
425
  const reported = tool.originFor?.(session, input);
426
426
  return reported === undefined ? undefined : originOf(reported);
427
427
  };
428
+ /** The longest a tool's `trustBeforeCall` may take before the call is judged as reading `external`. */
429
+ export const TRUST_BEFORE_CALL_TIMEOUT_MS = 2_000;
430
+ /**
431
+ * A tool's `trustBeforeCall` answer, bounded. A throw, a rejection, a label
432
+ * nobody recognises or no answer in time reads `external`: the call is then
433
+ * about to read something nobody can vouch for, and judging it as such is
434
+ * the safe direction. Nothing, for a tool that answers nothing.
435
+ */
436
+ const trustBeforeCallOf = async (tool, session) => {
437
+ let timer;
438
+ try {
439
+ const label = await Promise.race([
440
+ Promise.resolve().then(() => tool.trustBeforeCall?.(session)),
441
+ new Promise((_, reject) => {
442
+ // Not unref'd: cleared below either way, and an unref'd bound lets a
443
+ // process with nothing else running exit mid-turn.
444
+ timer = setTimeout(() => reject(new Error('trustBeforeCall timed out')), TRUST_BEFORE_CALL_TIMEOUT_MS);
445
+ }),
446
+ ]);
447
+ if (label === undefined) {
448
+ return undefined;
449
+ }
450
+ return isTrustLevel(label) ? label : 'external';
451
+ }
452
+ catch {
453
+ return 'external';
454
+ }
455
+ finally {
456
+ clearTimeout(timer);
457
+ }
458
+ };
428
459
  /**
429
460
  * Whether an agent's `tools:` allowlist permits a tool name.
430
461
  *
@@ -1895,6 +1926,15 @@ export const promptTextOf = (message, options = {}) => {
1895
1926
  const framed = `(overheard, not addressed to you)\n${quoted}`;
1896
1927
  return options.latest === true ? `${framed}\n\n${UNADDRESSED_TURN_NOTE}` : framed;
1897
1928
  };
1929
+ /**
1930
+ * What separates an overheard message's framed text from its images. The
1931
+ * contract every provider keeps for a message with `overheard` and
1932
+ * images: the text from `promptTextOf` first, then this, then the images,
1933
+ * so pixels never reach a model ahead of the frame that says nobody spoke
1934
+ * to it, and read as that message's attachments rather than as something
1935
+ * the agent was sent. Like the frame, it states the fact, not advice.
1936
+ */
1937
+ export const OVERHEARD_IMAGES_NOTE = '(attached to the overheard message above, not addressed to you)';
1898
1938
  /**
1899
1939
  * Whether the turn a session is on is one nobody asked for: its newest
1900
1940
  * user message is overheard, which only `addressed: false` produces at
@@ -2751,6 +2791,64 @@ const MEMORY_RECENT_INTRO = 'Recently remembered:';
2751
2791
  // renders one item per line.
2752
2792
  const renderMemoryTopicLine = (topic) => `- ${escapeControlCharacters(topic.name)} (${topic.count} fact${topic.count === 1 ? '' : 's'},`
2753
2793
  + ` last ${escapeControlCharacters(topic.lastUpdatedAt.slice(0, 10))})`;
2794
+ // A date alone is UTC midnight by specification, so it is unambiguous
2795
+ // wherever the daemon runs. A *time* without an offset is not: it reads as
2796
+ // the host's local zone, so the same requested bound would become a
2797
+ // different instant in Los Angeles than in UTC, and a fact would activate
2798
+ // or expire at a deployment-dependent hour. So the offset is required
2799
+ // whenever a time is given.
2800
+ const ISO_8601_INSTANT = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2}))?$/;
2801
+ /**
2802
+ * Whether the `YYYY-MM-DD` a bound opens with is a day that exists.
2803
+ *
2804
+ * `Date.parse` normalizes rather than refusing: `2026-02-30` parses fine and
2805
+ * becomes March 2nd, and `2026-02-29` becomes March 1st in a non-leap year.
2806
+ * The regex cannot see it either — both are ISO-*shaped*. A validity window
2807
+ * is the field where that matters most and shows least: the fact starts or
2808
+ * stops being true days from where the writer asked, and the record says
2809
+ * only what it was normalized to.
2810
+ *
2811
+ * Checked on the calendar components as written, never by comparing the
2812
+ * normalized instant back to the string — that was the first attempt at
2813
+ * this and it rejected `2026-04-01T23:00:00-05:00`, whose UTC date is
2814
+ * legitimately the 2nd. An offset is allowed to move the day; February 30th
2815
+ * is not a day.
2816
+ *
2817
+ * `setUTCFullYear` rather than `Date.UTC`, which maps a two-digit year into
2818
+ * the 1900s — the regex admits `0026-02-28`, and that is a real date.
2819
+ */
2820
+ const isRealCalendarDate = (raw) => {
2821
+ const [year, month, day] = raw.slice(0, 10).split('-').map(Number);
2822
+ const probe = new Date(0);
2823
+ probe.setUTCFullYear(year, month - 1, day);
2824
+ return probe.getUTCFullYear() === year && probe.getUTCMonth() === month - 1 && probe.getUTCDate() === day;
2825
+ };
2826
+ /**
2827
+ * Whether a stored time is an ISO-8601 instant that means one thing
2828
+ * everywhere: the shape above, a day that exists, and a value that parses.
2829
+ * The one reading of that rule, for `memory.remember`'s validity bounds and
2830
+ * for the times the memory block shows.
2831
+ */
2832
+ export const isIsoInstant = (raw) => ISO_8601_INSTANT.test(raw) && !Number.isNaN(Date.parse(raw)) && isRealCalendarDate(raw);
2833
+ /**
2834
+ * A stored time as `YYYY-MM-DD HH:MMZ`, or nothing when it isn't an
2835
+ * unambiguous instant (`isIsoInstant`): `Date` would turn `2026-02-30` into
2836
+ * March 2 and read an offset-less time in the host's zone. Rebuilt from the
2837
+ * parsed instant rather than sliced from the string, so a hand-edited
2838
+ * `createdAt` can only ever render as a time, never as text.
2839
+ */
2840
+ const utcMinute = (value) => {
2841
+ if (typeof value !== 'string' || !isIsoInstant(value)) {
2842
+ return undefined;
2843
+ }
2844
+ const iso = new Date(value).toISOString();
2845
+ // An offset can carry year 0000 or 9999 across a UTC year boundary, where
2846
+ // toISOString switches to the six-digit form (Codex P2 4349257583).
2847
+ if (!/^\d{4}-/.test(iso)) {
2848
+ return undefined;
2849
+ }
2850
+ return `${iso.slice(0, 10)} ${iso.slice(11, 16)}Z`;
2851
+ };
2754
2852
  /**
2755
2853
  * The memory block: the pinned core, the topic index, and the recency tail,
2756
2854
  * each grouped into trust regions, joined into **one** section.
@@ -2773,7 +2871,17 @@ export const renderMemorySection = (memory) => {
2773
2871
  }
2774
2872
  const injection = asMemoryInjection(memory);
2775
2873
  const blocks = [];
2776
- const fact = (entry) => `- ${escapeControlCharacters(entry.content)}`;
2874
+ // Each fact says when it was recorded, to the minute: a fact from three
2875
+ // days ago otherwise reads exactly like one from this turn, and live
2876
+ // state (a PR, a deploy) changes within a day. The model is shown
2877
+ // `createdAt` here because it never called recall for these. Stored
2878
+ // times only, never the clock: on the default Anthropic model this
2879
+ // section rides in the system block, which must change only when a
2880
+ // memory does, or every turn's thinking is dropped as a prefix mismatch.
2881
+ const fact = (entry) => {
2882
+ const at = utcMinute(entry.createdAt);
2883
+ return `- ${at ? `(${at}) ` : ''}${escapeControlCharacters(entry.content)}`;
2884
+ };
2777
2885
  const push = (intro, regions) => {
2778
2886
  if (regions.length > 0) {
2779
2887
  blocks.push(`${intro}\n\n${regions.join('\n\n')}`);
@@ -2805,6 +2913,86 @@ export const renderSkillsSection = (skills) => {
2805
2913
  });
2806
2914
  return `You have skills — procedures for doing particular tasks well. When one is relevant to the task at hand, load its full instructions with the ${SKILL_READ_TOOL_NAME} tool (pass the id) and follow them; the one-line descriptions here are pointers, not the procedures themselves:\n${lines.join('\n')}`;
2807
2915
  };
2916
+ /**
2917
+ * How hard a Claude model works on a turn, lowest first: the API's
2918
+ * `output_config.effort` and the Agent SDK's `effort` option. A soul's
2919
+ * `effort:` sets it per agent; unset sends nothing, which leaves each
2920
+ * model on its own default.
2921
+ */
2922
+ export const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
2923
+ export const isEffortLevel = (value) => typeof value === 'string' && EFFORT_LEVELS.includes(value);
2924
+ /**
2925
+ * The effort levels a Claude model takes, or `undefined` for a model this
2926
+ * table does not know (a proxy's name, a model newer than this release).
2927
+ * An empty list is a model known to take no effort at all.
2928
+ *
2929
+ * Kept to families the effort docs name. A model missing here is sent the
2930
+ * configured value as written and reported as unverified, never as applied.
2931
+ */
2932
+ /**
2933
+ * A Claude model id with its other established names folded away: a
2934
+ * Bedrock id (`anthropic.` behind any region or `global.` qualifier, a
2935
+ * `-v1:0` suffix) and a Vertex one (`@` and a date), lowercased. Every
2936
+ * check on a model family goes through this, so one that recognizes a
2937
+ * name and another that misses it cannot disagree about the same model.
2938
+ */
2939
+ export const claudeModelBase = (model) => model.trim().toLowerCase().replace(/^(?:[a-z0-9-]+\.)*anthropic\./, '').split('@')[0].replace(/-v\d+(?::\d+)?$/, '');
2940
+ /**
2941
+ * The highest effort a Claude model takes with thinking disabled, for the
2942
+ * models the effort docs name a ceiling for (Claude Opus 5 and Claude
2943
+ * Haiku 5.5: `high`; above it the API answers 400). `undefined` is no
2944
+ * known ceiling. Kept beside `effortLevelsFor` so both read one id.
2945
+ */
2946
+ export const disabledThinkingEffortCeiling = (model) => {
2947
+ const id = claudeModelBase(model);
2948
+ return /^claude-opus-5(-|$)/.test(id) || /^claude-haiku-5-5(-|$)/.test(id) ? 'high' : undefined;
2949
+ };
2950
+ export const effortLevelsFor = (model) => {
2951
+ // Missed, Opus 4.6 under its Bedrock name asked for xhigh would be sent
2952
+ // xhigh as unverified and refused.
2953
+ const id = claudeModelBase(model);
2954
+ if (/^claude-(opus|sonnet|fable|mythos)-5(-|$)/.test(id) || /^claude-haiku-5-5(-|$)/.test(id) || /^claude-opus-4-[78](-|$)/.test(id)) {
2955
+ return EFFORT_LEVELS;
2956
+ }
2957
+ // Mythos Preview takes max but not xhigh, like the 4.6 models.
2958
+ if (/^claude-(opus|sonnet)-4-6(-|$)/.test(id) || /^claude-mythos-preview(-|$)/.test(id)) {
2959
+ return ['low', 'medium', 'high', 'max'];
2960
+ }
2961
+ if (/^claude-opus-4-5(-|$)/.test(id)) {
2962
+ return ['low', 'medium', 'high'];
2963
+ }
2964
+ if (/^claude-(haiku-4-5|sonnet-4-5|(sonnet|opus)-4(-20\d{6})?$|opus-4-[01](-|$)|3)/.test(id)) {
2965
+ return [];
2966
+ }
2967
+ return undefined;
2968
+ };
2969
+ export const resolveEffort = (model, requested) => {
2970
+ const levels = effortLevelsFor(model);
2971
+ if (levels === undefined) {
2972
+ return { requested, effort: requested, status: 'unverified' };
2973
+ }
2974
+ if (levels.includes(requested)) {
2975
+ return { requested, effort: requested, status: 'applied' };
2976
+ }
2977
+ const below = EFFORT_LEVELS.slice(0, EFFORT_LEVELS.indexOf(requested)).filter((level) => levels.includes(level));
2978
+ const lowered = below.at(-1);
2979
+ return lowered === undefined
2980
+ ? { requested, status: 'unsupported' }
2981
+ : { requested, effort: lowered, status: 'lowered' };
2982
+ };
2983
+ /** One line for logs and diagnostics: `high`, `high (asked xhigh; claude-opus-4-6 has no xhigh)`. */
2984
+ export const describeEffort = (model, resolution) => {
2985
+ switch (resolution.status) {
2986
+ case 'applied':
2987
+ return resolution.requested;
2988
+ case 'lowered':
2989
+ return `${resolution.effort} (asked ${resolution.requested}; ${model} has no ${resolution.requested})`;
2990
+ case 'unsupported':
2991
+ return `not sent (asked ${resolution.requested}; ${model} takes no effort setting)`;
2992
+ case 'unverified':
2993
+ return `${resolution.requested} (sent; ${model} is not a model Stratus knows the effort levels of)`;
2994
+ }
2995
+ };
2808
2996
  /**
2809
2997
  * The language agents write in when nothing says otherwise: American
2810
2998
  * English. A soul's `language:` and the config file's `language` override
@@ -3226,16 +3414,6 @@ export class AllowAllApprovalPolicy {
3226
3414
  * written after read the same.
3227
3415
  */
3228
3416
  const userImages = (images) => images !== undefined && images.length > 0 ? { images } : {};
3229
- /**
3230
- * See `RunInput.addressed`: an image reaches the model as pixels, before
3231
- * and outside the text that frames what was said as somebody else's, so
3232
- * a turn nobody asked for takes none.
3233
- */
3234
- const refuseUnaddressedImages = (input) => {
3235
- if (input.addressed === false && input.images !== undefined && input.images.length > 0) {
3236
- 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.');
3237
- }
3238
- };
3239
3417
  /**
3240
3418
  * Thrown by a provider when the request did not fit the model's context
3241
3419
  * window — the transcript, not the answer, is what was too long.
@@ -3561,7 +3739,6 @@ export class AgentRunner {
3561
3739
  await this.plugins.loadAll({ bus: this.bus, tools: this.tools });
3562
3740
  }
3563
3741
  async run(input) {
3564
- refuseUnaddressedImages(input);
3565
3742
  // The sender's trust is the turn's, not the session's: it is read here
3566
3743
  // and kept out of the persisted metadata, where a later reader would
3567
3744
  // take the first sender's label for every turn that follows.
@@ -3648,7 +3825,6 @@ export class AgentRunner {
3648
3825
  }
3649
3826
  }
3650
3827
  async resume(input) {
3651
- refuseUnaddressedImages(input);
3652
3828
  const session = await this.store.get(input.sessionId);
3653
3829
  if (!session) {
3654
3830
  throw new Error(`Session not found: ${input.sessionId}`);
@@ -4575,6 +4751,19 @@ export class AgentRunner {
4575
4751
  if (problem) {
4576
4752
  return rejected(`Invalid input for ${call.toolName}: ${problem}. Fix the call and try again.`);
4577
4753
  }
4754
+ // A call the tool already knows it will refuse, answered here for the
4755
+ // same reason: nobody should be asked to approve it. A hook that
4756
+ // throws is no answer, and the call goes on to be judged as before.
4757
+ let refusal;
4758
+ try {
4759
+ refusal = await tool.refusalFor?.(call.input, session);
4760
+ }
4761
+ catch {
4762
+ refusal = undefined;
4763
+ }
4764
+ if (typeof refusal === 'string' && refusal.trim().length > 0) {
4765
+ return rejected(refusal.trim());
4766
+ }
4578
4767
  }
4579
4768
  // Only a call that can actually be held for a human is checkpointed:
4580
4769
  // a `safe` one is never asked about, and writing a record for it would
@@ -4617,17 +4806,52 @@ export class AgentRunner {
4617
4806
  * unaffected.
4618
4807
  */
4619
4808
  let originWhenJudged;
4809
+ // What the call may read, lowered into the session before the policy
4810
+ // reads the session's label, so the decision covers this call and not
4811
+ // only the next one. Asked again after an approval, because a human's
4812
+ // answer can take minutes and the label can drop in that time; one
4813
+ // re-decision, then a refusal, so a label that keeps moving cannot
4814
+ // hold the call in a loop of questions.
4815
+ const lowerBeforeCall = async () => {
4816
+ if (!tool.trustBeforeCall) {
4817
+ return undefined;
4818
+ }
4819
+ const label = await trustBeforeCallOf(tool, session);
4820
+ if (label !== undefined) {
4821
+ await this.taint(session, label, call.toolName);
4822
+ }
4823
+ return label;
4824
+ };
4620
4825
  let approved;
4826
+ let decidedTrust = sessionTrustOf(session);
4827
+ let movedTwice = false;
4621
4828
  try {
4622
- approved = await this.approvals.approve({
4623
- session,
4624
- call,
4625
- tool,
4626
- risk,
4627
- ...(signal ? { signal } : {}),
4628
- ...(options.parkedAt ? { parkedAt: options.parkedAt } : {}),
4629
- });
4630
- originWhenJudged = originForSession(tool, session, call.input);
4829
+ await lowerBeforeCall();
4830
+ const decide = async () => {
4831
+ decidedTrust = sessionTrustOf(session);
4832
+ const answer = await this.approvals.approve({
4833
+ session,
4834
+ call,
4835
+ tool,
4836
+ risk,
4837
+ ...(signal ? { signal } : {}),
4838
+ ...(options.parkedAt ? { parkedAt: options.parkedAt } : {}),
4839
+ });
4840
+ // With nothing awaited since the policy returned (see above).
4841
+ originWhenJudged = originForSession(tool, session, call.input);
4842
+ return answer;
4843
+ };
4844
+ approved = await decide();
4845
+ if (approved && tool.trustBeforeCall) {
4846
+ const dropped = async () => {
4847
+ const label = await lowerBeforeCall();
4848
+ return label !== undefined && leastTrusted(decidedTrust, label) !== decidedTrust;
4849
+ };
4850
+ if (await dropped()) {
4851
+ approved = await decide();
4852
+ movedTwice = approved && await dropped();
4853
+ }
4854
+ }
4631
4855
  }
4632
4856
  finally {
4633
4857
  // Cleared before anything executes and on every exit — an abort that
@@ -4637,6 +4861,18 @@ export class AgentRunner {
4637
4861
  await this.checkpointPendingApproval(session, undefined);
4638
4862
  }
4639
4863
  }
4864
+ if (movedTwice) {
4865
+ await this.bus.emit({ type: 'tool.denied', sessionId: session.id, call });
4866
+ return {
4867
+ callId: call.id,
4868
+ toolName: call.toolName,
4869
+ ok: false,
4870
+ output: null,
4871
+ error: `${call.toolName} did not run: what it may read was marked less trusted twice while it was being approved. `
4872
+ + 'Call again to have it judged as things stand now.',
4873
+ trust: 'agent',
4874
+ };
4875
+ }
4640
4876
  if (!approved) {
4641
4877
  await this.bus.emit({ type: 'tool.denied', sessionId: session.id, call });
4642
4878
  return {
@@ -4675,6 +4911,7 @@ export class AgentRunner {
4675
4911
  const collector = createImageCollector();
4676
4912
  const executed = await this.executor.execute(call, tool, session, {
4677
4913
  ...(signal ? { signal } : {}),
4914
+ decidedTrust,
4678
4915
  attachImage: collector.attach,
4679
4916
  });
4680
4917
  // Only a call that succeeded shows what it attached: a failure's error