@stratusagent/core 0.11.7 → 0.11.9

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
  /**
@@ -1543,6 +1556,66 @@ export const transcriptOf = (request) => request.messages ?? request.session.mes
1543
1556
  * in config, that an auditor can see.
1544
1557
  */
1545
1558
  export const DEFAULT_SUBPROCESS_PASS_ENV = ['PATH', 'HOME', 'LANG', 'LC_ALL', 'TZ'];
1559
+ /**
1560
+ * Collects what a tool attaches through `ExecutionContext.attachImage`,
1561
+ * held to the limits that sink documents.
1562
+ */
1563
+ export const createImageCollector = () => {
1564
+ const images = [];
1565
+ let total = 0;
1566
+ return {
1567
+ attach: (image) => {
1568
+ if (!isImageAttachmentMediaType(image.mediaType)) {
1569
+ throw new Error(`An image of type ${String(image.mediaType)} cannot be shown to the model; it takes ${IMAGE_ATTACHMENT_MEDIA_TYPES.join(', ')}.`);
1570
+ }
1571
+ const bytes = Buffer.from(image.data, 'base64');
1572
+ if (bytes.length === 0) {
1573
+ throw new Error('The image is empty.');
1574
+ }
1575
+ if (bytes.length > IMAGE_ATTACHMENT_MAX_BYTES) {
1576
+ throw new Error(`The image is ${bytes.length} bytes, over the ${IMAGE_ATTACHMENT_MAX_BYTES}-byte limit for one image.`);
1577
+ }
1578
+ if (total + bytes.length > IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES) {
1579
+ throw new Error(`One call can return at most ${IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES} bytes of images.`);
1580
+ }
1581
+ const dimensions = imageDimensions(bytes, image.mediaType);
1582
+ if (dimensions === undefined) {
1583
+ throw new Error(`The bytes are not a complete ${image.mediaType} image.`);
1584
+ }
1585
+ if (dimensions.width > IMAGE_ATTACHMENT_MAX_DIMENSION || dimensions.height > IMAGE_ATTACHMENT_MAX_DIMENSION) {
1586
+ throw new Error(`The image is ${dimensions.width}×${dimensions.height}, over the ${IMAGE_ATTACHMENT_MAX_DIMENSION}-pixel side the model can take.`);
1587
+ }
1588
+ total += bytes.length;
1589
+ images.push({ mediaType: image.mediaType, data: image.data, ...(image.name !== undefined ? { name: image.name } : {}) });
1590
+ },
1591
+ images: () => [...images],
1592
+ };
1593
+ };
1594
+ /**
1595
+ * The transcript message for a tool result: its images moved off the
1596
+ * result onto the message, where the replay budget and the trim already
1597
+ * look, so the stored content and the result stay text.
1598
+ */
1599
+ const toolResultMessage = (session, result) => {
1600
+ const { images, ...stored } = result;
1601
+ return {
1602
+ id: `${session.id}:tool:${result.callId}`,
1603
+ role: 'tool',
1604
+ name: result.toolName,
1605
+ content: JSON.stringify(stored),
1606
+ createdAt: new Date().toISOString(),
1607
+ toolResult: stored,
1608
+ ...(images !== undefined && images.length > 0 ? { images } : {}),
1609
+ };
1610
+ };
1611
+ /** A result as the bus carries it: never the image bytes. */
1612
+ const withoutImages = (result) => {
1613
+ if (result.images === undefined) {
1614
+ return result;
1615
+ }
1616
+ const { images: _images, ...rest } = result;
1617
+ return rest;
1618
+ };
1546
1619
  /** Where a parked turn's checkpoint lives in `session.metadata`. */
1547
1620
  export const PENDING_APPROVAL_METADATA_KEY = 'pendingApproval';
1548
1621
  /**
@@ -2809,6 +2882,7 @@ export const renderChannelSection = (session, conversation) => {
2809
2882
  */
2810
2883
  const describeRoom = (room, channel) => {
2811
2884
  const count = room.members !== undefined ? room.members.toLocaleString('en-US') : undefined;
2885
+ const idNote = room.id !== undefined ? ` Its ${channel} id is ${room.id}.` : '';
2812
2886
  const inThread = room.thread === true ? ' You are replying in a thread there, which everyone who can read the channel can open.' : '';
2813
2887
  const outside = room.shared === true
2814
2888
  ? ' It is shared with people outside this workspace, through Slack Connect or another workspace of the organization, and they read it too.'
@@ -2821,13 +2895,13 @@ const describeRoom = (room, channel) => {
2821
2895
  + 'Only the two of you can read it, so you are talking to one person.';
2822
2896
  case 'group':
2823
2897
  return `this is a group direct message in ${channel}${count !== undefined ? ` with ${count} members, you included` : ''}. `
2824
- + `Only they can read it.${outside}${inThread} ${forEveryone}`;
2898
+ + `Only they can read it.${idNote}${outside}${inThread} ${forEveryone}`;
2825
2899
  case 'private':
2826
2900
  return `this is a private ${channel} channel${count !== undefined ? ` with ${count} members, you included` : ''}. `
2827
- + `Only its members can read it.${outside}${inThread} ${forEveryone}`;
2901
+ + `Only its members can read it.${idNote}${outside}${inThread} ${forEveryone}`;
2828
2902
  case 'public':
2829
2903
  return `this is a public ${channel} channel${count !== undefined ? ` with ${count} members` : ''}. `
2830
- + `Anyone in the workspace can find it and read it, now or later, not only the people talking.${outside}${inThread} ${forEveryone}`;
2904
+ + `Anyone in the workspace can find it and read it, now or later, not only the people talking.${idNote}${outside}${inThread} ${forEveryone}`;
2831
2905
  }
2832
2906
  };
2833
2907
  // A provider or model name as the prompt may carry it. A model name can
@@ -3293,6 +3367,87 @@ const throwIfAborted = (signal) => {
3293
3367
  * task checks in rather than where it dies, and 40 is enough for real
3294
3368
  * multi-step work while still bounding what a runaway loop can spend.
3295
3369
  */
3370
+ /**
3371
+ * What is wrong with a call's input against the shape its tool declared,
3372
+ * or undefined when nothing the check reads is wrong.
3373
+ *
3374
+ * Deliberately shallow: required keys, the declared JSON types, `enum`,
3375
+ * and `additionalProperties: false`, applied to nested objects too. It
3376
+ * exists so a call the tool would reject anyway is answered with the reason
3377
+ * before anyone is asked to approve it, not to replace a tool's own checks,
3378
+ * which still run. A schema keyword it doesn't read is not a failure.
3379
+ */
3380
+ /** JSON equality, member order aside, the way JSON Schema compares `enum` values. */
3381
+ const sameJson = (left, right, depth = 0) => {
3382
+ if (left === right) {
3383
+ return true;
3384
+ }
3385
+ // Past any depth a real enum has, too deep to compare safely: unknown is
3386
+ // not wrong, so it counts as a match and the tool's own checks decide.
3387
+ if (depth > 64) {
3388
+ return true;
3389
+ }
3390
+ if (left === null || right === null || typeof left !== 'object' || typeof right !== 'object') {
3391
+ return false;
3392
+ }
3393
+ if (Array.isArray(left) || Array.isArray(right)) {
3394
+ return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((item, index) => sameJson(item, right[index], depth + 1));
3395
+ }
3396
+ const leftKeys = Object.keys(left).filter((key) => left[key] !== undefined);
3397
+ const rightKeys = Object.keys(right).filter((key) => right[key] !== undefined);
3398
+ return leftKeys.length === rightKeys.length && leftKeys.every((key) => sameJson(left[key], right[key], depth + 1));
3399
+ };
3400
+ export const inputProblem = (schema, input, where = 'input', depth = 0) => {
3401
+ if (schema === undefined || depth > 4) {
3402
+ return undefined;
3403
+ }
3404
+ const declared = schema.type;
3405
+ const types = Array.isArray(declared) ? declared.filter((type) => typeof type === 'string') : typeof declared === 'string' ? [declared] : [];
3406
+ if (types.length > 0) {
3407
+ const actual = input === null ? 'null'
3408
+ : Array.isArray(input) ? 'array'
3409
+ : typeof input === 'number' ? (Number.isInteger(input) ? 'integer' : 'number')
3410
+ : typeof input;
3411
+ const fits = types.some((type) => type === actual || (type === 'number' && actual === 'integer'));
3412
+ if (!fits) {
3413
+ return `${where} should be ${types.join(' or ')}, not ${actual}`;
3414
+ }
3415
+ }
3416
+ if (Array.isArray(schema.enum) && !schema.enum.some((option) => sameJson(option, input))) {
3417
+ return `${where} should be one of ${schema.enum.map((option) => JSON.stringify(option)).join(', ')}`;
3418
+ }
3419
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
3420
+ return undefined;
3421
+ }
3422
+ const properties = schema.properties !== null && typeof schema.properties === 'object' && !Array.isArray(schema.properties)
3423
+ ? schema.properties
3424
+ : {};
3425
+ for (const key of Array.isArray(schema.required) ? schema.required : []) {
3426
+ if (typeof key === 'string' && input[key] === undefined) {
3427
+ return `${where} is missing "${key}"`;
3428
+ }
3429
+ }
3430
+ // With `patternProperties` a key may be declared by a pattern, and running
3431
+ // a pattern a schema supplied is a regex engine on untrusted input. Not
3432
+ // this check's job: extra keys are only judged when no pattern exists.
3433
+ const patterned = schema.patternProperties !== undefined && schema.patternProperties !== null;
3434
+ if (schema.additionalProperties === false && !patterned) {
3435
+ const unknown = Object.keys(input).find((key) => !(key in properties));
3436
+ if (unknown !== undefined) {
3437
+ return `${where} has "${unknown}", which this tool does not take (it takes ${Object.keys(properties).map((key) => `"${key}"`).join(', ') || 'nothing'})`;
3438
+ }
3439
+ }
3440
+ for (const [key, value] of Object.entries(input)) {
3441
+ const property = properties[key];
3442
+ if (value !== undefined && property !== null && typeof property === 'object' && !Array.isArray(property)) {
3443
+ const problem = inputProblem(property, value, `${where}.${key}`, depth + 1);
3444
+ if (problem) {
3445
+ return problem;
3446
+ }
3447
+ }
3448
+ }
3449
+ return undefined;
3450
+ };
3296
3451
  export const DEFAULT_MAX_TURNS = 40;
3297
3452
  export class AgentRunner {
3298
3453
  bus;
@@ -3340,11 +3495,23 @@ export class AgentRunner {
3340
3495
  // and kept out of the persisted metadata, where a later reader would
3341
3496
  // take the first sender's label for every turn that follows.
3342
3497
  const { [SENDER_TRUST_METADATA_KEY]: _sender, ...persisted } = input.metadata ?? {};
3343
- const senderTrust = senderTrustOf(input.metadata);
3498
+ const earlier = input.earlier ?? [];
3499
+ // Everyone whose words open the transcript, not only the one who
3500
+ // asked: see `RunInput.earlier`.
3501
+ const senderTrust = earlier.reduce((lowest, entry) => leastTrusted(lowest, senderTrustOf(entry.metadata)), senderTrustOf(input.metadata));
3502
+ const createdAt = new Date().toISOString();
3503
+ const heard = earlier.map((entry, index) => ({
3504
+ id: `${input.sessionId}:user:${index + 1}`,
3505
+ role: 'user',
3506
+ content: entry.message,
3507
+ createdAt,
3508
+ overheard: true,
3509
+ observed: true,
3510
+ }));
3344
3511
  // The first stored turn is held to the window like every later one:
3345
3512
  // a one-shot session is a row too.
3346
3513
  const opening = {
3347
- id: `${input.sessionId}:user:1`,
3514
+ id: `${input.sessionId}:user:${heard.length + 1}`,
3348
3515
  role: 'user',
3349
3516
  content: input.userMessage,
3350
3517
  createdAt: new Date().toISOString(),
@@ -3358,7 +3525,7 @@ export class AgentRunner {
3358
3525
  id: input.sessionId,
3359
3526
  agent: input.agent,
3360
3527
  status: 'running',
3361
- messages: [opening],
3528
+ messages: [...heard, opening],
3362
3529
  // Labelled from the first write, whatever the dispatching surface
3363
3530
  // put in `metadata` under this key: the label is the runner's to
3364
3531
  // write, and a fresh session starts at the top of the lattice lowered
@@ -4126,14 +4293,8 @@ export class AgentRunner {
4126
4293
  * longer re-enterable, the two facts belong in one write.
4127
4294
  */
4128
4295
  async recordToolResult(session, result) {
4129
- session.messages.push({
4130
- id: `${session.id}:tool:${result.callId}`,
4131
- role: 'tool',
4132
- name: result.toolName,
4133
- content: JSON.stringify(result),
4134
- createdAt: new Date().toISOString(),
4135
- toolResult: result,
4136
- });
4296
+ session.messages.push(toolResultMessage(session, result));
4297
+ omitImagesOutsideReplayBudget(session.messages, this.imageReplayBudget);
4137
4298
  if (readPendingApproval(session)?.call.id === result.callId) {
4138
4299
  const metadata = { ...(session.metadata ?? {}) };
4139
4300
  delete metadata[PENDING_APPROVAL_METADATA_KEY];
@@ -4200,14 +4361,8 @@ export class AgentRunner {
4200
4361
  ...(context?.signal ? { signal: context.signal } : {}),
4201
4362
  recoverable: false,
4202
4363
  });
4203
- session.messages.push({
4204
- id: `${session.id}:tool:${result.callId}`,
4205
- role: 'tool',
4206
- name: result.toolName,
4207
- content: JSON.stringify(result),
4208
- createdAt: new Date().toISOString(),
4209
- toolResult: result,
4210
- });
4364
+ session.messages.push(toolResultMessage(session, result));
4365
+ omitImagesOutsideReplayBudget(session.messages, this.imageReplayBudget);
4211
4366
  await this.store.save(session);
4212
4367
  return result;
4213
4368
  }
@@ -4282,6 +4437,17 @@ export class AgentRunner {
4282
4437
  if (!tool) {
4283
4438
  return rejected(`Tool not found: ${call.toolName}`);
4284
4439
  }
4440
+ // A malformed call is answered before anyone is asked about it, for the
4441
+ // reason an unknown tool is: approving a call the tool will reject is a
4442
+ // question with no useful answer, and the model needs the reason, not a
4443
+ // human's click, to fix it. Only for calls that would be asked about; a
4444
+ // `safe` tool's own checks answer it as they always have.
4445
+ if (resolveToolRisk(tool) !== 'safe') {
4446
+ const problem = inputProblem(tool.parameters, call.input);
4447
+ if (problem) {
4448
+ return rejected(`Invalid input for ${call.toolName}: ${problem}. Fix the call and try again.`);
4449
+ }
4450
+ }
4285
4451
  // Only a call that can actually be held for a human is checkpointed:
4286
4452
  // a `safe` one is never asked about, and writing a record for it would
4287
4453
  // add a save to every unattended call to describe a wait that does not
@@ -4333,7 +4499,7 @@ export class AgentRunner {
4333
4499
  ...(signal ? { signal } : {}),
4334
4500
  ...(options.parkedAt ? { parkedAt: options.parkedAt } : {}),
4335
4501
  });
4336
- originWhenJudged = originForSession(tool, session);
4502
+ originWhenJudged = originForSession(tool, session, call.input);
4337
4503
  }
4338
4504
  finally {
4339
4505
  // Cleared before anything executes and on every exit — an abort that
@@ -4363,7 +4529,7 @@ export class AgentRunner {
4363
4529
  // `tool.completed` keeps the pairing every consumer reads — the
4364
4530
  // watchdog's phase, the channel's live line. The agent is told plainly,
4365
4531
  // because this is a page that moved rather than a permission it lacks.
4366
- const originNow = originForSession(tool, session);
4532
+ const originNow = originForSession(tool, session, call.input);
4367
4533
  if (originNow !== originWhenJudged) {
4368
4534
  const result = {
4369
4535
  callId: call.id,
@@ -4378,7 +4544,15 @@ export class AgentRunner {
4378
4544
  await this.bus.emit({ type: 'tool.completed', sessionId: session.id, result });
4379
4545
  return result;
4380
4546
  }
4381
- const result = await this.executor.execute(call, tool, session, signal ? { signal } : undefined);
4547
+ const collector = createImageCollector();
4548
+ const executed = await this.executor.execute(call, tool, session, {
4549
+ ...(signal ? { signal } : {}),
4550
+ attachImage: collector.attach,
4551
+ });
4552
+ // Only a call that succeeded shows what it attached: a failure's error
4553
+ // is the result, and an image ahead of it would read as the answer.
4554
+ const attached = executed.ok ? collector.images() : [];
4555
+ const result = attached.length > 0 ? { ...executed, images: attached } : withoutImages(executed);
4382
4556
  // Where tools execute, not in the provider loop above: this path also
4383
4557
  // serves `executeHostedToolCall`, through which `provider-claude-code`
4384
4558
  // and `provider-codex` run their bridged kernel tools. A hook in
@@ -4387,7 +4561,7 @@ export class AgentRunner {
4387
4561
  // wrong. An executor that returned no label is read as `unknown` —
4388
4562
  // absence of provenance is not evidence of trust, here either.
4389
4563
  await this.taint(session, result.trust ?? 'unknown', call.toolName);
4390
- await this.bus.emit({ type: 'tool.completed', sessionId: session.id, result });
4564
+ await this.bus.emit({ type: 'tool.completed', sessionId: session.id, result: withoutImages(result) });
4391
4565
  return result;
4392
4566
  }
4393
4567
  }