@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.d.ts +154 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +237 -10
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
4640
|
-
|
|
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
|