mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(acp): run cron and background-notification turns on the shared loop (#16190)
* feat(acp): run cron and background-notification turns on the shared loop With experimental.unifiedAgentLoop on, ACP's cron turns (loop ticks included) run as Cron rounds and its background-notification turns as Notification rounds on the shared round, the shared tool batch and the client's stop point, with the Todo Stop Guard as their stop policy. The prompt loop's shared round moves into #runSharedRound, which the prompt, cron and notification loops share; the prompt loop passes in what only a prompt has. ACP keeps the scheduling around these turns, records the cron prompt once it reached history and the notification with its task, and shows each notification response as one background message. userTurnRecordedByHost also covers Cron and Notification sends, whose record the host writes. The ACP parity suite gains a background-shell scenario whose result starts a notification turn. Part of #15327. * fix(acp): settle shared cron and notification turns as the current path does A cron turn that loop protection or a declined approval stopped is no longer delivered to its channel, as the current path returns before delivery. The cron prompt is recorded as the first event of its response arrives, before the client records the response, so the record keeps the prompt ahead of its answer. A cron or notification round that threw on its aborted signal, which only the session aborts, ends as cancelled rather than firing StopFailure. The cron turn counts its rounds as they start, so a round that throws still counts. The automatic loop emits a response's usage through the shared round's emitResponseUsage instead of repeating it, and the shared round destructures what it aliased. * docs(design): list the cron prompt a cancel before its first request keeps
This commit is contained in:
1 parent
1b689dd4be
commit
68fb173041
7 files changed
+1593
-721
No files matched your search
@@ -176,11 +176,11 @@ A policy affects only the surface that passes it.
|
||||
|
||||
Some surfaces did part of the interaction step themselves before they moved, and keep doing it on the shared path for a while. A send option then tells the client not to do that part, so it does not run twice. Each such option is a transition step, not a lasting API: it is listed here with the step that removes it or turns it into an input.
|
||||
|
||||
| Option | Why the host keeps the part | Removed by |
|
||||
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `toolLoopGuardsByHost` | ACP's tool step runs the daemon tool-loop guards: the per-turn tool-call cap, repeated calls, the exploration reminder and the repeated-tool-failure guard | PR 2b for a fresh prompt's tool batches: the client's guards apply, and ACP keeps the ones only it has, its parameter-error, partial-edit and repeated-tool-failure stops, as its policy. A session with live tools and an Agent Host session keep ACP's tool step and this option until PR 2d |
|
||||
| `interactionSpanByHost` | ACP's span is parented to the daemon's trace | an input instead: the client joins the span the host started for the prompt; before the default changes for ACP |
|
||||
| `userTurnRecordedByHost` | ACP records the prompt as typed, with its display text, attachments, resource links and daemon prompt id, before slash commands run, and takes the turn's file-history snapshot before its first send, so a prompt cancelled before it is sent still has one; on a retry, a continue or a restore it has started the todo work chain; on a Goal round it recorded the runtime continuation's message | an input instead: the host passes that record and the client writes it; before the default changes for ACP |
|
||||
| Option | Why the host keeps the part | Removed by |
|
||||
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `toolLoopGuardsByHost` | ACP's tool step runs the daemon tool-loop guards: the per-turn tool-call cap, repeated calls, the exploration reminder and the repeated-tool-failure guard | PR 2b for a fresh prompt's tool batches: the client's guards apply, and ACP keeps the ones only it has, its parameter-error, partial-edit and repeated-tool-failure stops, as its policy. A session with live tools and an Agent Host session keep ACP's tool step and this option until PR 2d |
|
||||
| `interactionSpanByHost` | ACP's span is parented to the daemon's trace | an input instead: the client joins the span the host started for the prompt; before the default changes for ACP |
|
||||
| `userTurnRecordedByHost` | ACP records the prompt as typed, with its display text, attachments, resource links and daemon prompt id, before slash commands run, and takes the turn's file-history snapshot before its first send, so a prompt cancelled before it is sent still has one; on a retry, a continue or a restore it has started the todo work chain; on a Goal round it recorded the runtime continuation's message; on a cron round it records the prompt once its request reached history, and on a background-notification round the notification with the task it reports | an input instead: the host passes that record and the client writes it; before the default changes for ACP |
|
||||
|
||||
`toolCallPreparations`, `memoryRecallQuery`, `stopPolicies` and `historyPromptId` are not in the table: the first asks for an extra event, the second gives recall the prompt's text, which the host expanded before the send, the third adds the host's reasons to continue at a stop point ([Stop-point continuations](#stop-point-continuations)), and the fourth gives a `Retry` the history identity of the attempt it replaces; none turns anything off. PR 2c-1 removed `stopHookRunByHost`, with which ACP fired the Stop hook in its own Stop loop.
|
||||
|
||||
@@ -351,6 +351,21 @@ Input a round took as its response finished is answered within the turn, as toda
|
||||
|
||||
The core changes of this step: `userTurnRecordedByHost` also covers a `Goal` round, whose message the host recorded, and a stop policy's continuation keeps the Goal fields of its send, so it runs under the same permit.
|
||||
|
||||
### ACP cron and background-notification turns (PR 2c-4)
|
||||
|
||||
PR 2c-4 puts ACP's last two kinds of turn on the shared round, the shared tool batch and the client's stop point: the cron turns the scheduler fires, loop ticks included, and the turns a background task's result starts. ACP keeps the scheduling around the turn as today: the queues, the admission of a notification turn, the echo of a cron prompt, the start and end notifications, and the channel delivery of a scheduled reply.
|
||||
|
||||
The prompt loop's shared round becomes a method that the three loops share. The prompt loop passes it what only a prompt has: putting a re-entered turn back when its request never went out, the Goal marks, and the loop stop that pauses a Goal. The cron and notification loops run their tool batches around it as the prompt loop does.
|
||||
|
||||
| Turn | With the setting on |
|
||||
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| cron | a `Cron` round; the client resets the turn's memory state, adds its system and todo reminders and recalls memory, as for the other surfaces; ACP records the prompt once its request reached history, as today |
|
||||
| background notification | a `Notification` round; ACP records the notification and adds its system and todo reminders, as today, and shows each response as one background message once it ended |
|
||||
|
||||
Both kinds of turn run the Todo Stop Guard as their stop policy, after the Stop hook. Queued input does not cut their responses short, as today, and is taken at a tool boundary; it no longer continues a notification turn at its stop point, but starts the next turn, as on a prompt since PR 2c-1. A cron turn keeps ACP's interaction span; a notification turn, which has none on ACP's side, gets the client's.
|
||||
|
||||
The core change of this step: `userTurnRecordedByHost` also covers a `Cron` and a `Notification` send, whose prompt or notification the host records.
|
||||
|
||||
## Migration
|
||||
|
||||
| PR | Content |
|
||||
@@ -428,7 +443,8 @@ A difference fails the scenario unless it is listed with a linked issue.
|
||||
- a UserPromptSubmit hook that blocks;
|
||||
- a Stop hook that blocks once, and one that blocks up to the cap;
|
||||
- the Todo Stop Guard continuing past unfinished todos until its attempts are spent;
|
||||
- a Goal whose first continuation stops on its own and whose second proposes it complete, which the verifier accepts.
|
||||
- a Goal whose first continuation stops on its own and whose second proposes it complete, which the verifier accepts;
|
||||
- a background shell whose result arrives after the prompt ended and starts a notification turn.
|
||||
|
||||
A continuation's request runs under a prompt id of its own on the current loop and under the turn's prompt id on the shared loop; the suite compares them as one.
|
||||
|
||||
@@ -471,6 +487,11 @@ The suite runs in the integration job. A scheduled job also runs the whole headl
|
||||
- a retry, a continue and a restore start the client's interaction: its loop detector, exploration budget and turn budget start over, and their first round does not count toward the session turn cap;
|
||||
- a continue whose first request never goes out, cancelled or failed, puts the interrupted user run back as it was, instead of keeping it as one merged entry, and the worktree notice the merged entry took stays pending for the next turn;
|
||||
- from PR 2c-3, loop protection that stops a Goal turn, the client's or ACP's, pauses the Goal with the loop as the reason, as on the other surfaces, instead of finishing the turn and letting the runtime schedule the next continuation;
|
||||
- from PR 2c-4, a cron or background-notification turn runs what a prompt runs on the shared path: the client's loop heuristics and session turn cap, the Stop hook and its continuations, StopFailure when its model request fails, and the shared tool batch with the client's tool-call guards, where a tool that ends the turn ends it, unless the cron job delivers its reply to a channel;
|
||||
- a cron turn's system reminders are the client's, and it carries the client's memory recall; a notification turn gets an interaction span;
|
||||
- input queued during a notification turn, and the turn's own background results, no longer continue it at its stop point; they start the next turn;
|
||||
- a cron turn cancelled between rounds keeps the results of its last tool batch in history, as a prompt does, and a notification turn whose response is retried shows only the retried text;
|
||||
- a cron turn cancelled after its echo and before its first request keeps its prompt in history and in the record, as a cron turn cancelled while its request is going out does today;
|
||||
|
||||
The ACP parity suite covers the common cases unchanged and asserts the date reminder, UserPromptSubmit on tool results, cancelling while that hook runs, and that a batch's approvals come before its calls run.
|
||||
|
||||
|
||||
@@ -176,11 +176,11 @@ headless 的主循环以 `maxRounds: 1` 运行。因此它为每一轮做的准
|
||||
|
||||
有些入口在迁移前自己做了交互步骤中的一部分工作,迁到共享路径后还会继续自己做一段时间。这时用一个发送选项告诉 client 跳过这部分,避免执行两次。这类选项只是迁移期的过渡手段,不是长期 API。下表逐项列出,并写明由哪一步把它删除或改成输入。
|
||||
|
||||
| 选项 | 宿主为什么保留这部分 | 由哪一步移除 |
|
||||
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `toolLoopGuardsByHost` | ACP 的工具步骤运行 daemon 工具循环守卫:每轮工具调用上限、重复调用、探索提醒,以及重复工具失败守卫 | PR 2b 迁移全新 prompt 的工具批:client 的守卫生效,只有 ACP 才有的守卫,即它的参数错误、半截编辑和重复工具失败停止,作为 ACP 的策略保留。带 live 工具的会话和 Agent Host 会话保留 ACP 的工具步骤和这个选项,直到 PR 2d |
|
||||
| `interactionSpanByHost` | ACP 的 span 挂在 daemon 的 trace 下 | 改为输入:client 加入宿主为该 prompt 开启的 span;ACP 修改默认值之前完成 |
|
||||
| `userTurnRecordedByHost` | ACP 在 slash 命令执行前,按用户输入的原样记录 prompt,连同显示文本、附件、resource link 和 daemon prompt id 一起记录,并在首次发送前为这一轮建立文件历史快照,所以发出前就被取消的 prompt 也有快照;在 retry、continue 或 restore 上,宿主已经启动了 todo 工作链;在 Goal 模型轮上,宿主已经记录了 runtime 续写的消息 | 改为输入:宿主传入这条记录,由 client 写入;ACP 修改默认值之前完成 |
|
||||
| 选项 | 宿主为什么保留这部分 | 由哪一步移除 |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `toolLoopGuardsByHost` | ACP 的工具步骤运行 daemon 工具循环守卫:每轮工具调用上限、重复调用、探索提醒,以及重复工具失败守卫 | PR 2b 迁移全新 prompt 的工具批:client 的守卫生效,只有 ACP 才有的守卫,即它的参数错误、半截编辑和重复工具失败停止,作为 ACP 的策略保留。带 live 工具的会话和 Agent Host 会话保留 ACP 的工具步骤和这个选项,直到 PR 2d |
|
||||
| `interactionSpanByHost` | ACP 的 span 挂在 daemon 的 trace 下 | 改为输入:client 加入宿主为该 prompt 开启的 span;ACP 修改默认值之前完成 |
|
||||
| `userTurnRecordedByHost` | ACP 在 slash 命令执行前,按用户输入的原样记录 prompt,连同显示文本、附件、resource link 和 daemon prompt id 一起记录,并在首次发送前为这一轮建立文件历史快照,所以发出前就被取消的 prompt 也有快照;在 retry、continue 或 restore 上,宿主已经启动了 todo 工作链;在 Goal 模型轮上,宿主已经记录了 runtime 续写的消息;在 cron 模型轮上,宿主在请求进入历史后记录 prompt;在后台通知模型轮上,宿主记录通知及其报告的任务 | 改为输入:宿主传入这条记录,由 client 写入;ACP 修改默认值之前完成 |
|
||||
|
||||
`toolCallPreparations`、`memoryRecallQuery`、`stopPolicies` 和 `historyPromptId` 不在表中:第一个只是请求一个额外的事件,第二个把宿主在发送前展开的 prompt 原文交给召回,第三个加入宿主在 stop 点继续的理由(见 [stop 点续发](#stop-点续发)),第四个让 `Retry` 沿用它所替代那次尝试的历史标识;四者都不关闭任何行为。PR 2c-1 删除了 `stopHookRunByHost`,ACP 原来用它在自己的 Stop 循环里触发 Stop hook。
|
||||
|
||||
@@ -351,6 +351,21 @@ PR 2c-3 让 ACP 的 Goal 轮次使用共享模型轮、共享工具批和 client
|
||||
|
||||
这一步对 core 的改动:`userTurnRecordedByHost` 也覆盖 `Goal` 模型轮,表示宿主已经记录了它的消息;stop policy 的续发请求保留原请求的 Goal 字段,因此在同一个 permit 下运行。
|
||||
|
||||
### ACP 的 cron 和后台通知轮次(PR 2c-4)
|
||||
|
||||
PR 2c-4 让 ACP 最后两类轮次使用共享模型轮、共享工具批和 client 的 stop 点:调度器触发的 cron 轮次(包括 loop tick),以及后台任务结果启动的轮次。轮次外围的调度和现在一样留在 ACP:队列、通知轮次的准入、cron prompt 的回显、开始和结束通知,以及定时回复的 channel 投递。
|
||||
|
||||
prompt 循环里的共享模型轮改为三个循环共用的方法。prompt 循环把只有 prompt 才有的部分传给它:请求没有发出时把重新进入的轮次放回原样、Goal 标记,以及暂停 Goal 的循环停止。cron 和通知循环像 prompt 循环一样,在它外面运行自己的工具批。
|
||||
|
||||
| 轮次 | 设置开启后 |
|
||||
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| cron | `Cron` 模型轮;和其他界面一样,由 client 重置这一轮的记忆状态、加上它的系统提醒和 todo 提醒并召回记忆;ACP 和现在一样在请求进入历史后记录 prompt |
|
||||
| 后台通知 | `Notification` 模型轮;ACP 和现在一样记录通知、加上它的系统提醒和 todo 提醒,并在每个响应结束后把它作为一条后台消息显示 |
|
||||
|
||||
两类轮次都在 Stop hook 之后运行 Todo Stop Guard 作为 stop 策略。排队的输入和现在一样不会打断它们的响应,在工具边界取入;在 stop 点它不再让通知轮次继续,而是启动下一轮,和 PR 2c-1 以来的 prompt 一样。cron 轮次保留 ACP 的交互 span;通知轮次在 ACP 一侧没有 span,改用 client 的。
|
||||
|
||||
这一步对 core 的改动:`userTurnRecordedByHost` 也覆盖 `Cron` 和 `Notification` 发送,表示由宿主记录 prompt 或通知。
|
||||
|
||||
## 迁移计划
|
||||
|
||||
| PR | 内容 |
|
||||
@@ -428,7 +443,8 @@ PR 3–6 每一项之后,同样各自跟随默认开启和删除旧 loop 两
|
||||
- 拦截 prompt 的 UserPromptSubmit hook;
|
||||
- 拦截一次的 Stop hook,以及一直拦截到上限的 Stop hook;
|
||||
- Todo Stop Guard 在还有未完成 todo 时继续,直到用完尝试次数;
|
||||
- 一个 Goal:第一次续写自行停下,第二次续写提出 Goal 已完成,verifier 接受。
|
||||
- 一个 Goal:第一次续写自行停下,第二次续写提出 Goal 已完成,verifier 接受;
|
||||
- 一个后台 shell:它的结果在 prompt 结束后到达,启动一个通知轮次。
|
||||
|
||||
续发请求在现有 loop 上使用自己的 prompt id,在共享 loop 上沿用这一轮的 prompt id;对拍测试把两者视为相同。
|
||||
|
||||
@@ -471,6 +487,11 @@ PR 3–6 每一项之后,同样各自跟随默认开启和删除旧 loop 两
|
||||
- retry、continue 和 restore 会开启 client 的一次交互:循环检测器、探索预算和轮次预算重新开始,它们的第一轮不计入会话轮数上限;
|
||||
- continue 的第一条请求一直没有发出时(被取消或失败),被打断的用户输入按原样放回,而不是作为一条合并后的条目保留,合并条目取走的 worktree 提醒也留给下一轮;
|
||||
- 从 PR 2c-3 起,循环保护(client 的或 ACP 的)停下 Goal 轮次时,和其他界面一样以循环为原因暂停 Goal,而不是结束这一轮、让 runtime 安排下一次续写;
|
||||
- 从 PR 2c-4 起,cron 或后台通知轮次运行 prompt 在共享路径上运行的内容:client 的循环启发式检查和会话轮次上限、Stop hook 及其续发、模型请求失败时的 StopFailure,以及带 client 工具调用守卫的共享工具批,其中结束轮次的工具会结束这一轮,除非这个 cron 任务把回复投递到 channel;
|
||||
- cron 轮次的系统提醒改为 client 的,并带上 client 的记忆召回;通知轮次有了交互 span;
|
||||
- 通知轮次期间排队的输入,以及这一轮自己的后台结果,不再在 stop 点让它继续,而是启动下一轮;
|
||||
- 在两个模型轮之间被取消的 cron 轮次,和 prompt 一样把最后一个工具批的结果保留在历史中;响应被重试的通知轮次只显示重试后的文本;
|
||||
- 在回显之后、第一个请求发出之前被取消的 cron 轮次,会把 prompt 保留在历史和记录中,和现在请求发出过程中被取消的 cron 轮次一样;
|
||||
|
||||
ACP 对拍测试覆盖了保持不变的常见场景,并对日期提醒、工具结果上的 UserPromptSubmit、该 hook 运行时取消,以及一批的审批先于它的调用执行做了断言。
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ interface Scenario {
|
||||
* this many Goal turns to end.
|
||||
*/
|
||||
goalTurns?: number;
|
||||
/** Waits after the prompts for this many background-notification turns to end. */
|
||||
backgroundTurns?: number;
|
||||
/** The model's reply to a request that does not stream, such as a verifier's. */
|
||||
sideReply?: (body: JsonObject) => FakeOpenAIResponse;
|
||||
/** Sends `session/cancel` once this file exists in the test directory. */
|
||||
@@ -94,6 +96,8 @@ interface Run {
|
||||
permissionsAt: number[];
|
||||
/** The stop reason of each Goal turn, in order. */
|
||||
goalTurnEnds: unknown[];
|
||||
/** The stop reason of each background-notification turn, in order. */
|
||||
backgroundTurnEnds: unknown[];
|
||||
}
|
||||
|
||||
const PROMPT_TIMEOUT_MS = 60_000;
|
||||
@@ -102,6 +106,11 @@ const PROMPT_TIMEOUT_MS = 60_000;
|
||||
const RANDOM_ID =
|
||||
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g;
|
||||
|
||||
// A background task's id, its process and the run's own home directory.
|
||||
const BACKGROUND_TASK_ID = /bg_[0-9a-f]{8}/g;
|
||||
const PROCESS_ID = /(pid>|pid: |pid )\d+/g;
|
||||
const RUN_HOME = new RegExp(`${ACP_HOME_PREFIX}[A-Za-z0-9]+`, 'g');
|
||||
|
||||
// Fields that differ between two runs of the same scenario.
|
||||
const VOLATILE_KEYS = new Set([
|
||||
'sessionId',
|
||||
@@ -155,7 +164,13 @@ function normalize(value: unknown, dir: string, sessionId = ''): unknown {
|
||||
.join('<DIR>')
|
||||
.replace(CONTINUATION_PROMPT_ID, '$1');
|
||||
if (sessionId) text = text.split(sessionId).join('<SESSION>');
|
||||
const replaced = JSON.parse(text.replace(RANDOM_ID, '<ID>'));
|
||||
const replaced = JSON.parse(
|
||||
text
|
||||
.replace(RANDOM_ID, '<ID>')
|
||||
.replace(BACKGROUND_TASK_ID, 'bg_<ID>')
|
||||
.replace(PROCESS_ID, '$1<PID>')
|
||||
.replace(RUN_HOME, '<HOME>'),
|
||||
);
|
||||
const strip = (node: unknown): unknown => {
|
||||
if (Array.isArray(node)) return node.map(strip);
|
||||
if (node && typeof node === 'object') {
|
||||
@@ -312,6 +327,7 @@ const allowOnce = () => ({
|
||||
|
||||
const updates: unknown[] = [];
|
||||
const goalTurnEnds: unknown[] = [];
|
||||
const backgroundTurnEnds: unknown[] = [];
|
||||
const permissions: unknown[] = [];
|
||||
const permissionsAt: number[] = [];
|
||||
const pending = new Map<
|
||||
@@ -378,6 +394,13 @@ const allowOnce = () => ({
|
||||
goalTurnEnds.push(params['reason']);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
method === '_qwencode/end_turn' &&
|
||||
params?.['source'] === 'background_notification'
|
||||
) {
|
||||
backgroundTurnEnds.push(params['reason']);
|
||||
return;
|
||||
}
|
||||
if (typeof id !== 'number' || typeof method !== 'string') return;
|
||||
if (method === 'session/request_permission') {
|
||||
permissions.push(params?.['toolCall']);
|
||||
@@ -461,7 +484,8 @@ const allowOnce = () => ({
|
||||
}
|
||||
const goalDeadline = Date.now() + PROMPT_TIMEOUT_MS;
|
||||
while (
|
||||
goalTurnEnds.length < (scenario.goalTurns ?? 0) &&
|
||||
(goalTurnEnds.length < (scenario.goalTurns ?? 0) ||
|
||||
backgroundTurnEnds.length < (scenario.backgroundTurns ?? 0)) &&
|
||||
Date.now() < goalDeadline
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
@@ -492,6 +516,7 @@ const allowOnce = () => ({
|
||||
permissions: permissions.map((call) => normalize(call, dir, sessionId)),
|
||||
permissionsAt,
|
||||
goalTurnEnds,
|
||||
backgroundTurnEnds,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -708,6 +733,31 @@ const allowOnce = () => ({
|
||||
content: '{"decision":"accept","reason":"The forecast was given."}',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'a background shell whose result starts a notification turn',
|
||||
prompts: ['Run the job in the background.'],
|
||||
backgroundTurns: 1,
|
||||
// The job outlasts the prompt, so its result starts a turn of its
|
||||
// own on both loops.
|
||||
reply: (_messages, _dir, index) =>
|
||||
index === 0
|
||||
? {
|
||||
toolCalls: [
|
||||
fakeToolCall(
|
||||
'run_shell_command',
|
||||
{
|
||||
command: 'sleep 2; echo job done',
|
||||
description: 'Run the job',
|
||||
is_background: true,
|
||||
},
|
||||
'call_job',
|
||||
),
|
||||
],
|
||||
}
|
||||
: index === 1
|
||||
? { content: 'The job is running.' }
|
||||
: { content: 'The job is done.' },
|
||||
},
|
||||
{
|
||||
name: 'a model error',
|
||||
prompts: ['Fail please.'],
|
||||
@@ -796,6 +846,10 @@ const allowOnce = () => ({
|
||||
expect(shared.stopReasons).toEqual(current.stopReasons);
|
||||
expect(shared.requests).toEqual(current.requests);
|
||||
expect(shared.goalTurnEnds).toEqual(current.goalTurnEnds);
|
||||
expect(shared.backgroundTurnEnds).toEqual(current.backgroundTurnEnds);
|
||||
expect(current.backgroundTurnEnds).toHaveLength(
|
||||
scenario.backgroundTurns ?? 0,
|
||||
);
|
||||
if (scenario.goalTurns) {
|
||||
expect(current.goalTurnEnds).toHaveLength(scenario.goalTurns);
|
||||
}
|
||||
|
||||
@@ -5499,19 +5499,15 @@ describe('Session', () => {
|
||||
const result = await prompt();
|
||||
|
||||
expect(result.stopReason).toBe('end_turn');
|
||||
// The prompt ends with its response, as the client's stop point does.
|
||||
expect(send).toHaveBeenCalledOnce();
|
||||
await vi.waitFor(() =>
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
message: expect.arrayContaining([{ text: 'WORKER_RESULT' }]),
|
||||
}),
|
||||
expect.stringContaining('notification'),
|
||||
undefined,
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
// The prompt ends with its response, as the client's stop point does,
|
||||
// and the result starts a notification turn of its own.
|
||||
await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(2));
|
||||
expect(send.mock.calls[1][0]).toContainEqual({ text: 'WORKER_RESULT' });
|
||||
expect(send.mock.calls[1][2]).toContain('notification');
|
||||
expect(send.mock.calls[1][3]).toMatchObject({
|
||||
type: core.SendMessageType.Notification,
|
||||
});
|
||||
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('the Todo Stop Guard as a stop policy', () => {
|
||||
@@ -5977,6 +5973,341 @@ describe('Session', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cron and background-notification turns', () => {
|
||||
/**
|
||||
* Runs each round on the mocked client; a round pushes its request
|
||||
* unless told not to, and runs `afterEvents` once its events are out.
|
||||
*/
|
||||
function automaticRounds(
|
||||
events: (index: number) => RoundEvent[] = () => [],
|
||||
pushes: (index: number) => boolean = () => true,
|
||||
afterEvents: (index: number) => Promise<void> | void = () => {},
|
||||
) {
|
||||
const sends: Array<
|
||||
[Part[], AbortSignal, string, Record<string, unknown>]
|
||||
> = [];
|
||||
let pushCount = 0;
|
||||
mockConfig.isUnifiedAgentLoopEnabled = vi.fn().mockReturnValue(true);
|
||||
Object.assign(mockChat, {
|
||||
getUserContentPushCount: vi.fn(() => pushCount),
|
||||
});
|
||||
Object.assign(mockLlmClient, {
|
||||
admitUserPrompt: vi.fn().mockResolvedValue({ blocked: false }),
|
||||
finishManagedAutoMemoryRecall: vi.fn(),
|
||||
sendMessageStream: vi.fn(
|
||||
(
|
||||
parts: Part[],
|
||||
signal: AbortSignal,
|
||||
promptId: string,
|
||||
options: Record<string, unknown>,
|
||||
) => {
|
||||
sends.push([parts, signal, promptId, options]);
|
||||
const index = sends.length - 1;
|
||||
return (async function* () {
|
||||
if (pushes(index)) pushCount++;
|
||||
for (const event of events(index)) yield event;
|
||||
await afterEvents(index);
|
||||
})();
|
||||
},
|
||||
),
|
||||
});
|
||||
mockChat.sendMessageStream = vi.fn();
|
||||
return sends;
|
||||
}
|
||||
|
||||
/** Fires one in-session cron job once the session starts its scheduler. */
|
||||
function fireCron(prompt: string, job: object = {}) {
|
||||
mockConfig.isCronEnabled = vi.fn().mockReturnValue(true);
|
||||
mockConfig.getCronScheduler = vi.fn().mockReturnValue({
|
||||
size: 1,
|
||||
hasPendingWork: true,
|
||||
start: vi.fn((callback: (job: { prompt: string }) => void) =>
|
||||
callback({ prompt, ...job }),
|
||||
),
|
||||
stop: vi.fn(),
|
||||
getExitSummary: vi.fn().mockReturnValue(undefined),
|
||||
});
|
||||
}
|
||||
|
||||
const notify = () =>
|
||||
session.enqueueBackgroundNotification({
|
||||
displayText: 'Agent completed.',
|
||||
modelText: '<task-notification />',
|
||||
taskId: 'task-1',
|
||||
status: 'completed',
|
||||
kind: 'agent',
|
||||
});
|
||||
|
||||
const backgroundResponses = () =>
|
||||
updates().filter(
|
||||
(update) =>
|
||||
update.sessionUpdate === 'agent_message_chunk' &&
|
||||
(update._meta as { source?: string } | undefined)?.source ===
|
||||
'background_notification_response',
|
||||
);
|
||||
|
||||
it('runs a cron fire as a Cron round and records its prompt once sent', async () => {
|
||||
fireCron('nightly report');
|
||||
const sends = automaticRounds((index) =>
|
||||
index === 1 ? [text('cron result'), finished()] : [finished()],
|
||||
);
|
||||
|
||||
await prompt();
|
||||
await vi.waitFor(() =>
|
||||
expect(mockChatRecordingService.recordCronPrompt).toHaveBeenCalled(),
|
||||
);
|
||||
|
||||
expect(sends[1][0]).toEqual([{ text: 'nightly report' }]);
|
||||
expect(sends[1][3]).toMatchObject({
|
||||
type: core.SendMessageType.Cron,
|
||||
userTurnRecordedByHost: true,
|
||||
interactionSpanByHost: true,
|
||||
toolCallPreparations: true,
|
||||
});
|
||||
expect(sends[1][3]['stopPolicies']).toHaveLength(1);
|
||||
expect(mockChatRecordingService.recordCronPrompt).toHaveBeenCalledWith(
|
||||
[{ text: 'nightly report' }],
|
||||
'nightly report',
|
||||
);
|
||||
await vi.waitFor(() => expect(agentText()).toContain('cron result'));
|
||||
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records a cron prompt before the response its round records', async () => {
|
||||
fireCron('nightly report');
|
||||
const order: string[] = [];
|
||||
mockChatRecordingService.recordCronPrompt.mockImplementation(() => {
|
||||
order.push('cron prompt');
|
||||
});
|
||||
// The client records a response once its stream ends.
|
||||
automaticRounds(
|
||||
(index) =>
|
||||
index === 1 ? [text('cron result'), finished()] : [finished()],
|
||||
() => true,
|
||||
(index) => {
|
||||
if (index === 1) order.push('cron response');
|
||||
},
|
||||
);
|
||||
|
||||
await prompt();
|
||||
await vi.waitFor(() => expect(order).toContain('cron response'));
|
||||
|
||||
expect(order).toEqual(['cron prompt', 'cron response']);
|
||||
});
|
||||
|
||||
describe('scheduled delivery', () => {
|
||||
const scheduledJob = {
|
||||
id: 'task-1',
|
||||
lastFiredAt: 1_750_000_000_000,
|
||||
delivery: {
|
||||
kind: 'channel',
|
||||
target: { channelName: 'dingtalk', type: 'chat', id: 'chat-1' },
|
||||
},
|
||||
};
|
||||
const deliveries = () =>
|
||||
vi
|
||||
.mocked(mockClient.extMethod)
|
||||
.mock.calls.filter(
|
||||
([method]) => method === 'qwen/control/channel-delivery',
|
||||
);
|
||||
|
||||
it('delivers a scheduled cron turn that ended on its own', async () => {
|
||||
fireCron('nightly report', scheduledJob);
|
||||
automaticRounds((index) =>
|
||||
index === 1 ? [text('cron result'), finished()] : [finished()],
|
||||
);
|
||||
|
||||
await prompt();
|
||||
|
||||
await vi.waitFor(() => expect(deliveries()).toHaveLength(1));
|
||||
expect(deliveries()[0][1]).toMatchObject({
|
||||
deliveryId: 'task-1:1750000000000',
|
||||
text: 'cron result',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not deliver a scheduled cron turn loop protection stopped', async () => {
|
||||
fireCron('nightly report', scheduledJob);
|
||||
automaticRounds((index) =>
|
||||
index === 1
|
||||
? [
|
||||
text('looping'),
|
||||
{
|
||||
type: core.LlmEventType.LoopDetected,
|
||||
value: {
|
||||
loopType: core.LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS,
|
||||
},
|
||||
} as RoundEvent,
|
||||
]
|
||||
: [finished()],
|
||||
);
|
||||
|
||||
await prompt();
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
mockLlmClient.finishManagedAutoMemoryRecall,
|
||||
).toHaveBeenCalled(),
|
||||
);
|
||||
// Delivery is submitted on the next tick.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
expect(deliveries()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('ends a cron turn cancelled mid-response as cancelled, not as a failure', async () => {
|
||||
fireCron('nightly report');
|
||||
const fireStopFailureEvent = vi.fn().mockResolvedValue(undefined);
|
||||
mockConfig.getHookSystem = vi.fn().mockReturnValue({
|
||||
fireStopFailureEvent,
|
||||
});
|
||||
mockConfig.hasHooksForEvent = vi
|
||||
.fn()
|
||||
.mockImplementation((name: string) => name === 'StopFailure');
|
||||
let release!: () => void;
|
||||
const released = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
automaticRounds(
|
||||
(index) => (index === 1 ? [text('cron partial')] : [finished()]),
|
||||
() => true,
|
||||
async (index) => {
|
||||
if (index !== 1) return;
|
||||
await released;
|
||||
const error = new Error('aborted');
|
||||
error.name = 'AbortError';
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
|
||||
await prompt();
|
||||
await vi.waitFor(() => expect(agentText()).toContain('cron partial'));
|
||||
await session.cancelPendingPrompt();
|
||||
release();
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
mockLlmClient.finishManagedAutoMemoryRecall,
|
||||
).toHaveBeenCalled(),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(fireStopFailureEvent).not.toHaveBeenCalled();
|
||||
expect(agentText()).not.toContain('cron error');
|
||||
});
|
||||
|
||||
it('keeps a cron prompt whose request never went out out of the record', async () => {
|
||||
fireCron('nightly report');
|
||||
const sends = automaticRounds(
|
||||
() => [finished()],
|
||||
(index) => index !== 1,
|
||||
);
|
||||
|
||||
await prompt();
|
||||
await vi.waitFor(() => expect(sends).toHaveLength(2));
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
mockLlmClient.finishManagedAutoMemoryRecall,
|
||||
).toHaveBeenCalled(),
|
||||
);
|
||||
|
||||
expect(
|
||||
mockChatRecordingService.recordCronPrompt,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends a cron fire's tool results back as a ToolResult round", async () => {
|
||||
fireCron('nightly report');
|
||||
const execute = useTool(core.Kind.Read, 'read_file');
|
||||
const sends = automaticRounds((index) =>
|
||||
index === 1
|
||||
? [
|
||||
toolCall({ id: 'call-1', name: 'read_file', args: {} }),
|
||||
finished(),
|
||||
]
|
||||
: [finished()],
|
||||
);
|
||||
|
||||
await prompt();
|
||||
await vi.waitFor(() => expect(sends).toHaveLength(3));
|
||||
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
expect(sends[2][3]).toMatchObject({
|
||||
type: core.SendMessageType.ToolResult,
|
||||
interactionSpanByHost: true,
|
||||
});
|
||||
expect(sends[2][3]).not.toHaveProperty('userTurnRecordedByHost');
|
||||
expect(sends[2][0]).toContainEqual(
|
||||
expect.objectContaining({
|
||||
functionResponse: expect.objectContaining({ id: 'call-1' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('runs a background notification as a Notification round and shows its response once', async () => {
|
||||
const sends = automaticRounds(() => [
|
||||
text('agent '),
|
||||
text('done'),
|
||||
finished(),
|
||||
]);
|
||||
|
||||
await notify();
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
backgroundResponses().some(
|
||||
(update) =>
|
||||
(
|
||||
update._meta as {
|
||||
backgroundTask?: { turnComplete?: boolean };
|
||||
}
|
||||
).backgroundTask?.turnComplete === true,
|
||||
),
|
||||
).toBe(true),
|
||||
);
|
||||
|
||||
expect(sends).toHaveLength(1);
|
||||
expect(sends[0][3]).toMatchObject({
|
||||
type: core.SendMessageType.Notification,
|
||||
userTurnRecordedByHost: true,
|
||||
});
|
||||
expect(sends[0][3]).not.toHaveProperty('interactionSpanByHost');
|
||||
expect(sends[0][0]).toContainEqual({ text: '<task-notification />' });
|
||||
expect(
|
||||
backgroundResponses().map(
|
||||
(update) => (update.content as { text: string }).text,
|
||||
),
|
||||
).toEqual(['agent done', '']);
|
||||
// Not streamed piece by piece as a prompt's response is.
|
||||
expect(agentText()).not.toContain('agent ');
|
||||
});
|
||||
|
||||
it('ends a notification turn that loop protection stopped as partial', async () => {
|
||||
automaticRounds(() => [
|
||||
text('looping'),
|
||||
{
|
||||
type: core.LlmEventType.LoopDetected,
|
||||
value: { loopType: core.LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS },
|
||||
} as RoundEvent,
|
||||
]);
|
||||
|
||||
await notify();
|
||||
await vi.waitFor(() =>
|
||||
expect(mockClient.extNotification).toHaveBeenCalledWith(
|
||||
'_qwencode/end_turn',
|
||||
expect.objectContaining({ source: 'background_notification' }),
|
||||
),
|
||||
);
|
||||
|
||||
const last = backgroundResponses().at(-1)!;
|
||||
expect(
|
||||
(
|
||||
last._meta as {
|
||||
backgroundTask: { turnComplete?: boolean; partial?: boolean };
|
||||
}
|
||||
).backgroundTask,
|
||||
).toMatchObject({ turnComplete: true, partial: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Managed Runtime batch close', () => {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -11099,6 +11099,39 @@ Other open files:
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ type: SendMessageType.Cron, record: 'recordCronPrompt' as const },
|
||||
{
|
||||
type: SendMessageType.Notification,
|
||||
record: 'recordNotification' as const,
|
||||
},
|
||||
])(
|
||||
'leaves the record of a $type send to a host that records it',
|
||||
async ({ type, record }) => {
|
||||
const recorder = {
|
||||
recordCronPrompt: vi.fn(),
|
||||
recordNotification: vi.fn(),
|
||||
recordAttributionSnapshot: vi.fn(),
|
||||
recordFileHistorySnapshot: vi.fn(),
|
||||
};
|
||||
vi.mocked(mockConfig.getChatRecordingService).mockReturnValue(
|
||||
recorder as unknown as ReturnType<Config['getChatRecordingService']>,
|
||||
);
|
||||
mockTurnRunFn.mockReturnValue(textTurn('Hello'));
|
||||
installChat();
|
||||
|
||||
await run([{ text: 'tick' }], `prompt-host-${type}`, {
|
||||
type,
|
||||
userTurnRecordedByHost: true,
|
||||
});
|
||||
expect(mockTurnRunFn).toHaveBeenCalledOnce();
|
||||
expect(recorder[record]).not.toHaveBeenCalled();
|
||||
|
||||
await run([{ text: 'tick' }], `prompt-client-${type}`, { type });
|
||||
expect(recorder[record]).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
/** A recall that never settles; returns a spy on its abort listener. */
|
||||
const recallAbortHandler = () => {
|
||||
const abortHandler = vi.fn();
|
||||
|
||||
@@ -342,7 +342,9 @@ export interface SendMessageOptions {
|
||||
* a prompt, so the client does none of them. On a Retry, the host started
|
||||
* the todo work chain and keeps the attempt's recorded user message, so the
|
||||
* client neither starts the chain nor records the message again. On a Goal
|
||||
* turn, the host recorded the runtime continuation's message.
|
||||
* turn, the host recorded the runtime continuation's message. On a Cron or
|
||||
* Notification send, the host records the prompt or the notification
|
||||
* itself.
|
||||
*/
|
||||
userTurnRecordedByHost?: boolean;
|
||||
/**
|
||||
@@ -4559,15 +4561,17 @@ export class LlmClient {
|
||||
// under them without losing the resumed info item this record exists to
|
||||
// restore. See `ChatRecord.deliveredTurn` for that accepted imprecision
|
||||
// and the test pinning it.
|
||||
this.config
|
||||
.getChatRecordingService()
|
||||
?.recordNotification(
|
||||
request,
|
||||
options?.notificationDisplayText,
|
||||
undefined,
|
||||
goalPermit,
|
||||
/* deliveredTurn */ true,
|
||||
);
|
||||
if (!options?.userTurnRecordedByHost) {
|
||||
this.config
|
||||
.getChatRecordingService()
|
||||
?.recordNotification(
|
||||
request,
|
||||
options?.notificationDisplayText,
|
||||
undefined,
|
||||
goalPermit,
|
||||
/* deliveredTurn */ true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Notifications start a fresh Turn with a new prompt_id, so the loop
|
||||
@@ -4720,13 +4724,15 @@ export class LlmClient {
|
||||
|
||||
// record user/cron message for session management
|
||||
if (messageType === SendMessageType.Cron) {
|
||||
this.config
|
||||
.getChatRecordingService()
|
||||
?.recordCronPrompt(
|
||||
request,
|
||||
options?.notificationDisplayText,
|
||||
goalPermit,
|
||||
);
|
||||
if (!options?.userTurnRecordedByHost) {
|
||||
this.config
|
||||
.getChatRecordingService()
|
||||
?.recordCronPrompt(
|
||||
request,
|
||||
options?.notificationDisplayText,
|
||||
goalPermit,
|
||||
);
|
||||
}
|
||||
} else if (!options?.userTurnRecordedByHost) {
|
||||
const recorder = this.config.getChatRecordingService();
|
||||
recorder?.recordUserMessage(
|
||||
|
||||
Reference in new issue
Block a user