@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.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
- const output = result.ok ? result.output : { error: result.error ?? 'Tool failed' };
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 senderTrust = senderTrustOf(input.metadata);
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:1`,
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 `maxTurns + 2` is a
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 > this.maxTurns || stuck !== undefined;
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 > this.maxTurns + 1 || (wrappingUp && pendingEntry)) {
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,