@stratusagent/core 0.11.8 → 0.11.10
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 +149 -20
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +175 -15
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -354,7 +354,16 @@ export const UNTRUSTED_TOOL_RESULT_NOTE = 'This result was written by a party th
|
|
|
354
354
|
* transcripts, where a changed byte is a cache miss.
|
|
355
355
|
*/
|
|
356
356
|
export const renderToolResultContent = (result) => {
|
|
357
|
-
|
|
357
|
+
// A failure keeps what the tool returned with it. A shell command that
|
|
358
|
+
// exits 1 carries its stdout, stderr, and exit code, and without them the
|
|
359
|
+
// model cannot tell a failing test from a typo from a missing program, so
|
|
360
|
+
// it guesses or asks. A failure with no output renders exactly as before,
|
|
361
|
+
// so most persisted transcripts replay byte-identical.
|
|
362
|
+
const output = result.ok
|
|
363
|
+
? result.output
|
|
364
|
+
: result.output === null || result.output === undefined
|
|
365
|
+
? { error: result.error ?? 'Tool failed' }
|
|
366
|
+
: { error: result.error ?? 'Tool failed', output: result.output };
|
|
358
367
|
return JSON.stringify(result.trust === 'external'
|
|
359
368
|
? { untrusted: true, untrustedNote: UNTRUSTED_TOOL_RESULT_NOTE, output }
|
|
360
369
|
: output);
|
|
@@ -412,8 +421,8 @@ export const originOf = (rawUrl) => {
|
|
|
412
421
|
* as written, so every comparison of an origin in this codebase is of the
|
|
413
422
|
* same thing.
|
|
414
423
|
*/
|
|
415
|
-
const originForSession = (tool, session) => {
|
|
416
|
-
const reported = tool.originFor?.(session);
|
|
424
|
+
const originForSession = (tool, session, input) => {
|
|
425
|
+
const reported = tool.originFor?.(session, input);
|
|
417
426
|
return reported === undefined ? undefined : originOf(reported);
|
|
418
427
|
};
|
|
419
428
|
/**
|
|
@@ -1498,6 +1507,9 @@ const CONVERSATION_KINDS = ['direct', 'group', 'private', 'public'];
|
|
|
1498
1507
|
// vouched for, but still typed, so anything that is not plainly a name is
|
|
1499
1508
|
// dropped rather than quoted.
|
|
1500
1509
|
const CONVERSATION_WITH_PATTERN = /^[\p{L}\p{M}\p{N}][\p{L}\p{M}\p{N} .'\u2019-]{0,63}$/u;
|
|
1510
|
+
// A platform-assigned id, never a word anyone typed: Slack's are capitals
|
|
1511
|
+
// and digits. Anything else is dropped rather than quoted.
|
|
1512
|
+
const CONVERSATION_ID_PATTERN = /^[A-Z0-9]{6,32}$/;
|
|
1501
1513
|
/**
|
|
1502
1514
|
* The room a turn's metadata describes, keeping only what is safe to put
|
|
1503
1515
|
* in front of the model, or undefined when it describes none.
|
|
@@ -1507,7 +1519,7 @@ export const conversationContextFrom = (metadata) => {
|
|
|
1507
1519
|
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
1508
1520
|
return undefined;
|
|
1509
1521
|
}
|
|
1510
|
-
const { kind, members, with: withWhom, thread, shared } = raw;
|
|
1522
|
+
const { kind, members, with: withWhom, thread, shared, id } = raw;
|
|
1511
1523
|
if (typeof kind !== 'string' || !CONVERSATION_KINDS.includes(kind)) {
|
|
1512
1524
|
return undefined;
|
|
1513
1525
|
}
|
|
@@ -1517,6 +1529,7 @@ export const conversationContextFrom = (metadata) => {
|
|
|
1517
1529
|
...(kind === 'direct' && typeof withWhom === 'string' && CONVERSATION_WITH_PATTERN.test(withWhom.trim()) ? { with: withWhom.trim() } : {}),
|
|
1518
1530
|
...(thread === true ? { thread: true } : {}),
|
|
1519
1531
|
...(kind !== 'direct' && shared === true ? { shared: true } : {}),
|
|
1532
|
+
...(kind !== 'direct' && typeof id === 'string' && CONVERSATION_ID_PATTERN.test(id) ? { id } : {}),
|
|
1520
1533
|
};
|
|
1521
1534
|
};
|
|
1522
1535
|
/**
|
|
@@ -1895,6 +1908,11 @@ export const UNADDRESSED_TURN_NOTE = 'Nobody said that to you, and nobody is wai
|
|
|
1895
1908
|
* the runtime speaking for one call, and a transcript that kept it would
|
|
1896
1909
|
* read to every later turn as something the person said.
|
|
1897
1910
|
*/
|
|
1911
|
+
/**
|
|
1912
|
+
* What a harness runtime is told when auto-continue grants it a fresh
|
|
1913
|
+
* budget. The runtime speaking, never saved to the kernel's transcript.
|
|
1914
|
+
*/
|
|
1915
|
+
export const CONTINUE_NOTE = 'You ran out of steps for this stretch and have been given more. Carry on with the work from where you left off; do not start over or repeat what is done.';
|
|
1898
1916
|
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.';
|
|
1899
1917
|
/** The wrap-up note as a message, for the request's window only. */
|
|
1900
1918
|
const turnLimitNoteFor = (session) => ({
|
|
@@ -2869,6 +2887,7 @@ export const renderChannelSection = (session, conversation) => {
|
|
|
2869
2887
|
*/
|
|
2870
2888
|
const describeRoom = (room, channel) => {
|
|
2871
2889
|
const count = room.members !== undefined ? room.members.toLocaleString('en-US') : undefined;
|
|
2890
|
+
const idNote = room.id !== undefined ? ` Its ${channel} id is ${room.id}.` : '';
|
|
2872
2891
|
const inThread = room.thread === true ? ' You are replying in a thread there, which everyone who can read the channel can open.' : '';
|
|
2873
2892
|
const outside = room.shared === true
|
|
2874
2893
|
? ' It is shared with people outside this workspace, through Slack Connect or another workspace of the organization, and they read it too.'
|
|
@@ -2881,13 +2900,13 @@ const describeRoom = (room, channel) => {
|
|
|
2881
2900
|
+ 'Only the two of you can read it, so you are talking to one person.';
|
|
2882
2901
|
case 'group':
|
|
2883
2902
|
return `this is a group direct message in ${channel}${count !== undefined ? ` with ${count} members, you included` : ''}. `
|
|
2884
|
-
+ `Only they can read it.${outside}${inThread} ${forEveryone}`;
|
|
2903
|
+
+ `Only they can read it.${idNote}${outside}${inThread} ${forEveryone}`;
|
|
2885
2904
|
case 'private':
|
|
2886
2905
|
return `this is a private ${channel} channel${count !== undefined ? ` with ${count} members, you included` : ''}. `
|
|
2887
|
-
+ `Only its members can read it.${outside}${inThread} ${forEveryone}`;
|
|
2906
|
+
+ `Only its members can read it.${idNote}${outside}${inThread} ${forEveryone}`;
|
|
2888
2907
|
case 'public':
|
|
2889
2908
|
return `this is a public ${channel} channel${count !== undefined ? ` with ${count} members` : ''}. `
|
|
2890
|
-
+ `Anyone in the workspace can find it and read it, now or later, not only the people talking.${outside}${inThread} ${forEveryone}`;
|
|
2909
|
+
+ `Anyone in the workspace can find it and read it, now or later, not only the people talking.${idNote}${outside}${inThread} ${forEveryone}`;
|
|
2891
2910
|
}
|
|
2892
2911
|
};
|
|
2893
2912
|
// A provider or model name as the prompt may carry it. A model name can
|
|
@@ -3353,6 +3372,87 @@ const throwIfAborted = (signal) => {
|
|
|
3353
3372
|
* task checks in rather than where it dies, and 40 is enough for real
|
|
3354
3373
|
* multi-step work while still bounding what a runaway loop can spend.
|
|
3355
3374
|
*/
|
|
3375
|
+
/**
|
|
3376
|
+
* What is wrong with a call's input against the shape its tool declared,
|
|
3377
|
+
* or undefined when nothing the check reads is wrong.
|
|
3378
|
+
*
|
|
3379
|
+
* Deliberately shallow: required keys, the declared JSON types, `enum`,
|
|
3380
|
+
* and `additionalProperties: false`, applied to nested objects too. It
|
|
3381
|
+
* exists so a call the tool would reject anyway is answered with the reason
|
|
3382
|
+
* before anyone is asked to approve it, not to replace a tool's own checks,
|
|
3383
|
+
* which still run. A schema keyword it doesn't read is not a failure.
|
|
3384
|
+
*/
|
|
3385
|
+
/** JSON equality, member order aside, the way JSON Schema compares `enum` values. */
|
|
3386
|
+
const sameJson = (left, right, depth = 0) => {
|
|
3387
|
+
if (left === right) {
|
|
3388
|
+
return true;
|
|
3389
|
+
}
|
|
3390
|
+
// Past any depth a real enum has, too deep to compare safely: unknown is
|
|
3391
|
+
// not wrong, so it counts as a match and the tool's own checks decide.
|
|
3392
|
+
if (depth > 64) {
|
|
3393
|
+
return true;
|
|
3394
|
+
}
|
|
3395
|
+
if (left === null || right === null || typeof left !== 'object' || typeof right !== 'object') {
|
|
3396
|
+
return false;
|
|
3397
|
+
}
|
|
3398
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
3399
|
+
return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((item, index) => sameJson(item, right[index], depth + 1));
|
|
3400
|
+
}
|
|
3401
|
+
const leftKeys = Object.keys(left).filter((key) => left[key] !== undefined);
|
|
3402
|
+
const rightKeys = Object.keys(right).filter((key) => right[key] !== undefined);
|
|
3403
|
+
return leftKeys.length === rightKeys.length && leftKeys.every((key) => sameJson(left[key], right[key], depth + 1));
|
|
3404
|
+
};
|
|
3405
|
+
export const inputProblem = (schema, input, where = 'input', depth = 0) => {
|
|
3406
|
+
if (schema === undefined || depth > 4) {
|
|
3407
|
+
return undefined;
|
|
3408
|
+
}
|
|
3409
|
+
const declared = schema.type;
|
|
3410
|
+
const types = Array.isArray(declared) ? declared.filter((type) => typeof type === 'string') : typeof declared === 'string' ? [declared] : [];
|
|
3411
|
+
if (types.length > 0) {
|
|
3412
|
+
const actual = input === null ? 'null'
|
|
3413
|
+
: Array.isArray(input) ? 'array'
|
|
3414
|
+
: typeof input === 'number' ? (Number.isInteger(input) ? 'integer' : 'number')
|
|
3415
|
+
: typeof input;
|
|
3416
|
+
const fits = types.some((type) => type === actual || (type === 'number' && actual === 'integer'));
|
|
3417
|
+
if (!fits) {
|
|
3418
|
+
return `${where} should be ${types.join(' or ')}, not ${actual}`;
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3421
|
+
if (Array.isArray(schema.enum) && !schema.enum.some((option) => sameJson(option, input))) {
|
|
3422
|
+
return `${where} should be one of ${schema.enum.map((option) => JSON.stringify(option)).join(', ')}`;
|
|
3423
|
+
}
|
|
3424
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
3425
|
+
return undefined;
|
|
3426
|
+
}
|
|
3427
|
+
const properties = schema.properties !== null && typeof schema.properties === 'object' && !Array.isArray(schema.properties)
|
|
3428
|
+
? schema.properties
|
|
3429
|
+
: {};
|
|
3430
|
+
for (const key of Array.isArray(schema.required) ? schema.required : []) {
|
|
3431
|
+
if (typeof key === 'string' && input[key] === undefined) {
|
|
3432
|
+
return `${where} is missing "${key}"`;
|
|
3433
|
+
}
|
|
3434
|
+
}
|
|
3435
|
+
// With `patternProperties` a key may be declared by a pattern, and running
|
|
3436
|
+
// a pattern a schema supplied is a regex engine on untrusted input. Not
|
|
3437
|
+
// this check's job: extra keys are only judged when no pattern exists.
|
|
3438
|
+
const patterned = schema.patternProperties !== undefined && schema.patternProperties !== null;
|
|
3439
|
+
if (schema.additionalProperties === false && !patterned) {
|
|
3440
|
+
const unknown = Object.keys(input).find((key) => !(key in properties));
|
|
3441
|
+
if (unknown !== undefined) {
|
|
3442
|
+
return `${where} has "${unknown}", which this tool does not take (it takes ${Object.keys(properties).map((key) => `"${key}"`).join(', ') || 'nothing'})`;
|
|
3443
|
+
}
|
|
3444
|
+
}
|
|
3445
|
+
for (const [key, value] of Object.entries(input)) {
|
|
3446
|
+
const property = properties[key];
|
|
3447
|
+
if (value !== undefined && property !== null && typeof property === 'object' && !Array.isArray(property)) {
|
|
3448
|
+
const problem = inputProblem(property, value, `${where}.${key}`, depth + 1);
|
|
3449
|
+
if (problem) {
|
|
3450
|
+
return problem;
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
return undefined;
|
|
3455
|
+
};
|
|
3356
3456
|
export const DEFAULT_MAX_TURNS = 40;
|
|
3357
3457
|
export class AgentRunner {
|
|
3358
3458
|
bus;
|
|
@@ -3400,11 +3500,23 @@ export class AgentRunner {
|
|
|
3400
3500
|
// and kept out of the persisted metadata, where a later reader would
|
|
3401
3501
|
// take the first sender's label for every turn that follows.
|
|
3402
3502
|
const { [SENDER_TRUST_METADATA_KEY]: _sender, ...persisted } = input.metadata ?? {};
|
|
3403
|
-
const
|
|
3503
|
+
const earlier = input.earlier ?? [];
|
|
3504
|
+
// Everyone whose words open the transcript, not only the one who
|
|
3505
|
+
// asked: see `RunInput.earlier`.
|
|
3506
|
+
const senderTrust = earlier.reduce((lowest, entry) => leastTrusted(lowest, senderTrustOf(entry.metadata)), senderTrustOf(input.metadata));
|
|
3507
|
+
const createdAt = new Date().toISOString();
|
|
3508
|
+
const heard = earlier.map((entry, index) => ({
|
|
3509
|
+
id: `${input.sessionId}:user:${index + 1}`,
|
|
3510
|
+
role: 'user',
|
|
3511
|
+
content: entry.message,
|
|
3512
|
+
createdAt,
|
|
3513
|
+
overheard: true,
|
|
3514
|
+
observed: true,
|
|
3515
|
+
}));
|
|
3404
3516
|
// The first stored turn is held to the window like every later one:
|
|
3405
3517
|
// a one-shot session is a row too.
|
|
3406
3518
|
const opening = {
|
|
3407
|
-
id: `${input.sessionId}:user
|
|
3519
|
+
id: `${input.sessionId}:user:${heard.length + 1}`,
|
|
3408
3520
|
role: 'user',
|
|
3409
3521
|
content: input.userMessage,
|
|
3410
3522
|
createdAt: new Date().toISOString(),
|
|
@@ -3418,7 +3530,7 @@ export class AgentRunner {
|
|
|
3418
3530
|
id: input.sessionId,
|
|
3419
3531
|
agent: input.agent,
|
|
3420
3532
|
status: 'running',
|
|
3421
|
-
messages: [opening],
|
|
3533
|
+
messages: [...heard, opening],
|
|
3422
3534
|
// Labelled from the first write, whatever the dispatching surface
|
|
3423
3535
|
// put in `metadata` under this key: the label is the runner's to
|
|
3424
3536
|
// write, and a fresh session starts at the top of the lattice lowered
|
|
@@ -3828,18 +3940,32 @@ export class AgentRunner {
|
|
|
3828
3940
|
? { toolName: results[0].toolName, times: failureRun }
|
|
3829
3941
|
: undefined;
|
|
3830
3942
|
};
|
|
3943
|
+
// Where this message wraps up: `maxTurns`, raised by another
|
|
3944
|
+
// `maxTurns` each time auto-continue grants one.
|
|
3945
|
+
let ceiling = this.maxTurns;
|
|
3946
|
+
let continued = 0;
|
|
3831
3947
|
for (let turn = resumeFrom?.turn ?? 1;; turn += 1) {
|
|
3832
3948
|
throwIfAborted(signal);
|
|
3949
|
+
// Out of budget and still working — the last response called a
|
|
3950
|
+
// tool — so with auto-continue on it gets another allowance rather
|
|
3951
|
+
// than a wrap-up. A loop, because a recovered turn can start
|
|
3952
|
+
// several allowances in. Never for a turn stuck on a failure, past
|
|
3953
|
+
// the cap, or with somebody's message waiting behind it.
|
|
3954
|
+
while (turn > ceiling && stuck === undefined && this.mayContinue(session.id, continued)) {
|
|
3955
|
+
ceiling += this.maxTurns;
|
|
3956
|
+
continued += 1;
|
|
3957
|
+
await this.bus.emit({ type: 'session.continued', sessionId: session.id, continuation: continued, turns: this.maxTurns });
|
|
3958
|
+
}
|
|
3833
3959
|
// One turn past the ceiling, and only one: the wrap-up, which may
|
|
3834
|
-
// not call a tool, so a turn that reaches `
|
|
3960
|
+
// not call a tool, so a turn that reaches `ceiling + 2` is a
|
|
3835
3961
|
// provider that ignored `toolChoice` and was already failed below.
|
|
3836
3962
|
// A turn stopped for repeating itself wraps up the same way, early.
|
|
3837
|
-
const wrappingUp = turn >
|
|
3963
|
+
const wrappingUp = turn > ceiling || stuck !== undefined;
|
|
3838
3964
|
// A recovered call is a tool turn too, so one parked past the
|
|
3839
3965
|
// ceiling — `maxTurns` lowered while it waited — is refused as it
|
|
3840
3966
|
// was before wrap-up existed, not run on the one call reserved for
|
|
3841
3967
|
// saying what happened.
|
|
3842
|
-
if (turn >
|
|
3968
|
+
if (turn > ceiling + 1 || (wrappingUp && pendingEntry)) {
|
|
3843
3969
|
throw new Error(turnLimitMessage(this.maxTurns));
|
|
3844
3970
|
}
|
|
3845
3971
|
if (pendingEntry) {
|
|
@@ -3957,6 +4083,20 @@ export class AgentRunner {
|
|
|
3957
4083
|
...(enabledSkills.length > 0 ? { skills: enabledSkills } : {}),
|
|
3958
4084
|
...(runtime !== undefined ? { runtime } : {}),
|
|
3959
4085
|
...(this.streaming ? { onDelta } : {}),
|
|
4086
|
+
...(this.options.autoContinue !== undefined
|
|
4087
|
+
? {
|
|
4088
|
+
// The same grant the loop below makes, for a provider
|
|
4089
|
+
// whose loop is its own.
|
|
4090
|
+
mayContinue: () => {
|
|
4091
|
+
if (!this.mayContinue(session.id, continued)) {
|
|
4092
|
+
return false;
|
|
4093
|
+
}
|
|
4094
|
+
continued += 1;
|
|
4095
|
+
void this.bus.emit({ type: 'session.continued', sessionId: session.id, continuation: continued, turns: this.maxTurns });
|
|
4096
|
+
return true;
|
|
4097
|
+
},
|
|
4098
|
+
}
|
|
4099
|
+
: {}),
|
|
3960
4100
|
onUsage,
|
|
3961
4101
|
...(signal ? { signal } : {}),
|
|
3962
4102
|
});
|
|
@@ -4108,6 +4248,15 @@ export class AgentRunner {
|
|
|
4108
4248
|
throw error;
|
|
4109
4249
|
}
|
|
4110
4250
|
}
|
|
4251
|
+
/** Whether a message out of budget gets another; see `AgentRunnerOptions.autoContinue`. */
|
|
4252
|
+
mayContinue(sessionId, continued) {
|
|
4253
|
+
const autoContinue = this.options.autoContinue;
|
|
4254
|
+
// A budget of nothing renews to nothing, forever: never renewed.
|
|
4255
|
+
if (autoContinue === undefined || this.maxTurns < 1 || (autoContinue !== true && continued >= autoContinue)) {
|
|
4256
|
+
return false;
|
|
4257
|
+
}
|
|
4258
|
+
return this.options.waitingInput?.(sessionId) !== true;
|
|
4259
|
+
}
|
|
4111
4260
|
/** See `completeAnsweredTurn`; the loop's end and `continueTurn`'s. */
|
|
4112
4261
|
async completeTurn(finished) {
|
|
4113
4262
|
return completeAnsweredTurn(finished, this.store, this.bus);
|
|
@@ -4330,6 +4479,17 @@ export class AgentRunner {
|
|
|
4330
4479
|
if (!tool) {
|
|
4331
4480
|
return rejected(`Tool not found: ${call.toolName}`);
|
|
4332
4481
|
}
|
|
4482
|
+
// A malformed call is answered before anyone is asked about it, for the
|
|
4483
|
+
// reason an unknown tool is: approving a call the tool will reject is a
|
|
4484
|
+
// question with no useful answer, and the model needs the reason, not a
|
|
4485
|
+
// human's click, to fix it. Only for calls that would be asked about; a
|
|
4486
|
+
// `safe` tool's own checks answer it as they always have.
|
|
4487
|
+
if (resolveToolRisk(tool) !== 'safe') {
|
|
4488
|
+
const problem = inputProblem(tool.parameters, call.input);
|
|
4489
|
+
if (problem) {
|
|
4490
|
+
return rejected(`Invalid input for ${call.toolName}: ${problem}. Fix the call and try again.`);
|
|
4491
|
+
}
|
|
4492
|
+
}
|
|
4333
4493
|
// Only a call that can actually be held for a human is checkpointed:
|
|
4334
4494
|
// a `safe` one is never asked about, and writing a record for it would
|
|
4335
4495
|
// add a save to every unattended call to describe a wait that does not
|
|
@@ -4381,7 +4541,7 @@ export class AgentRunner {
|
|
|
4381
4541
|
...(signal ? { signal } : {}),
|
|
4382
4542
|
...(options.parkedAt ? { parkedAt: options.parkedAt } : {}),
|
|
4383
4543
|
});
|
|
4384
|
-
originWhenJudged = originForSession(tool, session);
|
|
4544
|
+
originWhenJudged = originForSession(tool, session, call.input);
|
|
4385
4545
|
}
|
|
4386
4546
|
finally {
|
|
4387
4547
|
// Cleared before anything executes and on every exit — an abort that
|
|
@@ -4411,7 +4571,7 @@ export class AgentRunner {
|
|
|
4411
4571
|
// `tool.completed` keeps the pairing every consumer reads — the
|
|
4412
4572
|
// watchdog's phase, the channel's live line. The agent is told plainly,
|
|
4413
4573
|
// because this is a page that moved rather than a permission it lacks.
|
|
4414
|
-
const originNow = originForSession(tool, session);
|
|
4574
|
+
const originNow = originForSession(tool, session, call.input);
|
|
4415
4575
|
if (originNow !== originWhenJudged) {
|
|
4416
4576
|
const result = {
|
|
4417
4577
|
callId: call.id,
|