@vellumai/assistant 0.12.2-dev.202609182214.bd8f92f → 0.12.2-dev.202609182314.c515b72
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/package.json +1 -1
- package/src/__tests__/interrupt-on-send.test.ts +57 -43
- package/src/__tests__/interrupt-turn-note.test.ts +5 -6
- package/src/daemon/conversation-interrupt-repair.ts +4 -43
- package/src/daemon/conversation-interrupt.ts +7 -11
- package/src/daemon/conversation-messaging.ts +6 -4
- package/src/daemon/conversation.ts +13 -8
- package/src/persistence/conversation-crud.ts +4 -4
- package/src/runtime/AGENTS.md +1 -1
- package/src/util/abort-reasons.ts +18 -26
package/package.json
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
abortedToolResultText,
|
|
22
22
|
CANCELLED_TOOL_RESULT_TEXT,
|
|
23
23
|
createAbortReason,
|
|
24
|
+
INTERRUPTED_TURN_NOTE_TEXT,
|
|
24
25
|
PREEMPTED_TOOL_RESULT_TEXT,
|
|
25
26
|
} from "../util/abort-reasons.js";
|
|
26
27
|
|
|
@@ -378,8 +379,8 @@ describe("interruptRunningTurn", () => {
|
|
|
378
379
|
|
|
379
380
|
test("arms the interrupted-turn note when the abort caught no tool call", async () => {
|
|
380
381
|
// The abort landed during the provider call, so there is no `tool_use` to
|
|
381
|
-
// answer
|
|
382
|
-
//
|
|
382
|
+
// answer. The note on the interrupting user message is the only thing that
|
|
383
|
+
// tells the model what happened.
|
|
383
384
|
const turn = registerBusyTurn({
|
|
384
385
|
messages: [
|
|
385
386
|
{ role: "user", content: [{ type: "text", text: "research this" }] },
|
|
@@ -393,9 +394,9 @@ describe("interruptRunningTurn", () => {
|
|
|
393
394
|
expect(persisted).toEqual([]);
|
|
394
395
|
});
|
|
395
396
|
|
|
396
|
-
test("
|
|
397
|
-
// The
|
|
398
|
-
//
|
|
397
|
+
test("arms the note alongside the fact-only result of a stopped tool call", async () => {
|
|
398
|
+
// The two texts do different jobs: the `tool_result` states what happened
|
|
399
|
+
// to that one call, the note says what to do about the interruption.
|
|
399
400
|
const turn = registerBusyTurn({
|
|
400
401
|
messages: [assistantWithToolUse("tool-1")],
|
|
401
402
|
});
|
|
@@ -404,13 +405,36 @@ describe("interruptRunningTurn", () => {
|
|
|
404
405
|
|
|
405
406
|
expect(persisted).toHaveLength(1);
|
|
406
407
|
expect(persisted[0].content).toContain(PREEMPTED_TOOL_RESULT_TEXT);
|
|
407
|
-
expect(turn.conversation.pendingInterruptNote).toBe(
|
|
408
|
+
expect(turn.conversation.pendingInterruptNote).toBe(true);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("arms exactly one note however many parallel calls were stopped", async () => {
|
|
412
|
+
// Every stopped call gets its own fact-only result; the behavior
|
|
413
|
+
// instruction is read once, off the interrupting message.
|
|
414
|
+
const turn = registerBusyTurn({
|
|
415
|
+
messages: [assistantWithToolUse("tool-1", "tool-2", "tool-3")],
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
await interruptRunningTurn(turn.conversation, { origin: "test" });
|
|
419
|
+
|
|
420
|
+
const results = JSON.parse(persisted[0].content) as Array<{
|
|
421
|
+
tool_use_id: string;
|
|
422
|
+
content: string;
|
|
423
|
+
}>;
|
|
424
|
+
expect(results.map((block) => block.tool_use_id)).toEqual([
|
|
425
|
+
"tool-1",
|
|
426
|
+
"tool-2",
|
|
427
|
+
"tool-3",
|
|
428
|
+
]);
|
|
429
|
+
for (const block of results) {
|
|
430
|
+
expect(block.content).toBe(PREEMPTED_TOOL_RESULT_TEXT);
|
|
431
|
+
}
|
|
432
|
+
expect(turn.conversation.pendingInterruptNote).toBe(true);
|
|
408
433
|
});
|
|
409
434
|
|
|
410
|
-
test("
|
|
435
|
+
test("arms the note when the loop wrote its own preempted result", async () => {
|
|
411
436
|
// The agent loop's abort handler unwound cleanly and answered the batch
|
|
412
|
-
// itself, so the repair finds nothing to do
|
|
413
|
-
// there.
|
|
437
|
+
// itself, so the repair finds nothing to do. The note is owed all the same.
|
|
414
438
|
const turn = registerBusyTurn({
|
|
415
439
|
messages: [
|
|
416
440
|
assistantWithToolUse("tool-1"),
|
|
@@ -421,12 +445,10 @@ describe("interruptRunningTurn", () => {
|
|
|
421
445
|
await interruptRunningTurn(turn.conversation, { origin: "test" });
|
|
422
446
|
|
|
423
447
|
expect(persisted).toEqual([]);
|
|
424
|
-
expect(turn.conversation.pendingInterruptNote).toBe(
|
|
448
|
+
expect(turn.conversation.pendingInterruptNote).toBe(true);
|
|
425
449
|
});
|
|
426
450
|
|
|
427
451
|
test("arms the note when the cut-off tool call was an ordinary cancel", async () => {
|
|
428
|
-
// A tail answered with the plain cancel wording is a stop the user made
|
|
429
|
-
// earlier, not this interrupt's notice, so the note is still owed.
|
|
430
452
|
const turn = registerBusyTurn({
|
|
431
453
|
messages: [
|
|
432
454
|
assistantWithToolUse("tool-1"),
|
|
@@ -633,6 +655,9 @@ describe("interruptRunningTurn", () => {
|
|
|
633
655
|
// Armed instead, so the drain that runs the queued message repairs it.
|
|
634
656
|
expect(turn.conversation.pendingInterruptRepair).toBe(true);
|
|
635
657
|
expect(turn.activityEvents).toEqual([]);
|
|
658
|
+
// The message joins the queue rather than replacing the turn, so it is not
|
|
659
|
+
// the message that interrupted anything and carries no note.
|
|
660
|
+
expect(turn.conversation.pendingInterruptNote).toBe(false);
|
|
636
661
|
});
|
|
637
662
|
|
|
638
663
|
test("a second interrupt stops the turn the first one started", async () => {
|
|
@@ -932,7 +957,7 @@ describe("repairInterruptedToolUseBlocks", () => {
|
|
|
932
957
|
} as unknown as Conversation;
|
|
933
958
|
}
|
|
934
959
|
|
|
935
|
-
test("writes one result per abandoned call
|
|
960
|
+
test("writes one fact-only result per abandoned call", async () => {
|
|
936
961
|
const messages: Message[] = [assistantWithToolUse("tool-1", "tool-2")];
|
|
937
962
|
|
|
938
963
|
await repairInterruptedToolUseBlocks(fakeConversation(messages), {
|
|
@@ -961,31 +986,6 @@ describe("repairInterruptedToolUseBlocks", () => {
|
|
|
961
986
|
);
|
|
962
987
|
});
|
|
963
988
|
|
|
964
|
-
test("reports the preempted result it wrote", async () => {
|
|
965
|
-
const messages: Message[] = [assistantWithToolUse("tool-1")];
|
|
966
|
-
|
|
967
|
-
const result = await repairInterruptedToolUseBlocks(
|
|
968
|
-
fakeConversation(messages),
|
|
969
|
-
{ force: true },
|
|
970
|
-
);
|
|
971
|
-
|
|
972
|
-
expect(result.preemptedToolResultOnTail).toBe(true);
|
|
973
|
-
});
|
|
974
|
-
|
|
975
|
-
test("reports no preempted result on a history that ends on plain text", async () => {
|
|
976
|
-
const messages: Message[] = [
|
|
977
|
-
{ role: "user", content: [{ type: "text", text: "hi" }] },
|
|
978
|
-
{ role: "assistant", content: [{ type: "text", text: "hello" }] },
|
|
979
|
-
];
|
|
980
|
-
|
|
981
|
-
const result = await repairInterruptedToolUseBlocks(
|
|
982
|
-
fakeConversation(messages),
|
|
983
|
-
{ force: true },
|
|
984
|
-
);
|
|
985
|
-
|
|
986
|
-
expect(result.preemptedToolResultOnTail).toBe(false);
|
|
987
|
-
});
|
|
988
|
-
|
|
989
989
|
test("adds nothing when the loop already wrote its own results", async () => {
|
|
990
990
|
// The agent loop's abort handler synthesizes results of its own when it
|
|
991
991
|
// unwinds cleanly, so exactly one result per call exists either way.
|
|
@@ -994,14 +994,12 @@ describe("repairInterruptedToolUseBlocks", () => {
|
|
|
994
994
|
toolResult("tool-1", PREEMPTED_TOOL_RESULT_TEXT),
|
|
995
995
|
];
|
|
996
996
|
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
);
|
|
997
|
+
await repairInterruptedToolUseBlocks(fakeConversation(messages), {
|
|
998
|
+
force: true,
|
|
999
|
+
});
|
|
1001
1000
|
|
|
1002
1001
|
expect(messages).toHaveLength(2);
|
|
1003
1002
|
expect(persisted).toEqual([]);
|
|
1004
|
-
expect(result.preemptedToolResultOnTail).toBe(true);
|
|
1005
1003
|
});
|
|
1006
1004
|
|
|
1007
1005
|
test("does nothing on a history that ends on an ordinary assistant reply", async () => {
|
|
@@ -1083,3 +1081,19 @@ describe("abortedToolResultText", () => {
|
|
|
1083
1081
|
);
|
|
1084
1082
|
});
|
|
1085
1083
|
});
|
|
1084
|
+
|
|
1085
|
+
describe("the interrupt texts", () => {
|
|
1086
|
+
test("the tool result states the fact and nothing more", () => {
|
|
1087
|
+
// Repeated once per stopped call in a parallel batch, so an instruction
|
|
1088
|
+
// here is read as many times as the batch was wide.
|
|
1089
|
+
expect(PREEMPTED_TOOL_RESULT_TEXT).toBe(
|
|
1090
|
+
"Stopped early because the user sent a new message. This call may have completed anyway; check before repeating it.",
|
|
1091
|
+
);
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
test("the note carries the behavior and leads with the reply", () => {
|
|
1095
|
+
expect(INTERRUPTED_TURN_NOTE_TEXT).toBe(
|
|
1096
|
+
"<interrupted_turn>The user sent this while you were still working, so that work stopped. Reply right away, in a line or two, before any more thinking or tool calls, so the user knows you heard them. Then pick the earlier work back up only if it is still wanted. Never apologize for or mention the interruption.</interrupted_turn>",
|
|
1097
|
+
);
|
|
1098
|
+
});
|
|
1099
|
+
});
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* metadata is what rebuilds the identical block on every later load.
|
|
2
|
+
* The message that interrupted a turn carries the note saying what to do about
|
|
3
|
+
* the interruption, on its LLM-facing content only: the persisted row stays
|
|
4
|
+
* exactly what the user typed, so no client renders it, and
|
|
5
|
+
* `interruptedPriorTurn` in the row's metadata is what rebuilds the identical
|
|
6
|
+
* block on every later load.
|
|
8
7
|
*/
|
|
9
8
|
import { beforeEach, describe, expect, test } from "bun:test";
|
|
10
9
|
|
|
@@ -15,37 +15,6 @@ import type { Conversation } from "./conversation.js";
|
|
|
15
15
|
|
|
16
16
|
const log = getLogger("conversation-interrupt-repair");
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
* What the repair leaves behind, for the caller that has to decide whether the
|
|
20
|
-
* model was told its turn was cut off.
|
|
21
|
-
*/
|
|
22
|
-
export interface InterruptRepairResult {
|
|
23
|
-
/**
|
|
24
|
-
* True when the tail of the history carries a preempted `tool_result`,
|
|
25
|
-
* whether the agent loop's own abort handler wrote it or this repair did.
|
|
26
|
-
*
|
|
27
|
-
* That result is the model's notice that a message preempted it. False means
|
|
28
|
-
* the abort landed with no tool call in flight, so the notice has to ride on
|
|
29
|
-
* the interrupting user message instead.
|
|
30
|
-
*/
|
|
31
|
-
preemptedToolResultOnTail: boolean;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Whether the last message answers a tool call with the preemption text. */
|
|
35
|
-
function tailCarriesPreemptedToolResult(messages: Message[]): boolean {
|
|
36
|
-
const tail = messages.at(-1);
|
|
37
|
-
if (!tail || tail.role !== "user") {
|
|
38
|
-
return false;
|
|
39
|
-
}
|
|
40
|
-
return tail.content.some(
|
|
41
|
-
(block) =>
|
|
42
|
-
// Only a synthetic tool_result carries the preemption text; a
|
|
43
|
-
// server-side web_search_tool_result never does.
|
|
44
|
-
block.type === "tool_result" &&
|
|
45
|
-
block.content === PREEMPTED_TOOL_RESULT_TEXT,
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
18
|
/**
|
|
50
19
|
* Give every abandoned `tool_use` block at the tail of the history a
|
|
51
20
|
* `tool_result`, so the next provider request is well formed.
|
|
@@ -78,22 +47,18 @@ function tailCarriesPreemptedToolResult(messages: Message[]): boolean {
|
|
|
78
47
|
export async function repairInterruptedToolUseBlocks(
|
|
79
48
|
conversation: Conversation,
|
|
80
49
|
options: { force?: boolean; requireDurable?: boolean } = {},
|
|
81
|
-
): Promise<
|
|
50
|
+
): Promise<void> {
|
|
82
51
|
const wasSteerArmed = conversation.pendingSteerRepair;
|
|
83
52
|
const wasArmed = conversation.pendingInterruptRepair;
|
|
84
53
|
if (!wasSteerArmed && !wasArmed && options.force !== true) {
|
|
85
|
-
return
|
|
86
|
-
preemptedToolResultOnTail: tailCarriesPreemptedToolResult(
|
|
87
|
-
conversation.messages,
|
|
88
|
-
),
|
|
89
|
-
};
|
|
54
|
+
return;
|
|
90
55
|
}
|
|
91
56
|
conversation.pendingSteerRepair = false;
|
|
92
57
|
conversation.pendingInterruptRepair = false;
|
|
93
58
|
|
|
94
59
|
const messages = conversation.messages;
|
|
95
60
|
if (messages.length === 0) {
|
|
96
|
-
return
|
|
61
|
+
return;
|
|
97
62
|
}
|
|
98
63
|
|
|
99
64
|
// Walk backwards from the tail to find the last assistant message with
|
|
@@ -127,10 +92,7 @@ export async function repairInterruptedToolUseBlocks(
|
|
|
127
92
|
if (pendingToolUseIds.length === 0) {
|
|
128
93
|
// Nothing dangling either because the turn made no tool call before the
|
|
129
94
|
// abort, or because the loop's abort handler already answered the batch.
|
|
130
|
-
|
|
131
|
-
return {
|
|
132
|
-
preemptedToolResultOnTail: tailCarriesPreemptedToolResult(messages),
|
|
133
|
-
};
|
|
95
|
+
return;
|
|
134
96
|
}
|
|
135
97
|
|
|
136
98
|
log.info(
|
|
@@ -181,5 +143,4 @@ export async function repairInterruptedToolUseBlocks(
|
|
|
181
143
|
"Failed to persist the synthetic tool_result repair row; the in-memory history carries the repair and the next turn runs on it",
|
|
182
144
|
);
|
|
183
145
|
}
|
|
184
|
-
return { preemptedToolResultOnTail: true };
|
|
185
146
|
}
|
|
@@ -316,13 +316,11 @@ export async function interruptRunningTurn(
|
|
|
316
316
|
}
|
|
317
317
|
|
|
318
318
|
let repaired = false;
|
|
319
|
-
let preemptedToolResultOnTail = false;
|
|
320
319
|
try {
|
|
321
|
-
|
|
320
|
+
await repairInterruptedToolUseBlocks(conversation, {
|
|
322
321
|
force: true,
|
|
323
322
|
requireDurable: true,
|
|
324
323
|
});
|
|
325
|
-
preemptedToolResultOnTail = repair.preemptedToolResultOnTail;
|
|
326
324
|
repaired = true;
|
|
327
325
|
} catch (err) {
|
|
328
326
|
// The repair row is not durable, so the caller must not write the
|
|
@@ -352,14 +350,12 @@ export async function interruptRunningTurn(
|
|
|
352
350
|
// busy conversation with nothing running and no path back to idle.
|
|
353
351
|
conversation.pendingInterruptActivityBridge = true;
|
|
354
352
|
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
|
|
361
|
-
conversation.pendingInterruptNote = true;
|
|
362
|
-
}
|
|
353
|
+
// Arm the note the interrupting user message carries. It is the one place
|
|
354
|
+
// the behavior after an interrupt is spelled out, so it rides on every
|
|
355
|
+
// handover, tool call stopped or not: a synthetic `tool_result` states only
|
|
356
|
+
// what happened to the one call it answers, and it lands ahead of the user's
|
|
357
|
+
// words rather than on them.
|
|
358
|
+
conversation.pendingInterruptNote = true;
|
|
363
359
|
|
|
364
360
|
return "released";
|
|
365
361
|
}
|
|
@@ -1611,10 +1611,12 @@ export async function persistQueuedMessageBody(
|
|
|
1611
1611
|
updateMessageMetadata(persistedUserMessage.id, { attachmentStoredPaths });
|
|
1612
1612
|
}
|
|
1613
1613
|
|
|
1614
|
-
//
|
|
1615
|
-
// the model
|
|
1616
|
-
//
|
|
1617
|
-
//
|
|
1614
|
+
// This is the first user row after a handover, so it carries the note that
|
|
1615
|
+
// tells the model what to do about the work the handover stopped. The note
|
|
1616
|
+
// goes on this row because that work sits directly above it in the history,
|
|
1617
|
+
// which is also why it is usually the interrupting message and never has to
|
|
1618
|
+
// be the one that armed the flag. Consumed here, once: a later message sits
|
|
1619
|
+
// under a completed turn, where the note would be stale. Stamped after the
|
|
1618
1620
|
// insert, like the stored paths above, so a persist that never lands
|
|
1619
1621
|
// leaves the flag armed for the send that replaces it.
|
|
1620
1622
|
const carriesInterruptNote = ctx.pendingInterruptNote === true;
|
|
@@ -796,17 +796,22 @@ export class Conversation {
|
|
|
796
796
|
*/
|
|
797
797
|
pendingInterruptActivityBridge = false;
|
|
798
798
|
/**
|
|
799
|
-
* Set by `interruptRunningTurn`
|
|
800
|
-
*
|
|
801
|
-
* interrupting user message, which appends
|
|
799
|
+
* Set by `interruptRunningTurn` on every handover it completes, and consumed
|
|
800
|
+
* exactly once by the first user message persisted after it, which appends
|
|
802
801
|
* {@link INTERRUPTED_TURN_NOTE_TEXT} to that message's LLM-facing content
|
|
803
802
|
* and stamps `interruptedPriorTurn` on the row.
|
|
804
803
|
*
|
|
805
|
-
*
|
|
806
|
-
* `tool_result`
|
|
807
|
-
*
|
|
808
|
-
*
|
|
809
|
-
*
|
|
804
|
+
* The note is the only place the behavior after an interrupt is spelled out.
|
|
805
|
+
* A synthetic `tool_result` states what happened to the one call it answers
|
|
806
|
+
* and nothing more, so an interrupt caught mid-tool arms this too.
|
|
807
|
+
*
|
|
808
|
+
* It belongs to the history position, not to the send that armed it. That
|
|
809
|
+
* first row is the one sitting directly under the work the handover stopped,
|
|
810
|
+
* so it is the row whose note the model reads in the right place. Normally it
|
|
811
|
+
* is the interrupting message itself. When that send loses the lock race and
|
|
812
|
+
* queues, the message that persists first was also sent while the assistant
|
|
813
|
+
* was working, and the note is true of it; the queued one drains after a
|
|
814
|
+
* completed turn, where the same note would be stale.
|
|
810
815
|
* @internal
|
|
811
816
|
*/
|
|
812
817
|
pendingInterruptNote = false;
|
|
@@ -489,10 +489,10 @@ export const messageMetadataSchema = z
|
|
|
489
489
|
*/
|
|
490
490
|
attachmentStoredPaths: z.record(z.string(), z.string()).optional(),
|
|
491
491
|
/**
|
|
492
|
-
* Marks a role-`"user"` row whose arrival interrupted a turn
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
492
|
+
* Marks a role-`"user"` row whose arrival interrupted a running turn.
|
|
493
|
+
* `loadFromDb` rebuilds the LLM-facing `<interrupted_turn>` note from it;
|
|
494
|
+
* the row's own content is exactly what the user sent, so clients render
|
|
495
|
+
* nothing extra.
|
|
496
496
|
*/
|
|
497
497
|
interruptedPriorTurn: z.boolean().optional(),
|
|
498
498
|
memoryInjectedBlock: z.string().optional(),
|
package/src/runtime/AGENTS.md
CHANGED
|
@@ -24,7 +24,7 @@ Queueing remains the fallback, not a legacy path, and every non-`released` outco
|
|
|
24
24
|
|
|
25
25
|
A repair that fell back to the queue carries its durable requirement onto the drain that runs the queued message, and `pendingInterruptRepair` is what carries it: the drain reads that flag and asks `repairInterruptedToolUseBlocks` for `requireDurable`, so a second persist failure leaves the flag armed and the message queued instead of persisting the prompt behind an unmatched durable `tool_use`. Only a steered drain settles for an in-memory repair, because it has no row of its own to persist behind it.
|
|
26
26
|
|
|
27
|
-
**The
|
|
27
|
+
**The two interrupt texts are split by job, and the behavior instruction lives in exactly one of them.** A synthetic `tool_result` (`PREEMPTED_TOOL_RESULT_TEXT`) states the fact about the one call it answers: the call stopped early and may have taken effect anyway. It is repeated once per stopped call in a parallel batch, so it holds no instruction. Every completed handover arms `pendingInterruptNote`, whether or not a tool call was stopped. The persist of the interrupting user message consumes that flag exactly once: it stamps `interruptedPriorTurn` on the row and appends `INTERRUPTED_TURN_NOTE_TEXT` to the message's LLM-facing content only, so the persisted row stays what the user typed and no client renders the note. That note is the single place the behavior after an interrupt is written, it leads with the short acknowledgement the user is waiting for, and it sits after the user's words instead of before them. `reinjectInterruptTurnNote` rebuilds the identical block at history load, next to the attachment annotations and for the same reason: a reload that rebuilds it differently breaks provider prefix-cache parity. Do not add a third annotation, and do not put instructions back in the tool result.
|
|
28
28
|
|
|
29
29
|
Two ordering rules a change here must not break. The synthetic `tool_result` is persisted BEFORE the caller writes the interrupting user row, because a user message between a `tool_use` and its result is a sequence every provider rejects. And the `thinking` / `message_interrupted` activity state is emitted by the agent loop at the head of the replacement turn, never at the handover: activity states are cached in `lastActivityStateMsg` and replayed to reconnecting clients, so a send that fails before its turn starts would otherwise strand every client on a busy conversation that is running nothing.
|
|
30
30
|
|
|
@@ -78,49 +78,41 @@ export function isUserInterruptAbort(reason: AbortReason | undefined): boolean {
|
|
|
78
78
|
*/
|
|
79
79
|
export const CANCELLED_TOOL_RESULT_TEXT = "Cancelled by user";
|
|
80
80
|
|
|
81
|
-
/**
|
|
82
|
-
* The instruction both interrupt annotations end on: answer the message that
|
|
83
|
-
* interrupted, then decide what happens to the work it cut off.
|
|
84
|
-
*
|
|
85
|
-
* Shared so the two annotations cannot drift. Resuming the work inline is the
|
|
86
|
-
* trap the last clause closes: the reply the user is waiting for arrives only
|
|
87
|
-
* once the resumed work finishes, so an interrupt that was meant to get their
|
|
88
|
-
* question answered first buys nothing. A subagent carries the work in
|
|
89
|
-
* parallel and leaves the conversation free.
|
|
90
|
-
*/
|
|
91
|
-
const INTERRUPT_PRIORITY_INSTRUCTION =
|
|
92
|
-
"reply to it first, then decide whether to resume or abandon the work that was in progress, and if it is still wanted and too big to finish inline, hand it to a subagent so it continues in parallel instead of holding up the conversation. This is the normal way a conversation flows, not a problem: do not apologize, do not mention that anything was stopped or interrupted, and do not ask the user to wait.";
|
|
93
|
-
|
|
94
81
|
/**
|
|
95
82
|
* Synthetic `tool_result` text for a tool call a newly arrived user message
|
|
96
83
|
* cut off.
|
|
97
84
|
*
|
|
85
|
+
* States the fact about this one call and nothing else. What the model should
|
|
86
|
+
* do about the interruption belongs on the interrupting message
|
|
87
|
+
* ({@link INTERRUPTED_TURN_NOTE_TEXT}), which is read once no matter how many
|
|
88
|
+
* calls the abort stopped and which sits after the user's words rather than
|
|
89
|
+
* before them.
|
|
90
|
+
*
|
|
98
91
|
* Distinct from {@link CANCELLED_TOOL_RESULT_TEXT} because the model's next
|
|
99
92
|
* decision is different. A plain cancel ends the work; a preemption means a
|
|
100
|
-
* message is waiting, the abandoned call may well have taken effect on the
|
|
101
|
-
* outside world before the abort landed
|
|
102
|
-
* message before it decides what to do about the work it was doing.
|
|
93
|
+
* message is waiting, and the abandoned call may well have taken effect on the
|
|
94
|
+
* outside world before the abort landed.
|
|
103
95
|
*/
|
|
104
|
-
export const PREEMPTED_TOOL_RESULT_TEXT =
|
|
96
|
+
export const PREEMPTED_TOOL_RESULT_TEXT =
|
|
97
|
+
"Stopped early because the user sent a new message. This call may have completed anyway; check before repeating it.";
|
|
105
98
|
|
|
106
99
|
/**
|
|
107
100
|
* Annotation appended to the LLM-facing content of the user message that
|
|
108
|
-
* interrupted a turn
|
|
109
|
-
* flight.
|
|
101
|
+
* interrupted a turn.
|
|
110
102
|
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* the new message and drops the work it had just committed to.
|
|
103
|
+
* The one place the behavior after an interrupt is spelled out, and it rides
|
|
104
|
+
* on every interrupt, whether or not a tool call was stopped. Leading with the
|
|
105
|
+
* short reply is the point: the user is waiting to learn they were heard, and
|
|
106
|
+
* a turn that opens with more reasoning or another tool call leaves them
|
|
107
|
+
* staring at silence.
|
|
117
108
|
*
|
|
118
109
|
* Tagged rather than written as prose so it reads as a system annotation on
|
|
119
110
|
* the message instead of something the user typed. It rides on the LLM-facing
|
|
120
111
|
* content only: the persisted row stays exactly what the user sent, so no
|
|
121
112
|
* client renders it.
|
|
122
113
|
*/
|
|
123
|
-
export const INTERRUPTED_TURN_NOTE_TEXT =
|
|
114
|
+
export const INTERRUPTED_TURN_NOTE_TEXT =
|
|
115
|
+
"<interrupted_turn>The user sent this while you were still working, so that work stopped. Reply right away, in a line or two, before any more thinking or tool calls, so the user knows you heard them. Then pick the earlier work back up only if it is still wanted. Never apologize for or mention the interruption.</interrupted_turn>";
|
|
124
116
|
|
|
125
117
|
/**
|
|
126
118
|
* The synthetic `tool_result` text that matches why the turn was aborted.
|