@stratusagent/core 0.13.2 → 0.13.4

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
  *
@@ -2760,6 +2791,64 @@ const MEMORY_RECENT_INTRO = 'Recently remembered:';
2760
2791
  // renders one item per line.
2761
2792
  const renderMemoryTopicLine = (topic) => `- ${escapeControlCharacters(topic.name)} (${topic.count} fact${topic.count === 1 ? '' : 's'},`
2762
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
+ };
2763
2852
  /**
2764
2853
  * The memory block: the pinned core, the topic index, and the recency tail,
2765
2854
  * each grouped into trust regions, joined into **one** section.
@@ -2782,7 +2871,17 @@ export const renderMemorySection = (memory) => {
2782
2871
  }
2783
2872
  const injection = asMemoryInjection(memory);
2784
2873
  const blocks = [];
2785
- 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
+ };
2786
2885
  const push = (intro, regions) => {
2787
2886
  if (regions.length > 0) {
2788
2887
  blocks.push(`${intro}\n\n${regions.join('\n\n')}`);
@@ -2814,6 +2913,86 @@ export const renderSkillsSection = (skills) => {
2814
2913
  });
2815
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')}`;
2816
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
+ };
2817
2996
  /**
2818
2997
  * The language agents write in when nothing says otherwise: American
2819
2998
  * English. A soul's `language:` and the config file's `language` override
@@ -4627,17 +4806,52 @@ export class AgentRunner {
4627
4806
  * unaffected.
4628
4807
  */
4629
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
+ };
4630
4825
  let approved;
4826
+ let decidedTrust = sessionTrustOf(session);
4827
+ let movedTwice = false;
4631
4828
  try {
4632
- approved = await this.approvals.approve({
4633
- session,
4634
- call,
4635
- tool,
4636
- risk,
4637
- ...(signal ? { signal } : {}),
4638
- ...(options.parkedAt ? { parkedAt: options.parkedAt } : {}),
4639
- });
4640
- 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
+ }
4641
4855
  }
4642
4856
  finally {
4643
4857
  // Cleared before anything executes and on every exit — an abort that
@@ -4647,6 +4861,18 @@ export class AgentRunner {
4647
4861
  await this.checkpointPendingApproval(session, undefined);
4648
4862
  }
4649
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
+ }
4650
4876
  if (!approved) {
4651
4877
  await this.bus.emit({ type: 'tool.denied', sessionId: session.id, call });
4652
4878
  return {
@@ -4685,6 +4911,7 @@ export class AgentRunner {
4685
4911
  const collector = createImageCollector();
4686
4912
  const executed = await this.executor.execute(call, tool, session, {
4687
4913
  ...(signal ? { signal } : {}),
4914
+ decidedTrust,
4688
4915
  attachImage: collector.attach,
4689
4916
  });
4690
4917
  // Only a call that succeeded shows what it attached: a failure's error