Merge branch 'master' into feat/produced-files-folder
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
@@ -174,11 +174,24 @@ describe('bash tool through the agent loop', () => {
|
||||
expect(resultText(toolResult)).toContain('[exit code: 9]')
|
||||
})
|
||||
|
||||
it('background: start ack → pending completion notice → task_output collects it', async () => {
|
||||
it('background: start ack → completion wakes the idle agent → task_output collects it', async () => {
|
||||
// The command blocks on a sentinel this test creates only after the agent
|
||||
// has gone idle, so settlement cannot fold into the still-running turn.
|
||||
// Without that fence a fast command can settle before step 2's pre-step
|
||||
// claim, which folds the notice into a turn whose scripted reply is final:
|
||||
// the turn then closes with an empty next-step inbox and the collection
|
||||
// entries are never reached.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-bg-'))
|
||||
dirs.push(dir)
|
||||
const sentinel = join(dir, 'release')
|
||||
// The task id is deterministic (a fresh LocalTaskService counts per kind from 1),
|
||||
// so the script can name `bash-1` without threading a generated id.
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('call-1', 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true }),
|
||||
toolCallResponse('call-1', 'bash', {
|
||||
command: `while [ ! -f ${JSON.stringify(sentinel)} ]; do sleep 0.02; done; echo bg-ok`,
|
||||
description: 'test command',
|
||||
run_in_background: true,
|
||||
}),
|
||||
textResponse('Started it in the background.'),
|
||||
toolCallResponse('call-2', 'task_output', { task_id: 'bash-1' }),
|
||||
textResponse('Background task finished.'),
|
||||
@@ -192,29 +205,35 @@ describe('bash tool through the agent loop', () => {
|
||||
const firstResult = findEvent(events(agent), 'tool/result')
|
||||
expect(firstResult.data.message.content[0].isError).toBe(false)
|
||||
expect(resultText(firstResult)).toBe('started background task bash-1')
|
||||
|
||||
// The task settles on its own; the tool-tasks notice listener injects a
|
||||
// pending next-step message without waking the idle agent.
|
||||
// The turn closed with the task still running, so the notice cannot exist yet.
|
||||
const isNotice = (e: SessionEvent): e is SessionEvent<'user/message'> =>
|
||||
e.type === 'user/message' && e.data.source.kind === 'plugin'
|
||||
await pollUntil(() => agent.inbox.nextStep.some(message => message.source.kind === 'plugin'))
|
||||
const pendingNotice = agent.inbox.nextStep.find(message => message.source.kind === 'plugin')!
|
||||
expect(pendingNotice.content.some(
|
||||
block => block.type === 'text' && block.text.includes('background task bash-1 (bash: echo bg-ok) finished'),
|
||||
)).toBe(true)
|
||||
expect(pendingNotice.source).toEqual({
|
||||
expect(events(agent).some(isNotice)).toBe(false)
|
||||
|
||||
// Releasing the command now settles it against a provably idle owner. No
|
||||
// second user message: the wake alone opens the turn that collects it.
|
||||
writeFileSync(sentinel, '')
|
||||
const lastResultText = (): string => {
|
||||
const found = events(agent).findLast(event => event.type === 'tool/result')
|
||||
return found === undefined ? '' : resultText(found)
|
||||
}
|
||||
await pollUntil(() => events(agent).some(isNotice) && lastResultText().includes('bg-ok'))
|
||||
// Two turns: the user's, then the one the completion opened by itself.
|
||||
expect(events(agent).filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
|
||||
// The notice carries the gated command as its label, so this pins the id,
|
||||
// the terminal status, and the producer identity; the verbatim notice text
|
||||
// and its bounding are pinned in the tool-tasks unit tests.
|
||||
const notice = events(agent).find(isNotice)!
|
||||
const noticeText = notice.data.content
|
||||
.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
expect(noticeText).toContain('background task bash-1 (bash: ')
|
||||
expect(noticeText).toContain('finished [status: completed, exit code: 0]')
|
||||
expect(notice.data.source).toMatchObject({
|
||||
kind: 'plugin',
|
||||
plugin: 'tool-tasks',
|
||||
form: 'notice',
|
||||
summary: 'bash echo bg-ok [status: completed, exit code: 0]',
|
||||
})
|
||||
|
||||
// The next turn first admits that notice as user/message, then collects
|
||||
// the output through the generic task tool.
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'collect it' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
const notice = events(agent).find(isNotice)!
|
||||
expect(notice.data).toEqual(pendingNotice)
|
||||
const readResult = findEvent(events(agent), 'tool/result', 'last')
|
||||
expect(readResult.data.message.content[0].isError).toBe(false)
|
||||
expect(resultText(readResult)).toContain('bg-ok')
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/tasks/tasks-local/README.md
|
||||
README.md: 6f9e1bf2524d7db0aff8dac0a43c86e82a20fbab
|
||||
README.zh.md: 5e62a8289e9c08ac9d745d576d1893b71bcb2f7b
|
||||
README.md: cc2e8422c367eeacfc5fc504298ecd6bfeae4c67
|
||||
README.zh.md: 81fc0a5b1e12b2b15705370bb0748b1733b312aa
|
||||
|
||||
@@ -10,7 +10,7 @@ Tasks belong to their owner and backend, not the producer tool fiber, so produce
|
||||
|
||||
Service disposal closes listeners, cancels all live tasks, awaits their records, and detaches effects from surviving owner scopes. If teardown cancellation throws, the service force-fails the record and warns that work may be orphaned instead of deadlocking. A cancellation that returns but never settles `done` remains indistinguishable from a slow stop and can stall teardown.
|
||||
|
||||
Settlement is first-wins: the earliest terminal outcome — producer settlement, a rejected `done` contained as `failed`, or a teardown force-failure — records once, notifies listeners once with per-listener containment, and releases waiters. Pending waits mark the task reported before listeners run so completion reporters do not duplicate notices.
|
||||
Settlement is first-wins: the earliest terminal outcome — producer settlement, a rejected `done` contained as `failed`, or a teardown force-failure — records once, releases waiters, and notifies listeners once with per-listener containment. Pending waits mark the task reported before listeners run so completion reporters do not duplicate notices, and a teardown cancel marks it for the same reason: nothing will read a notice addressed to an owner being destroyed. Completion is the last thing a settlement announces, after the record is committed and the visible-set change is published, because a reporter may open a model turn synchronously and every other observer must already have seen the settled record.
|
||||
|
||||
Controllers and listeners are layered by the scope that registered them, in the tools-registry shape: a registration files into its registering context's scope, and a read unions the global layer with the owner's scope chain. One process-wide registry therefore answers per-owner questions per owner — `start()` refuses `background tasks unavailable: no task controller serves this agent (load @deepseek-ai/dsh-tool-tasks in its composition)` for an owner whose own composition attaches none, however many other compositions attach theirs, and a settlement reaches only the listeners its owner's composition registered.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
服务 dispose 会关闭监听器、取消所有存活任务、等待其记录完成,并从仍存活的所有者 scope 中分离 effect。如果销毁期间的取消操作抛出异常,服务会强制将记录标为失败,并警告工作可能成为孤立工作,而不会死锁。取消操作已返回但 `done` 始终未结算时,系统无法将其与缓慢停止区分开,销毁过程可能因此停滞。
|
||||
|
||||
结算遵循首次结算优先原则:最早出现的终止结果(生产方结算、作为 `failed` 隔离处理的 `done` 拒绝,或销毁时的强制失败)只记录一次,也只通知监听器一次;各监听器的故障会单独隔离,随后释放等待方。挂起的等待会在监听器运行前把任务标记为已报告,因此完成报告方不会重复发出通知。
|
||||
结算遵循首次结算优先原则:最早出现的终止结果(生产方结算、作为 `failed` 隔离处理的 `done` 拒绝,或销毁时的强制失败)只记录一次,随后释放等待方,再只通知监听器一次;各监听器的故障会单独隔离。挂起的等待会在监听器运行前把任务标记为已报告,因此完成报告方不会重复发出通知;销毁时的取消出于同样的理由也会标记:面向正在被销毁的所有者的通知不会有人读到。完成是一次结算最后才宣布的事情,排在记录提交与可见集变更发布之后,因为报告方可能同步开启一个模型轮次,而该结算的其他所有观察者都必须已经看到已结算的记录。
|
||||
|
||||
控制器与监听器按注册方所在的 scope 分层,形状与 tools 注册表一致:一次注册归档到其注册上下文的 scope,一次读取则把全局层与所有者的 scope 链求并集。因此一个进程级注册表能逐所有者地回答逐所有者的问题——对自身组合未附加任何控制器的所有者,无论其他组合附加了多少,`start()` 都会拒绝并抛出 `background tasks unavailable: no task controller serves this agent (load @deepseek-ai/dsh-tool-tasks in its composition)`;一次结算也只会抵达其所有者所属组合注册的监听器。
|
||||
|
||||
|
||||
@@ -224,11 +224,12 @@ export class LocalTaskService extends TaskService {
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
task.waitResolvers.delete(onSettled)
|
||||
// A settled task cannot reach here: settlement releases every waiter
|
||||
// before it announces completion, and each released waiter detaches
|
||||
// this listener in the same synchronous span, so nothing that reacts
|
||||
// to a settlement can abort a wait the settlement already owed.
|
||||
if (timeoutOf(d.signal, TASK_WAIT_TIMEOUT) !== undefined) {
|
||||
resolve()
|
||||
} else if (isTerminal(task.status)) {
|
||||
// Settlement suppressed the notice for this waiter; deliver it.
|
||||
resolve()
|
||||
} else {
|
||||
uncount()
|
||||
reject(new Error('wait aborted'))
|
||||
@@ -364,9 +365,12 @@ export class LocalTaskService extends TaskService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the first terminal outcome, notify contained listeners, and release
|
||||
* waiters. First-wins preserves a teardown force-failure against late producer
|
||||
* settlement. Pending waits mark the task reported before listeners run.
|
||||
* Record the first terminal outcome, release waiters, then announce
|
||||
* completion. First-wins preserves a teardown force-failure against late
|
||||
* producer settlement. Pending waits mark the task reported before listeners
|
||||
* run. Completion is announced last because a reporter may open a model turn
|
||||
* synchronously: every other observer of this settlement must already have
|
||||
* seen the committed record.
|
||||
*/
|
||||
private settle(task: TrackedTask, outcome: TaskOutcome): void {
|
||||
if (isTerminal(task.status)) return
|
||||
@@ -375,24 +379,23 @@ export class LocalTaskService extends TaskService {
|
||||
task.output = outcome.output
|
||||
task.finishedAt = Date.now()
|
||||
if (task.waiters > 0) task.reported = true
|
||||
if (!this.listenersClosed) {
|
||||
const snapshot = this.snapshot(task)
|
||||
for (const listener of this.listenersFor(task.owner)) {
|
||||
try {
|
||||
const returned = listener(snapshot, task.owner)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
this.selfCtx.logger.warn(`tasks: onTaskDone listener rejected for ${task.id}: ${String(error)}`)
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
this.selfCtx.logger.warn(`tasks: onTaskDone listener threw for ${task.id}: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
const snapshot = this.snapshot(task)
|
||||
const waitResolvers = [...task.waitResolvers]
|
||||
task.waitResolvers.clear()
|
||||
for (const resolveWait of waitResolvers) resolveWait()
|
||||
task.markSettled()
|
||||
this.notifyChanged(task.owner)
|
||||
if (this.listenersClosed) return
|
||||
for (const listener of this.listenersFor(task.owner)) {
|
||||
try {
|
||||
const returned = listener(snapshot, task.owner)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
this.selfCtx.logger.warn(`tasks: onTaskDone listener rejected for ${task.id}: ${String(error)}`)
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
this.selfCtx.logger.warn(`tasks: onTaskDone listener threw for ${task.id}: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -463,6 +466,14 @@ export class LocalTaskService extends TaskService {
|
||||
private cancelForTeardown(tasks: TrackedTask[], reason: string): void {
|
||||
for (const task of tasks) {
|
||||
if (isTerminal(task.status)) continue
|
||||
// Teardown cancellation is a kill without a caller, so it claims the
|
||||
// terminal report the same way `kill()` does. Nothing will read a notice
|
||||
// for a task whose owner or service is being destroyed, and a waking
|
||||
// reporter would spend a model request per teardown layer. This is
|
||||
// decided before the producer runs: the force-failure below settles the
|
||||
// record too, so a throwing cancel must not be the one path that
|
||||
// announces an unreported completion into a disposing owner.
|
||||
task.reported = true
|
||||
try {
|
||||
task.cancel(reason)
|
||||
task.status = 'stopping'
|
||||
|
||||
@@ -444,8 +444,9 @@ describe('LocalTaskService.wait', () => {
|
||||
const ctx = await harness()
|
||||
const controller = new AbortController()
|
||||
const seen: TaskSnapshot[] = []
|
||||
// The listener aborts after settlement has assigned delivery to this waiter
|
||||
// but before its resolve microtask; the waiter must still receive the result.
|
||||
// The listener aborts after settlement released this waiter but before its
|
||||
// resolve microtask runs. Releasing waiters ahead of the announcement is
|
||||
// what makes that abort harmless; this is the guard on that ordering.
|
||||
ctx.tasks.onTaskDone((snapshot) => {
|
||||
seen.push(snapshot)
|
||||
controller.abort()
|
||||
@@ -593,6 +594,51 @@ describe('LocalTaskService owner cleanup', () => {
|
||||
expect(ctx.tasks.list(owner)).toEqual([])
|
||||
})
|
||||
|
||||
it('publishes the settled visible set before announcing completion', async () => {
|
||||
const ctx = await harness()
|
||||
const owner = stubAgent(ctx, 'owner')
|
||||
ctx.agents.register(owner)
|
||||
const p = producer({ owner })
|
||||
ctx.tasks.start(p.spec)
|
||||
// Registered after start so only the settlement's notifications are ordered.
|
||||
const order: string[] = []
|
||||
ctx.tasks.onTasksChanged(() => void order.push('changed'))
|
||||
ctx.tasks.onTaskDone(() => void order.push('done'))
|
||||
|
||||
p.settle({ status: 'completed' })
|
||||
await tick()
|
||||
|
||||
// A completion reporter may open a turn synchronously. Announcing before
|
||||
// the visible set is published would let a client render that turn while
|
||||
// its task row still reads `running`.
|
||||
expect(order).toEqual(['changed', 'done'])
|
||||
})
|
||||
|
||||
it('reports a teardown-cancelled record so completion reporters stay quiet', async () => {
|
||||
const ctx = await harness()
|
||||
const owner = stubAgent(ctx, 'owner')
|
||||
ctx.agents.register(owner)
|
||||
const seen: TaskSnapshot[] = []
|
||||
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
|
||||
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
ctx.tasks.start({
|
||||
kind: 'subagent',
|
||||
label: 'long research',
|
||||
owner,
|
||||
run: () => ({
|
||||
cancel() { settle({ status: 'killed' }) },
|
||||
done: new Promise<TaskOutcome>((res) => { settle = res }),
|
||||
}),
|
||||
})
|
||||
|
||||
// Observers still receive the terminal record; the report bit is what
|
||||
// keeps a notice reporter from addressing an owner being destroyed.
|
||||
await disposeAgentScope(owner)
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(seen[0]?.reported).toBe(true)
|
||||
})
|
||||
|
||||
it('attaches one cleanup per owner and drains all owned tasks with the scope', async () => {
|
||||
const ctx = await harness()
|
||||
const owner = stubAgent(ctx, 'owner')
|
||||
|
||||
@@ -41,12 +41,16 @@ declare module '@deepseek-ai/cordis' {
|
||||
* Implementations must honor these semantics:
|
||||
* - Registrations outlive producer and controller fibers. Owner and
|
||||
* service disposal cancel live work and await compliant producers; a
|
||||
* throwing teardown cancel force-fails only the record.
|
||||
* throwing teardown cancel force-fails only the record. Teardown
|
||||
* cancellation also marks the record reported, because a record its owner
|
||||
* is being destroyed for has no reader left.
|
||||
* - Owned-task access is fenced by the owner's session id. Ids are
|
||||
* predictable, so authorization — not secrecy — is the boundary.
|
||||
* - Settlement is first-wins: one terminal record, one round of contained
|
||||
* listener notification, and released waiters, even against a late
|
||||
* producer outcome.
|
||||
* - Settlement is first-wins: one terminal record, released waiters, and one
|
||||
* round of contained listener notification, even against a late producer
|
||||
* outcome. Completion is announced last, after the record is committed and
|
||||
* every other observer of the settlement has seen it, because a reporter
|
||||
* may open a model turn synchronously.
|
||||
* - {@link start} refuses work while no attached task controller serves the
|
||||
* spec's owner, so a producer cannot start work that owner cannot collect
|
||||
* or stop. One registry serves every composition in the process, so this
|
||||
|
||||
@@ -118,8 +118,11 @@ export interface TaskSnapshot {
|
||||
/** Epoch ms when the task settled; absent while `running`/`stopping`. */
|
||||
finishedAt?: number
|
||||
/**
|
||||
* True when a kill, read, or wait has reported or committed to report the
|
||||
* terminal state. Completion reporters suppress redundant notices when set.
|
||||
* True when a kill, read, wait, or teardown cancel has reported or committed
|
||||
* to report the terminal state. Completion reporters suppress redundant
|
||||
* notices when set. Teardown claims it because the owner or service being
|
||||
* destroyed leaves no reader: a reporter that opens a turn on notice would
|
||||
* otherwise spend a model request per teardown layer.
|
||||
*/
|
||||
reported: boolean
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/tasks/tool-tasks/README.md
|
||||
README.md: 76607351aba6b482817b890056254da737e7b9ed
|
||||
README.zh.md: 8d27510aa3be3b87ca167d88d85fc374fb1eb4f1
|
||||
README.md: a899fbaeb402096f523e230ee03d6f422d321810
|
||||
README.zh.md: d0d4b7bdc66d5261d914ae8380488c36c17a0f0a
|
||||
|
||||
@@ -18,7 +18,11 @@ When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill`
|
||||
|
||||
## Completion notices
|
||||
|
||||
An unreported completion injects `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` into the exact owner's next-step inbox. When bounded, the stable id prefix and collection command outrank variable label/detail so the notice remains actionable at PTY's supported 64-byte minimum. Injection is durable pending context for a later pre-step claim, not a wake-up; cancellation or owner disposal may discard it before claim. A kill or terminal read/wait marks delivery reported and suppresses the redundant notice.
|
||||
An unreported completion delivers `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` to the exact owner. When bounded, the stable id prefix and collection command outrank variable label/detail so the notice remains actionable at PTY's supported 64-byte minimum. A kill or terminal read/wait marks delivery reported and suppresses the redundant notice, as does the teardown cancel that drains an owner or the service.
|
||||
|
||||
Which lane carries it depends on what the owner is doing. A busy owner is injected: the notice joins the next-step inbox, and the turn cannot close while that inbox holds it, so several tasks settling together cost one step rather than one turn each. An idle owner is instead woken with a follow-up turn, because a pending notice nothing claims is a completion the model never learns about. `completionDelivery: quiet` keeps the injection lane for idle owners too, which is what a deterministic transcript needs.
|
||||
|
||||
Waking is bounded. Each owner may open `maxConsecutiveWakes` turns this way before further notices degrade to injection, and claiming any user-authored message restores the budget. The bound exists because the chain is self-exciting: a woken turn may start the background task whose completion wakes it again. Notices this plugin queued never refill the budget they spent.
|
||||
|
||||
One host registry may carry several mounts of this plugin — one per agent preset. The registry routes each settlement to the listeners the owner's scope chain reaches, so a mount under one preset never sees another preset's agents and an agent reads exactly one notice per completion however many presets are mounted. The same routing decides which agents this mount's controller serves: an agent whose composition loads no `tool-tasks` cannot start background work at all.
|
||||
|
||||
@@ -28,6 +32,8 @@ One host registry may carry several mounts of this plugin — one per agent pres
|
||||
|---|---|---|
|
||||
| `waitTimeoutMs` | `30000` | wait used when `wait: true` omits `timeout_ms` |
|
||||
| `maxWaitTimeoutMs` | `600000` | cap for model-supplied waits |
|
||||
| `completionDelivery` | `wakeup` | `wakeup` opens a turn on an idle owner; `quiet` leaves the notice pending |
|
||||
| `maxConsecutiveWakes` | `3` | turns one owner may open by wake before notices degrade to injection |
|
||||
|
||||
A default above the cap fails at load.
|
||||
|
||||
@@ -75,7 +81,7 @@ Reads return output or `(no new output)` followed by `[status: <status>]` and op
|
||||
|
||||
#### Token effect
|
||||
|
||||
Results and notices remain in parent history until compaction. Stream reads do not repeat consumed output; a producer-supplied `outputLimitBytes` bounds each complete read or notice.
|
||||
Results and notices remain in parent history until compaction. Stream reads do not repeat consumed output; a producer-supplied `outputLimitBytes` bounds each complete read or notice. Under `wakeup`, a notice reaching an idle owner also buys a model request the user did not ask for, capped per owner by `maxConsecutiveWakes`; a notice reaching a busy owner adds a step to the turn it is already paying for.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -83,6 +89,8 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Completion notices do not wake idle agents** — callers needing an immediate result must use `task_output`.
|
||||
- **A settlement inside the driver's retirement window still strands its notice** — between the turn loop's last inbox check and the driver committing its idle phase the owner still reads as busy, so the notice is injected and nothing wakes. Steering has the same hole; closing it belongs to `agent-loop`.
|
||||
- **A spent wake budget is not restored by time** — only user-authored input refills it, so an unattended agent whose budget ran out collects its remaining notices on the next turn something else opens.
|
||||
- **A notice pending on an idle owner does not survive that owner's disposal** — the disposal cancel clears the unclaimed inbox, and the log keeps the insert/cancel pair as the record.
|
||||
- **Stream reads are single-consumer** — independent observers need another runtime API.
|
||||
- **Unowned tasks have no session fence** — external callers must supply policy or avoid them.
|
||||
|
||||
@@ -18,7 +18,11 @@
|
||||
|
||||
## 完成通知
|
||||
|
||||
一项尚未报告的完成会把 `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` 注入到确切所有者的 next-step inbox。应用上限时,即使采用 PTY 支持的 64 字节下限,稳定 id 前缀和收集命令的优先级也高于可变 label/detail,因此通知仍可操作。注入是等待后续 pre-step 领取的持久上下文,并非唤醒;取消或 owner 释放可能在领取前丢弃它。kill 或针对已终止任务的 read/wait 会把交付标为已报告,并抑制重复通知。
|
||||
一项尚未报告的完成会把 `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` 交付给确切所有者。应用上限时,即使采用 PTY 支持的 64 字节下限,稳定 id 前缀和收集命令的优先级也高于可变 label/detail,因此通知仍可操作。kill 或针对已终止任务的 read/wait 会把交付标为已报告并抑制重复通知;排空 owner 或服务的 teardown 取消同样如此。
|
||||
|
||||
由哪条通道承载取决于所有者当时在做什么。繁忙的所有者走注入:通知进入 next-step inbox,而该 inbox 尚有内容时 turn 无法结束,因此同时结算的多个任务只花掉一步,而不是各占一轮。空闲的所有者则被 follow-up 唤醒,因为无人领取的待发通知等于模型永远不会知道的完成。`completionDelivery: quiet` 让空闲所有者也留在注入通道上,确定性 transcript 需要的正是这一点。
|
||||
|
||||
唤醒是有界的。每个所有者最多可通过唤醒开启 `maxConsecutiveWakes` 轮,此后的通知降级为注入;领取任何用户撰写的消息都会恢复该预算。设界是因为这条链会自激:被唤醒的一轮可能启动某个后台任务,而它的完成又会唤醒同一个所有者。本插件自己排队的通知永远不会补充它刚花掉的预算。
|
||||
|
||||
一个宿主注册表可能承载本插件的多份挂载——每个 agent preset 一份。注册表会把每次结算路由给所有者 scope 链所能抵达的监听器,因此某个 preset 下的挂载永远看不到另一个 preset 的 agent,无论挂载了多少 preset,一个 agent 每次完成都只读到一条通知。同一套路由也决定本挂载的控制器服务哪些 agent:组合中未加载 `tool-tasks` 的 agent 根本无法启动后台工作。
|
||||
|
||||
@@ -28,6 +32,8 @@
|
||||
|---|---|---|
|
||||
| `waitTimeoutMs` | `30000` | `wait: true` 省略 `timeout_ms` 时使用的等待时间 |
|
||||
| `maxWaitTimeoutMs` | `600000` | 模型所给等待时间的上限 |
|
||||
| `completionDelivery` | `wakeup` | `wakeup` 为空闲所有者开启一轮;`quiet` 让通知继续待领 |
|
||||
| `maxConsecutiveWakes` | `3` | 一个所有者可由唤醒开启的轮数,超出后通知降级为注入 |
|
||||
|
||||
默认值高于上限时,插件会在加载时失败。
|
||||
|
||||
@@ -75,7 +81,7 @@ Track every background task id you start. You are notified in-session when a tas
|
||||
|
||||
#### Token 影响
|
||||
|
||||
结果与通知在压缩(compaction)前保留于父级历史。流读取不会重复已消费的输出;生产方提供的 `outputLimitBytes` 会限制每次完整读取或通知。
|
||||
结果与通知在压缩(compaction)前保留于父级历史。流读取不会重复已消费的输出;生产方提供的 `outputLimitBytes` 会限制每次完整读取或通知。在 `wakeup` 下,抵达空闲所有者的通知还会额外买下一次用户并未要求的模型请求,其数量按所有者由 `maxConsecutiveWakes` 封顶;抵达繁忙所有者的通知则只是给它已经在支付的那一轮加一步。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -83,6 +89,8 @@ Track every background task id you start. You are notified in-session when a tas
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **完成通知不会唤醒空闲 agent**:需要立即获得结果的调用方必须使用 `task_output`。
|
||||
- **落在 driver 退休窗口内的结算仍会让通知搁浅**:在轮次循环最后一次检查 inbox 与 driver 提交 idle 相位之间,所有者读起来仍是繁忙,因此通知走注入且无人唤醒。steer 有同样的洞;堵上它属于 `agent-loop`。
|
||||
- **已花掉的唤醒预算不会随时间恢复**:只有用户撰写的输入才能补充,因此预算耗尽的无人值守 agent 要等到其他原因开启下一轮时才收走剩余通知。
|
||||
- **待领于空闲所有者的通知无法在该所有者释放后存活**:释放时的取消会清空未领取的 inbox,日志保留插入/取消这一对作为记录。
|
||||
- **流读取只有单一消费方**:独立观察者需要另一套运行时 API。
|
||||
- **无 owner 的任务没有会话隔离**:外部调用方必须提供策略或避开这些任务。
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Model-facing `task_output`, `task_list`, and `task_kill` tools over
|
||||
* `ctx.tasks`. Loading the plugin attaches the controller required by
|
||||
* producers. It also injects unreported completions as durable context for the
|
||||
* owner's next request; notices do not wake idle agents.
|
||||
* producers. It also delivers unreported completions to the owning agent:
|
||||
* injected into a busy owner's next step, or opening a turn on an idle one
|
||||
* under the default `wakeup` delivery, bounded per owner.
|
||||
* @module @deepseek-ai/dsh-tool-tasks
|
||||
*/
|
||||
|
||||
@@ -15,21 +16,40 @@ import type { GenericCallView, ToolDefinition, ToolExecution } from '@deepseek-a
|
||||
import { TaskId } from '@deepseek-ai/dsh-tasks'
|
||||
import type { TaskSnapshot } from '@deepseek-ai/dsh-tasks'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
export const name = 'tool-tasks'
|
||||
export const inject = ['tools', 'tasks', 'systemPrompt']
|
||||
|
||||
/** Configures bounded `task_output` waits. */
|
||||
/**
|
||||
* How an unreported completion reaches an owner that is already idle: `wakeup`
|
||||
* opens a turn for it, `quiet` leaves it pending until something else wakes the
|
||||
* owner. A busy owner is injected either way.
|
||||
*/
|
||||
export type CompletionDelivery = 'quiet' | 'wakeup'
|
||||
|
||||
/** Configures bounded `task_output` waits and completion-notice delivery. */
|
||||
export interface Config {
|
||||
/** Wait duration applied when `task_output` sets `wait` without `timeout_ms` (default 30s). */
|
||||
waitTimeoutMs?: number
|
||||
/** Hard cap on any single wait; a larger model-supplied `timeout_ms` is clamped down to it (default 10min). */
|
||||
maxWaitTimeoutMs?: number
|
||||
/** Whether a completion opens a turn on an idle owner (default `wakeup`). */
|
||||
completionDelivery?: CompletionDelivery
|
||||
/**
|
||||
* Turns one owner may have opened by completion wakes before the next
|
||||
* notice degrades to injection, reset by any user-authored input (default 3).
|
||||
* Bounds the self-exciting chain where a woken turn starts the task whose
|
||||
* completion wakes it again.
|
||||
*/
|
||||
maxConsecutiveWakes?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
waitTimeoutMs: z.number().min(1).default(30_000),
|
||||
maxWaitTimeoutMs: z.number().min(1).default(600_000),
|
||||
completionDelivery: z.union(['quiet', 'wakeup'] as const).default('wakeup'),
|
||||
maxConsecutiveWakes: z.number().min(1).default(3),
|
||||
})
|
||||
|
||||
/** Task state safe for model-authored programs; ownership/bookkeeping fields are omitted. */
|
||||
@@ -185,9 +205,29 @@ function presentTaskCall(title: string, kind: 'read' | 'execute', rawInput?: str
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const waitDefault = config.waitTimeoutMs ?? 30_000
|
||||
const waitCap = config.maxWaitTimeoutMs ?? 600_000
|
||||
const delivery = config.completionDelivery ?? 'wakeup'
|
||||
const wakeBudget = config.maxConsecutiveWakes ?? 3
|
||||
|
||||
// Turns this plugin opened on each owner since that owner last consumed
|
||||
// human input. Keyed by the exact Agent, so a same-session replacement
|
||||
// starts with a full budget.
|
||||
const spentWakes = new WeakMap<Agent, number>()
|
||||
if (waitDefault > waitCap) {
|
||||
throw new Error(`tool-tasks: waitTimeoutMs (${waitDefault}) exceeds maxWaitTimeoutMs (${waitCap})`)
|
||||
}
|
||||
// A budget is a count of turns. `Infinity` would leave the runaway chain this
|
||||
// field exists to bound unbounded, and a fraction never names a turn at all.
|
||||
if (!Number.isSafeInteger(wakeBudget)) {
|
||||
throw new Error(`tool-tasks: maxConsecutiveWakes (${wakeBudget}) must be a whole number of turns`)
|
||||
}
|
||||
// Nothing spends the budget under quiet delivery, so nothing needs to refill it.
|
||||
if (delivery === 'wakeup') {
|
||||
ctx.on('agent/inbox/claimed', ({ agent, message }) => {
|
||||
// Claiming is the point the human's input actually enters a step; a notice
|
||||
// this plugin itself queued must not refill the budget it just spent.
|
||||
if (message.source.kind === 'user') spentWakes.delete(agent)
|
||||
})
|
||||
}
|
||||
|
||||
const outputLimits = new WeakMap<ToolExecution, number>()
|
||||
ctx.on('tools/pre-execute', (exec, next) => {
|
||||
@@ -227,16 +267,18 @@ export function apply(ctx: Context, config: Config): void {
|
||||
})
|
||||
|
||||
// Use the exact lifecycle owner; reusable ids could resolve to a replacement.
|
||||
// Delivery targets the exact lifecycle owner. The notice waits in its
|
||||
// next-step inbox until another step claims it; disposal before that
|
||||
// boundary discards it with the owner.
|
||||
// A busy owner is injected: the notice waits in its next-step inbox, which
|
||||
// the turn cannot close over, so tasks settling together cost one step. An
|
||||
// idle owner is woken instead, because an unclaimed notice is a completion
|
||||
// the model never learns about. Either way, disposal before the claim
|
||||
// discards it with the owner, and teardown settlements arrive `reported`.
|
||||
//
|
||||
// The registry routes each settlement to the listeners its owner's scope
|
||||
// chain reaches, so a mount under one preset never sees another preset's
|
||||
// agents; this listener owns delivery, not the choice of whom to deliver to.
|
||||
ctx.tasks.onTaskDone((snapshot, owner) => {
|
||||
if (snapshot.reported || owner === undefined) return
|
||||
owner.inject(createUserMessage({
|
||||
const message = createUserMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: fitCompletionNotice(snapshot),
|
||||
@@ -247,7 +289,14 @@ export function apply(ctx: Context, config: Config): void {
|
||||
form: 'notice',
|
||||
summary: completionSummary(snapshot),
|
||||
},
|
||||
}))
|
||||
})
|
||||
const spent = spentWakes.get(owner) ?? 0
|
||||
if (delivery === 'wakeup' && owner.status === 'idle' && spent < wakeBudget) {
|
||||
spentWakes.set(owner, spent + 1)
|
||||
owner.followup(message)
|
||||
return
|
||||
}
|
||||
owner.inject(message)
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
|
||||
@@ -3,8 +3,9 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { emitAgentEvent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import { TaskId } from '@deepseek-ai/dsh-tasks'
|
||||
@@ -16,6 +17,7 @@ import { statusLine } from '@deepseek-ai/dsh-tool-tasks'
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
const agentRegistryDisposers = new WeakMap<Agent, () => void>()
|
||||
const agentScopeFibers = new WeakMap<Agent, { dispose: () => Promise<void> }>()
|
||||
|
||||
async function setup(config: ToolTasks.Config = {}) {
|
||||
const ctx = new Context()
|
||||
@@ -27,20 +29,31 @@ async function setup(config: ToolTasks.Config = {}) {
|
||||
return { ctx, agentsFiber, toolsFiber }
|
||||
}
|
||||
|
||||
/** The delivery surface a completion notice may reach on a fake owner. */
|
||||
interface FakeDelivery {
|
||||
inject?: (...args: unknown[]) => void
|
||||
followup?: (...args: unknown[]) => void
|
||||
/** Defaults to `running`, the lane that never wakes, so notice-content tests pin one lane. */
|
||||
status?: 'idle' | 'running'
|
||||
}
|
||||
|
||||
/**
|
||||
* A fake agent with the shared agent/session identity, registered in
|
||||
* `ctx.agents` with a dedicated lifecycle scope.
|
||||
*/
|
||||
function fakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent {
|
||||
function fakeAgent(ctx: Context, sessionId: string, delivery: FakeDelivery = {}): Agent {
|
||||
const scopeFiber = ctx.plugin(() => {})
|
||||
const id = SessionId(sessionId)
|
||||
const agent = {
|
||||
id,
|
||||
ctx: scopeFiber.ctx,
|
||||
inject,
|
||||
inject: delivery.inject ?? (() => {}),
|
||||
followup: delivery.followup ?? (() => {}),
|
||||
status: delivery.status ?? 'running',
|
||||
session: { id, header: { version: 0, id, createdAt: 0 } },
|
||||
} as unknown as Agent
|
||||
agentRegistryDisposers.set(agent, ctx.agents.register(agent))
|
||||
agentScopeFibers.set(agent, scopeFiber)
|
||||
return agent
|
||||
}
|
||||
|
||||
@@ -50,6 +63,13 @@ function detachAgent(agent: Agent): void {
|
||||
dispose()
|
||||
}
|
||||
|
||||
/** Dispose the agent's own lifecycle scope, which is what drains its owned tasks. */
|
||||
async function disposeAgentScope(agent: Agent): Promise<void> {
|
||||
const fiber = agentScopeFibers.get(agent)
|
||||
if (fiber === undefined) throw new Error(`missing scope fiber for agent "${agent.id}"`)
|
||||
await fiber.dispose()
|
||||
}
|
||||
|
||||
/** A controllable producer start-spec (settle `done` on demand, record cancels). */
|
||||
function producer(overrides: Partial<Omit<TaskStart, 'run'> & TaskHooks> = {}) {
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
@@ -81,6 +101,16 @@ function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
|
||||
const tick = () => new Promise<void>(r => setTimeout(r, 0))
|
||||
|
||||
/** Start and settle `count` owned tasks one at a time, letting each notice land. */
|
||||
async function settleTasks(ctx: Context, owner: Agent, count: number): Promise<void> {
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const p = producer({ owner })
|
||||
ctx.tasks.start(p.spec)
|
||||
p.settle({ status: 'completed' })
|
||||
await tick()
|
||||
}
|
||||
}
|
||||
|
||||
describe('tool-tasks setup', () => {
|
||||
it('attaches the task controller on load and detaches it with the fiber', async () => {
|
||||
const { ctx, toolsFiber } = await setup()
|
||||
@@ -98,6 +128,35 @@ describe('tool-tasks setup', () => {
|
||||
.rejects.toThrow('waitTimeoutMs (100) exceeds maxWaitTimeoutMs (50)')
|
||||
})
|
||||
|
||||
it('defaults delivery to wakeup and rejects an unknown lane', () => {
|
||||
expect(ToolTasks.Config({}).completionDelivery).toBe('wakeup')
|
||||
expect(ToolTasks.Config({}).maxConsecutiveWakes).toBe(3)
|
||||
expect(() => ToolTasks.Config({ completionDelivery: 'loud' as never })).toThrow()
|
||||
expect(() => ToolTasks.Config({ maxConsecutiveWakes: 0 })).toThrow()
|
||||
})
|
||||
|
||||
it('rejects a wake budget that cannot bound anything', async () => {
|
||||
// Reports the load outcome as text: a resolved fiber is not safely printable.
|
||||
const loadWith = async (maxConsecutiveWakes: number): Promise<string> => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
try {
|
||||
await ctx.plugin(ToolTasks, { maxConsecutiveWakes })
|
||||
return 'loaded'
|
||||
} catch (error: unknown) {
|
||||
return String(error)
|
||||
}
|
||||
}
|
||||
|
||||
// The field exists to bound runaway waking; a fractional budget counts
|
||||
// nothing and an infinite one removes the bound it was configured for.
|
||||
expect(await loadWith(Number.POSITIVE_INFINITY)).toContain('maxConsecutiveWakes')
|
||||
expect(await loadWith(2.5)).toContain('maxConsecutiveWakes')
|
||||
expect(await loadWith(1)).toBe('loaded')
|
||||
})
|
||||
|
||||
it('renders status lines with and without producer detail', () => {
|
||||
const base = { id: 'bash-1', kind: 'bash', label: 'x', startedAt: 0, reported: false } as unknown as TaskSnapshot
|
||||
expect(statusLine({ ...base, status: 'running' })).toBe('[status: running]')
|
||||
@@ -494,11 +553,139 @@ describe('completion notices across scoped mounts', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('completion notice delivery', () => {
|
||||
it('opens a turn on an idle owner when a task settles', async () => {
|
||||
const { ctx } = await setup()
|
||||
const inject = vi.fn()
|
||||
const followup = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
|
||||
const p = producer({ owner, label: 'pnpm test' })
|
||||
ctx.tasks.start(p.spec)
|
||||
|
||||
p.settle({ status: 'completed', detail: 'exit code: 0' })
|
||||
await tick()
|
||||
expect(followup).toHaveBeenCalledTimes(1)
|
||||
expect(inject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never wakes an idle owner under quiet delivery', async () => {
|
||||
const { ctx } = await setup({ completionDelivery: 'quiet' })
|
||||
const inject = vi.fn()
|
||||
const followup = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
|
||||
const p = producer({ owner })
|
||||
ctx.tasks.start(p.spec)
|
||||
|
||||
p.settle({ status: 'completed' })
|
||||
await tick()
|
||||
expect(inject).toHaveBeenCalledTimes(1)
|
||||
expect(followup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('degrades to injection once the consecutive wake budget is spent', async () => {
|
||||
const { ctx } = await setup({ maxConsecutiveWakes: 2 })
|
||||
const inject = vi.fn()
|
||||
const followup = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
|
||||
|
||||
await settleTasks(ctx, owner, 3)
|
||||
// A woken turn that starts another task is the self-exciting case: the
|
||||
// budget stops the chain, and the notice still reaches the inbox.
|
||||
expect(followup).toHaveBeenCalledTimes(2)
|
||||
expect(inject).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('restores the wake budget when the owner claims a user message', async () => {
|
||||
const { ctx } = await setup({ maxConsecutiveWakes: 1 })
|
||||
const inject = vi.fn()
|
||||
const followup = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
|
||||
|
||||
await settleTasks(ctx, owner, 2)
|
||||
expect(followup).toHaveBeenCalledTimes(1)
|
||||
|
||||
emitAgentEvent(ctx, owner, 'agent/inbox/claimed', {
|
||||
message: createUserMessage({ content: [{ type: 'text', text: 'carry on' }], source: { kind: 'user' } }),
|
||||
turn: 1,
|
||||
})
|
||||
await settleTasks(ctx, owner, 1)
|
||||
expect(followup).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('neither wakes nor injects into an owner its own teardown is draining', async () => {
|
||||
const { ctx } = await setup()
|
||||
const inject = vi.fn()
|
||||
const followup = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
ctx.tasks.start({
|
||||
kind: 'bash',
|
||||
label: 'sleep 60',
|
||||
owner,
|
||||
run: () => ({
|
||||
cancel() { settle({ status: 'killed' }) },
|
||||
done: new Promise<TaskOutcome>((res) => { settle = res }),
|
||||
}),
|
||||
})
|
||||
|
||||
// Disposal cancels and settles the owned task. Waking here would spend a
|
||||
// model request on an agent the host is destroying, once per tree layer.
|
||||
await disposeAgentScope(owner)
|
||||
await tick()
|
||||
expect(followup).not.toHaveBeenCalled()
|
||||
expect(inject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('neither wakes nor injects when the teardown cancel itself threw', async () => {
|
||||
const { ctx } = await setup()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const inject = vi.fn()
|
||||
const followup = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
|
||||
ctx.tasks.start({
|
||||
kind: 'bash',
|
||||
label: 'broken producer',
|
||||
owner,
|
||||
run: () => ({
|
||||
cancel() { throw new Error('cancel boom') },
|
||||
done: new Promise<TaskOutcome>(() => {}),
|
||||
}),
|
||||
})
|
||||
|
||||
// The registry force-fails the record instead of deadlocking. That path
|
||||
// settles the task too, so it must claim the report as the ordinary
|
||||
// teardown cancel does — otherwise a throwing producer is all it takes to
|
||||
// spend a model request on an owner being destroyed.
|
||||
await disposeAgentScope(owner)
|
||||
await tick()
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('work may be orphaned'))
|
||||
expect(followup).not.toHaveBeenCalled()
|
||||
expect(inject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the budget spent when the owner only claims plugin notices', async () => {
|
||||
const { ctx } = await setup({ maxConsecutiveWakes: 1 })
|
||||
const followup = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { followup, status: 'idle' })
|
||||
|
||||
await settleTasks(ctx, owner, 1)
|
||||
emitAgentEvent(ctx, owner, 'agent/inbox/claimed', {
|
||||
message: createUserMessage({
|
||||
content: [{ type: 'text', text: 'background task bash-1 finished' }],
|
||||
source: { kind: 'plugin', plugin: 'tool-tasks', form: 'notice', summary: 'bash' },
|
||||
}),
|
||||
turn: 1,
|
||||
})
|
||||
await settleTasks(ctx, owner, 1)
|
||||
expect(followup).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('completion notices', () => {
|
||||
it('injects a notice into the owning agent when an unreported task settles', async () => {
|
||||
const { ctx } = await setup()
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', inject)
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject })
|
||||
const p = producer({ owner, label: 'pnpm test' })
|
||||
ctx.tasks.start(p.spec)
|
||||
|
||||
@@ -521,7 +708,7 @@ describe('completion notices', () => {
|
||||
it('preserves task ids and collection guidance in bounded completion notices', async () => {
|
||||
const { ctx } = await setup()
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', inject)
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject })
|
||||
const first = producer({
|
||||
owner,
|
||||
kind: 'subagent',
|
||||
@@ -574,7 +761,7 @@ describe('completion notices', () => {
|
||||
prior.settle({ status: 'completed' })
|
||||
}
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', inject)
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject })
|
||||
const target = producer({
|
||||
owner,
|
||||
kind: 'pty-send',
|
||||
@@ -595,7 +782,7 @@ describe('completion notices', () => {
|
||||
it('reserves the collection-action tail when a producer supplies a smaller budget', async () => {
|
||||
const { ctx } = await setup()
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', inject)
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject })
|
||||
const tiny = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 8 })
|
||||
const short = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 32 })
|
||||
ctx.tasks.start(tiny.spec)
|
||||
@@ -616,7 +803,7 @@ describe('completion notices', () => {
|
||||
it('suppresses the notice for a task the model already killed', async () => {
|
||||
const { ctx } = await setup()
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', inject)
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject })
|
||||
const p = producer({ owner })
|
||||
ctx.tasks.start(p.spec)
|
||||
|
||||
@@ -629,7 +816,7 @@ describe('completion notices', () => {
|
||||
it('suppresses the notice when a wait returned the terminal state', async () => {
|
||||
const { ctx } = await setup()
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', inject)
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject })
|
||||
const p = producer({ owner, kind: 'subagent' })
|
||||
ctx.tasks.start(p.spec)
|
||||
|
||||
@@ -654,13 +841,13 @@ describe('completion notices', () => {
|
||||
// terminal state, so the notice lands in the old owner's (detached)
|
||||
// session instead of throwing or re-routing.
|
||||
const oldInject = vi.fn()
|
||||
const oldOwner = fakeAgent(ctx, 'shared', oldInject)
|
||||
const oldOwner = fakeAgent(ctx, 'shared', { inject: oldInject })
|
||||
const p = producer({ owner: oldOwner })
|
||||
ctx.tasks.start(p.spec)
|
||||
|
||||
detachAgent(oldOwner)
|
||||
const replacementInject = vi.fn()
|
||||
fakeAgent(ctx, 'shared', replacementInject)
|
||||
fakeAgent(ctx, 'shared', { inject: replacementInject })
|
||||
p.settle({ status: 'completed' })
|
||||
await tick()
|
||||
|
||||
@@ -671,7 +858,7 @@ describe('completion notices', () => {
|
||||
it('surfaces an inject failure through listener containment (a real bug must be visible)', async () => {
|
||||
const { ctx } = await setup()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const owner = fakeAgent(ctx, 'sess-1', () => { throw new Error('unexpected inject bug') })
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject: () => { throw new Error('unexpected inject bug') } })
|
||||
const p = producer({ owner })
|
||||
ctx.tasks.start(p.spec)
|
||||
p.settle({ status: 'completed' })
|
||||
@@ -684,7 +871,7 @@ describe('completion notices', () => {
|
||||
it('keeps using the exact owner after the agent registry is gone', async () => {
|
||||
const { ctx, agentsFiber } = await setup()
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', inject)
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject })
|
||||
|
||||
// Settlement must not depend on a later registry lookup: the exact owner
|
||||
// supplied at start remains the destination while its own scope is live.
|
||||
|
||||
Reference in New Issue
Block a user