@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.d.ts +178 -23
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +204 -30
- 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
|
/**
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|