From ec297c0ca0b419b25815423aadc251715eb22586 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 4 Aug 2026 21:02:03 +0800 Subject: [PATCH 01/16] feat(web): add fuzzy slash command discovery --- ...eb-slash-command-fuzzy-discovery.i18n.yaml | 6 ++ ...08-04-web-slash-command-fuzzy-discovery.md | 27 +++++++ ...04-web-slash-command-fuzzy-discovery.zh.md | 27 +++++++ apps/web/tests/lifecycle-chrome.e2e.ts | 9 ++- .../command-menu-fuzzy.expected.md | 3 + packages/client/ui-command/README.i18n.yaml | 4 +- packages/client/ui-command/README.md | 2 + packages/client/ui-command/README.zh.md | 2 + .../client/ui-command/src/client/service.ts | 79 +++++++++++++++++-- .../client/ui-command/tests/service.spec.ts | 26 +++++- 10 files changed, 172 insertions(+), 13 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md create mode 100644 .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md create mode 100644 apps/web/tests/snapshots/lifecycle-chrome/command-menu-fuzzy.expected.md diff --git a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml new file mode 100644 index 0000000000..900bc3562d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md +2026-08-04-web-slash-command-fuzzy-discovery.md: 8d7fe88f8d19a6edc7b51e63578c468df085c238 +2026-08-04-web-slash-command-fuzzy-discovery.zh.md: d3efdc23351a1b50853ee76fb731aee046004750 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md new file mode 100644 index 0000000000..8d7fe88f8d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md @@ -0,0 +1,27 @@ +# Agent Note: Web slash-command fuzzy discovery + +Status: implemented + +English | [中文](2026-08-04-web-slash-command-fuzzy-discovery.zh.md) + +## Problem + +The web command menu required a command-name prefix, so discovery failed when a user remembered the significant letters but not their exact positions. Broadening menu matching could make discovery easier, but command execution must remain exact and deterministic: an approximate line must never execute a nearby command. + +## Decision + +The `/` command source fuzzy-matches the typed query against command names as a case-insensitive ordered subsequence. Exact prefixes form the highest ranking class. Within each class, the strongest alignment score rewards separator boundaries and adjacent characters while penalizing leading characters and gaps; equal scores retain the host-directory and client-contribution order. Position filtering still removes argument-taking commands from inline menus before ranking. + +The scorer uses dynamic programming in `O(query length × name length)` time and `O(name length)` memory per candidate. Candidate scoring stays client-side and examines names only; descriptions do not affect matching. Menu selection still dispatches the selected exact name, while space and Enter adjudication continue to require an exact command token. + +## Alternatives considered + +**Keep prefix-only matching.** Rejected because it preserves the recall failure that motivates the feature; `/cpt` cannot discover `/compact`. + +**Match unordered characters or descriptions.** Rejected because unordered matches are difficult to predict, while description matches can surface commands whose visible names do not explain why they ranked. + +**Use a general fuzzy-search dependency.** Rejected because this surface needs one constrained subsequence rule over a small command catalog; a configurable search index would add bundle weight and ranking behavior not used by the product. + +## Consequences + +Users can discover a command from remembered in-order letters, and ranking remains stable across identical catalogs. The score is deliberately heuristic: a separator-aligned match can outrank a match with a shorter raw span. Package tests pin each ranking factor and stable ties, while the assembled Web replay snapshot pins `/cpt` resolving to `/compact`. Exact execution semantics are unchanged. diff --git a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md new file mode 100644 index 0000000000..d3efdc2335 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md @@ -0,0 +1,27 @@ +# Agent Note: Web 斜杠命令模糊发现 + +Status: implemented + +[English](2026-08-04-web-slash-command-fuzzy-discovery.md) | 中文 + +## Problem + +Web 命令菜单要求按命令名前缀匹配,因此用户只记得关键字母却不记得其准确位置时,就无法发现命令。扩大菜单的匹配范围可使命令更易发现,但命令执行仍必须保持精确匹配和确定性:近似输入行绝不能执行相近命令。 + +## Decision + +`/` 命令 source 将键入的查询作为不区分大小写的有序子序列,与命令名进行模糊匹配。精确前缀构成排名最高的一类匹配。在每类匹配中,对齐分数越高越优先:分隔符边界和相邻字符会提高分数,前导字符和间隔会降低分数;分数相同则保持 host 目录和 client contribution 的顺序。位置过滤仍会在排名前从行内菜单中移除接收参数的命令。 + +评分器对每个候选项使用动态规划,时间复杂度为 `O(query length × name length)`,空间复杂度为 `O(name length)`。候选项评分只在客户端进行且只检查命令名;命令描述不影响匹配。菜单选择仍派发所选的精确名称,而 space 与 Enter 裁决继续要求命令 token 精确匹配。 + +## Alternatives considered + +**保留仅前缀匹配。** 否决,因为本功能要解决的用户无法准确回忆前缀的问题依然存在:`/cpt` 无法发现 `/compact`。 + +**匹配无序字符或描述。** 否决,因为无序匹配难以预测,而描述匹配可能展示命令,但命令的可见名称无法解释其排名。 + +**使用通用模糊搜索依赖。** 否决,因为该界面只需对小型命令目录使用一种受限的子序列规则;可配置搜索索引会增加 bundle 体积,并引入产品未使用的排名行为。 + +## Consequences + +用户可以凭按顺序记得的字母发现命令;只要目录相同,排名就保持稳定。评分刻意采用启发式规则:与分隔符对齐的匹配可能排在原始跨度更短的匹配之前。包(package)测试固定各项排名因素以及同分时的稳定顺序,组装后的 Web 回放快照固定 `/cpt` 解析为 `/compact` 的行为。精确执行语义保持不变。 diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index b757af08d7..3883b9d6e0 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -26,6 +26,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', impor const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md') const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md') +const FUZZY_COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-fuzzy.expected.md') const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md') // Post-reload golden: the same settled conversation rebuilt purely from // persistence + history — byte-equal rendering is exactly the recovery claim. @@ -83,6 +84,12 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () expect(Math.abs( launchedBox!.y + launchedBox!.height - typedBox!.y - typedBox!.height, )).toBeLessThan(1) + await input.fill('/cpt') + await expect.poll(() => menu.getByRole('option').allTextContents()).toEqual([ + 'compactCompact older conversation history', + ]) + const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(FUZZY_COMMAND_MENU_EXPECTED, fuzzySnapshot, MODE) await input.fill('') await expect.poll(() => menu.count()).toBe(0) }) @@ -254,7 +261,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md', + 'session.jsonl', 'command-menu.expected.md', 'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md', ]) }) }) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/command-menu-fuzzy.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/command-menu-fuzzy.expected.md new file mode 100644 index 0000000000..13d915959d --- /dev/null +++ b/apps/web/tests/snapshots/lifecycle-chrome/command-menu-fuzzy.expected.md @@ -0,0 +1,3 @@ +- listbox "Trigger suggestions": + - text: Commands + - option "compact Compact older conversation history" [selected] diff --git a/packages/client/ui-command/README.i18n.yaml b/packages/client/ui-command/README.i18n.yaml index fb3743a8a7..fc3b051b58 100644 --- a/packages/client/ui-command/README.i18n.yaml +++ b/packages/client/ui-command/README.i18n.yaml @@ -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/client/ui-command/README.md -README.md: c892f2f244d7924014ad1b4d6e9fe16ff4e044e4 -README.zh.md: ed607de783e833eed94fba09bc20c74375711a4f +README.md: 37df56c815c2dfd1d54a9dc0be4e28363cc8b66c +README.zh.md: c90463dcac0095231327fa0fb7de1875457b73bc diff --git a/packages/client/ui-command/README.md b/packages/client/ui-command/README.md index c892f2f244..37df56c815 100644 --- a/packages/client/ui-command/README.md +++ b/packages/client/ui-command/README.md @@ -8,6 +8,8 @@ Client command surface (`ctx.command`): the session-keyed command-directory cach `CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt. +Menu queries fuzzy-match ordered, case-insensitive subsequences of command names. Prefixes rank first; separator boundaries, adjacent characters, and shorter gaps rank the remaining matches, with directory and contribution order breaking ties. This affects discovery only: space and Enter still require an exact command name. Rationale: [Web slash-command fuzzy discovery](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md). + `PopupSelectController` (`src/client/popup.ts`) is the headless shell state: `PopupSelectView` self-registers into `conversation.input.overlay` (the SlotMap key is ui-conversation's; this package pulls the declaration in with a type-only import — no runtime edge). The shell is a transient layer holding focus while open; token-segment consumption after onSelect runs both branches through `consumeTokenSegment` (menu-path span CAS, enter-path bare-token equality) against the draft face the wiring layer binds via `bindDraft`. The `/client` export surface is the plugin body (`apply`/`inject`), `CommandService`, the directory and popup classes with their state types, and the frozen contract types; the shell component itself is internal to the overlay registration. diff --git a/packages/client/ui-command/README.zh.md b/packages/client/ui-command/README.zh.md index ed607de783..c90463dcac 100644 --- a/packages/client/ui-command/README.zh.md +++ b/packages/client/ui-command/README.zh.md @@ -8,6 +8,8 @@ `CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。 +菜单查询会按顺序且不区分大小写地模糊匹配命令名的子序列。前缀排名最高;其余匹配项按分隔符边界优先、相邻字符优先、间隔越短越优先的规则排序,若仍同分,则以目录顺序和 contribution 顺序打破平局。此行为只影响命令发现:space 和 Enter 仍要求命令名精确匹配。原理:[Web 斜杠命令模糊发现](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)。 + `PopupSelectController`(`src/client/popup.ts`)是无头的壳状态:`PopupSelectView` 自行注册进 `conversation.input.overlay`(SlotMap key 归 ui-conversation 所有;本包只以 type-only 导入引入该声明——没有运行时依赖边)。壳是打开期间持有焦点的瞬态层;onSelect 之后的 token 片段消费在两条分支上都经 `consumeTokenSegment` 执行(菜单路径做 span CAS,回车路径做裸 token 相等比较),作用于接线层经 `bindDraft` 绑定的草稿表层。 `/client` 导出表层是插件主体(`apply`/`inject`)、`CommandService`、目录类和 popup 类及其状态类型,以及冻结的契约类型;壳组件本身是 overlay 注册的内部实现。 diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index 9784c56ced..33b42f6b51 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -2,9 +2,10 @@ * CommandService (`ctx.command`): the '/' command source over the * session-keyed directory, the client-contribution registry, and the * per-session popupSelect controllers. Candidate synthesis merges the host - * catalog with contributions by availability, then query/position filtering; - * a host/contribution name collision fails loud. Every execute addresses the - * session's agent by sessionId — sessions are always agent-backed. + * catalog with contributions by availability, then fuzzy query/position + * filtering; a host/contribution name collision fails loud. Every execute + * addresses the session's agent by sessionId — sessions are always + * agent-backed. */ import { Service } from 'cordis' import type { Context } from 'cordis' @@ -27,6 +28,69 @@ interface LiveState { readonly popups: Map> } +/** One fuzzy match with its stable source position. */ +interface RankedCandidate { + readonly candidate: SlashCandidate + readonly index: number + readonly prefix: boolean + readonly score: number +} + +/** Extra weight for command-name starts and separator boundaries. */ +function boundaryBonus(name: string, index: number): number { + return index === 0 || name.charAt(index - 1) === '-' || name.charAt(index - 1) === '_' ? 8 : 0 +} + +/** + * Score the strongest ordered-subsequence alignment in O(name × query). + * Boundary and adjacent matches earn weight; skipped and leading characters + * cost weight. + */ +function fuzzyScore(name: string, query: string): number | undefined { + if (query === '') return 0 + if (query.length > name.length) return undefined + const noMatch = Number.NEGATIVE_INFINITY + let previous = Array(name.length).fill(noMatch) + for (let index = 0; index < name.length; index++) { + if (name.charAt(index) === query.charAt(0)) previous[index] = 1 + boundaryBonus(name, index) - index + } + for (let queryIndex = 1; queryIndex < query.length; queryIndex++) { + const current = Array(name.length).fill(noMatch) + let bestGapped = noMatch + for (let index = 0; index < name.length; index++) { + const gappedIndex = index - 2 + if (gappedIndex >= 0) { + const prior = previous[gappedIndex] ?? noMatch + if (prior !== noMatch) bestGapped = Math.max(bestGapped, prior + gappedIndex) + } + if (name.charAt(index) !== query.charAt(queryIndex)) continue + const bonus = 1 + boundaryBonus(name, index) + const adjacent = index > 0 ? previous[index - 1] ?? noMatch : noMatch + if (adjacent !== noMatch) current[index] = adjacent + bonus + 4 + if (bestGapped !== noMatch) current[index] = Math.max(current[index] ?? noMatch, bestGapped + bonus + 1 - index) + } + previous = current + } + let best = noMatch + for (const score of previous) best = Math.max(best, score) + return best === noMatch ? undefined : best +} + +/** Case-insensitive fuzzy filtering with stable ordering for equal matches. */ +function fuzzyCandidates(candidates: readonly SlashCandidate[], rawQuery: string): readonly SlashCandidate[] { + const query = rawQuery.toLowerCase() + if (query === '') return candidates + const ranked: RankedCandidate[] = [] + candidates.forEach((candidate, index) => { + const name = candidate.name.toLowerCase() + const score = fuzzyScore(name, query) + if (score !== undefined) ranked.push({ candidate, index, prefix: name.startsWith(query), score }) + }) + ranked.sort((left, right) => + Number(right.prefix) - Number(left.prefix) || right.score - left.score || left.index - right.index) + return ranked.map(match => match.candidate) +} + /** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */ export class CommandService extends Service implements CommandServiceContract { static inject = ['slash', 'sessions', 'connection'] @@ -147,7 +211,7 @@ export class CommandService extends Service implements CommandServiceContract { } } - /** Menu candidates: host catalog + contribution availability, then query/position filtering. */ + /** Menu candidates: host catalog + contribution availability, then position filtering and fuzzy name ranking. */ private async candidates(session: ClientSessionContext, req: CandidateRequest): Promise { const list = await this.directory.ensureReady(session.sessionId, req.signal) const rows: SlashCandidate[] = [] @@ -163,9 +227,10 @@ export class CommandService extends Service implements CommandServiceContract { } rows.push({ name: contribution.name, description: contribution.description }) } - return rows - .filter(c => c.name.startsWith(req.query)) - .filter(c => req.position === 'leading' || c.hint === undefined) + return fuzzyCandidates( + rows.filter(c => req.position === 'leading' || c.hint === undefined), + req.query, + ) } /** Decision table, menu column: contribution/decorated-host → popup; host input → claim; host bare → detached execute. */ diff --git a/packages/client/ui-command/tests/service.spec.ts b/packages/client/ui-command/tests/service.spec.ts index 08fda13a6f..bd6d72c916 100644 --- a/packages/client/ui-command/tests/service.spec.ts +++ b/packages/client/ui-command/tests/service.spec.ts @@ -164,13 +164,33 @@ describe('candidates', () => { expect(b.listCalls).toEqual([]) }) - it('pulls the session catalog; prefix filter and hint mapping apply', async () => { + it('pulls the session catalog; fuzzy filter and hint mapping apply', async () => { const { source, listCalls } = await bench() const list = await source.candidates(proj('s1'), req('g')) expect(listCalls).toEqual([{ sessionId: sid('s1') }]) expect(list).toEqual([{ name: 'goal', description: 'leadingInput kind', hint: 'goal text' }]) }) + it('matches case-insensitive subsequences and ranks prefixes, boundaries, adjacency, gaps, then source order', async () => { + const commands: CommandDescriptor[] = [ + { name: 'q-xylophone', description: '' }, + { name: 'qx-long', description: '' }, + { name: 'fabulous', description: '' }, + { name: 'foo-bar', description: '' }, + { name: 'zuv', description: '' }, + { name: 'zu1v', description: '' }, + { name: 'yu1v', description: '' }, + { name: 'zu12v', description: '' }, + ] + const { source } = await bench({ commands: () => Promise.resolve({ commands }) }) + const names = async (query: string) => (await source.candidates(proj('s1'), req(query))).map(c => c.name) + await expect(names('QX')).resolves.toEqual(['qx-long', 'q-xylophone']) + await expect(names('fb')).resolves.toEqual(['foo-bar', 'fabulous']) + await expect(names('uv')).resolves.toEqual(['zuv', 'zu1v', 'yu1v', 'zu12v']) + await expect(names('zzz')).resolves.toEqual([]) + await expect(names('query-longer-than-every-name')).resolves.toEqual([]) + }) + it('catalogs are per session: another session pulls its own key', async () => { const { source, listCalls } = await bench() const names = (await source.candidates(proj('s2'), req(''))).map(c => c.name) @@ -195,10 +215,10 @@ describe('candidates', () => { expect(s2Names).not.toContain('theme') }) - it('contribution rows ride the same query prefix filter', async () => { + it('contribution rows ride the same fuzzy query filter', async () => { const { command, source } = await bench() command.register(themeContribution()) - const names = (await source.candidates(proj('s1'), req('th'))).map(c => c.name) + const names = (await source.candidates(proj('s1'), req('tm'))).map(c => c.name) expect(names).toEqual(['theme']) }) From ffdcafb45f6c1ef0b5fb2add63f9e193a1e2e4ac Mon Sep 17 00:00:00 2001 From: GeeeekExplorer <2651904866@qq.com> Date: Wed, 5 Aug 2026 16:42:51 +0800 Subject: [PATCH 02/16] feat(web): done dot on sessions that finished while unviewed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session that stops running while it is not the selected session arms a green 'done' reminder dot on its sidebar row, so the operator notices a finished background session and returns to it; opening the session clears the dot, and a re-run re-arms it on completion. SessionManager owns the reminder set (a sibling of the waiting-approval bit): a running->idle edge of a non-selected session arms it, select() consumes it, removal prunes it, and it survives connection generations. The bit rides SessionListEntry/SessionSummary into the workspace browser rows, which render the existing StateDot done state (running keeps the spinner) and label the hover card '已完成/Completed'. --- .../runtime/src/client/sessions/lineage.ts | 5 + .../runtime/src/client/sessions/manager.ts | 67 +++++++++- .../runtime/src/client/sessions/service.ts | 3 + packages/client/runtime/tests/lineage.spec.ts | 7 + packages/client/runtime/tests/manager.spec.ts | 125 ++++++++++++++++++ .../client/ui-workspace/src/client/locales.ts | 2 + .../ui-workspace/src/client/rows/Rows.tsx | 12 +- .../client/ui-workspace/src/client/tree.ts | 6 + .../client/ui-workspace/tests/rows.spec.tsx | 66 +++++++-- .../client/ui-workspace/tests/tree.spec.ts | 19 +++ 10 files changed, 297 insertions(+), 15 deletions(-) diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index 115370488f..69094f2964 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -29,6 +29,8 @@ export interface SessionListEntry { projectionValues?: Readonly> /** User interaction currently blocking this session, derived from live mux frames. */ pendingInteraction?: PendingInteractionStatus + /** Finished running while not selected and not yet opened — the sidebar's green "done" reminder (clears on select or the next run). */ + completed: boolean /** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */ depth: number } @@ -39,11 +41,13 @@ export interface SessionListEntry { * hydrated list from mutable timestamps. * @param summaries - the host's session.list items. * @param pendingInteractions - current manager-owned interaction status by session. + * @param completed - sessions with a pending completion reminder (manager-owned live fact; absent = false). * @returns display rows in render order. */ export function flattenLineage( summaries: readonly TitledSessionSummary[], pendingInteractions?: ReadonlyMap, + completed?: ReadonlySet, ): SessionListEntry[] { const byId = new Map() for (const s of summaries) byId.set(s.sessionId, s) @@ -72,6 +76,7 @@ export function flattenLineage( out.push({ ...s, ...(pendingInteraction === undefined ? {} : { pendingInteraction }), + completed: completed?.has(s.sessionId) ?? false, depth, }) const kids = children.get(s.sessionId) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index c9961592ba..64199c4812 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -109,6 +109,14 @@ export class SessionManager { * sessions never instantiated. Cleared per connection generation — the reopen replay re-adds * still-pending requests — and on session-removed. */ private readonly pendingInteractions = new Map>() + /** + * Sessions that finished running while not selected — the sidebar's green + * "done" reminder (manager-owned, survives connection generations; cleared + * on select and session-removed, re-armed by the next completion). + */ + private readonly completedNotifications = new Set() + /** Last-observed running bits per session; the true→false edge here arms {@link completedNotifications}. */ + private readonly prevRunning = new Map() /** Per-session projection value stores, retained independently of instance arrival (the * title-snapshot precedent, generalized): push frames land here whether or not the Session * is instantiated (list rows read the 'title' key), and an instantiated Session adopts the @@ -175,6 +183,8 @@ export class SessionManager { : this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false, ) this.selected = sessionId + // Looking at the session consumes its completion reminder (dot clears). + this.completedNotifications.delete(sessionId) void this.refreshSubagents(sessionId) this.notifier.notifyNow() } @@ -192,6 +202,7 @@ export class SessionManager { this.addresses.set(address.childSessionId, address) this.sessions.get(address.childSessionId)?.configureSubagent(address, catalog?.parentAvailable ?? false) this.selected = address.childSessionId + this.completedNotifications.delete(address.childSessionId) void this.refreshSubagents(address.childSessionId) this.notifier.notifyNow() } @@ -414,13 +425,28 @@ export class SessionManager { try { const { result } = await this.api.sessions.list({}) if (result.ok) { - let summaries = this.listPhase === 'pending' + const baseline = this.listPhase === 'pending' ? result.value.items : mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId) - for (const mutation of mutations) summaries = applyMutation(summaries, mutation) + // Seed first observations from the pull-time baseline BEFORE replaying + // in-flight mutations, then reconcile the reminders after EVERY + // replayed mutation: an edge that happens entirely between mutations + // (baseline idle → running → idle) must still arm, which a single + // sync on the folded result would collapse away. + for (const s of baseline) { + if (!this.prevRunning.has(s.sessionId)) this.prevRunning.set(s.sessionId, s.running) + } + let summaries = baseline + for (const mutation of mutations) { + summaries = applyMutation(summaries, mutation) + this.summaries = summaries + this.syncCompletedNotifications() + } this.summaries = summaries this.listState = 'idle' this.listPhase = 'ready' + // Covers the empty-mutations pull (a plain baseline carries no edge). + this.syncCompletedNotifications() // Push running/blank bits down to instantiated Sessions (the list is the authoritative summary source). for (const s of this.summaries) { const session = this.sessions.get(s.sessionId) @@ -566,6 +592,8 @@ export class SessionManager { private recordMutation(mutation: SessionListMutation): void { this.listMutations?.push(mutation) this.summaries = applyMutation(this.summaries, mutation) + // Eager edge reconciliation — a snapshot-build-time pass would miss consecutive status frames. + this.syncCompletedNotifications() this.notifier.markDirty() } @@ -893,6 +921,38 @@ export class SessionManager { }) } + /** + * Reconcile completion reminders against the latest summaries, eagerly after + * every mutation and pull (a snapshot-build-time pass would collapse + * consecutive status frames into one observation). A running→idle edge of a + * non-selected session arms its reminder; running disarms it; removal drops + * it. First observation only records the running bit — sessions already + * idle at load get no reminder. + */ + private syncCompletedNotifications(): void { + const seen = new Set() + for (const s of this.summaries) { + seen.add(s.sessionId) + const prev = this.prevRunning.get(s.sessionId) + if (prev === undefined) { + this.prevRunning.set(s.sessionId, s.running) + continue + } + if (prev && !s.running) { + if (s.sessionId !== this.selected) this.completedNotifications.add(s.sessionId) + } else if (s.running) { + this.completedNotifications.delete(s.sessionId) + } + this.prevRunning.set(s.sessionId, s.running) + } + for (const id of this.prevRunning.keys()) { + if (!seen.has(id)) this.prevRunning.delete(id) + } + for (const id of this.completedNotifications) { + if (!seen.has(id)) this.completedNotifications.delete(id) + } + } + private buildListSnapshot(): SessionListSnapshot { const merged: TitledSessionSummary[] = this.summaries.map((summary) => { // List rows read the generic 'title' projection key (host-computed unit @@ -914,7 +974,7 @@ export class SessionManager { const status = statuses.find(candidate => candidate !== 'approval') ?? statuses[0] if (status !== undefined) pendingInteractions.set(sessionId, status) } - const fresh = flattenLineage(merged, pendingInteractions) + const fresh = flattenLineage(merged, pendingInteractions, this.completedNotifications) const items = fresh.map((entry) => { const prev = this.entryCache.get(entry.sessionId) if ( @@ -924,6 +984,7 @@ export class SessionManager { && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth && prev.pendingInteraction === entry.pendingInteraction && prev.projectionValues === entry.projectionValues + && prev.completed === entry.completed ) return prev this.entryCache.set(entry.sessionId, entry) return entry diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 9399f594d3..b1b271e702 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -51,6 +51,8 @@ export interface SessionSummary { running: boolean /** User interaction currently blocking this session (sidebar amber-dot state). */ pendingInteraction?: PendingInteractionStatus + /** Finished while not selected and not yet opened — the sidebar's green "done" reminder. Absent = false. */ + completed?: boolean /** * Empty-log bit (host summary derivation mirror). New Session reuses a blank * one targeting the same workspace. Filtering stays with the consumer: the @@ -614,6 +616,7 @@ export class SessionsService implements ISessions { id: entry.sessionId, displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId), running: entry.running, + ...(entry.completed ? { completed: true } : {}), blank: entry.blank, updatedAt: entry.updatedAt, ...(entry.pendingInteraction === undefined diff --git a/packages/client/runtime/tests/lineage.spec.ts b/packages/client/runtime/tests/lineage.spec.ts index c616c19462..7d3c948f3e 100644 --- a/packages/client/runtime/tests/lineage.spec.ts +++ b/packages/client/runtime/tests/lineage.spec.ts @@ -52,4 +52,11 @@ describe('flattenLineage', () => { warnSpy.mockRestore() } }) + + it('projects the completion-reminder set into rows (absent = false)', () => { + const out = flattenLineage([s('a', 10), s('b', 20)], undefined, new Set(['b' as SessionId])) + expect(out.find(e => e.sessionId === 'a')?.completed).toBe(false) + expect(out.find(e => e.sessionId === 'b')?.completed).toBe(true) + expect(flattenLineage([s('a', 10)])[0]?.completed).toBe(false) + }) }) diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 909a293b3e..e203e49dd9 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -985,3 +985,128 @@ describe('pending-interaction list status', () => { expect(session.getSnapshot().pending).toEqual([]) }) }) + +describe('completed reminder', () => { + const status = (rpcId: string, sessionId: SessionId, running: boolean) => ({ + rpcId: rpcId as never, + payload: { type: 'host/session-status' as const, sessionId, running }, + }) + const added = (rpcId: string, sessionId: SessionId) => ({ + rpcId: rpcId as never, + payload: { type: 'host/session-added' as const, sessionId, blank: false }, + }) + const entry = (manager: SessionManager, sessionId: SessionId) => + manager.getListSnapshot().items.find(item => item.sessionId === sessionId) + + it('arms on a running→idle flip of a non-selected session and clears on select', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope(added('h1', S1)) + manager.handleHostEnvelope(added('h2', S2)) + manager.select(S1) + expect(entry(manager, S2)?.completed).toBe(false) + manager.handleHostEnvelope(status('s1', S2, true)) + manager.handleHostEnvelope(status('s2', S2, false)) + expect(entry(manager, S2)?.completed).toBe(true) + // Opening the session consumes the reminder. + manager.select(S2) + expect(entry(manager, S2)?.completed).toBe(false) + }) + + it('never arms for the session being watched and re-arms after a switch-away re-run', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope(added('h1', S1)) + manager.handleHostEnvelope(added('h2', S2)) + manager.select(S2) + manager.handleHostEnvelope(status('s1', S2, true)) + manager.handleHostEnvelope(status('s2', S2, false)) + expect(entry(manager, S2)?.completed).toBe(false) // watched to completion: no reminder + // Switch away; a fresh run completing again arms the reminder. + manager.select(S1) + manager.handleHostEnvelope(status('s3', S2, true)) + manager.handleHostEnvelope(status('s4', S2, false)) + expect(entry(manager, S2)?.completed).toBe(true) + }) + + it('a re-run disarms the reminder while running and re-arms on its completion', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope(added('h1', S1)) + manager.handleHostEnvelope(added('h2', S2)) + manager.select(S1) + manager.handleHostEnvelope(status('s1', S2, true)) + manager.handleHostEnvelope(status('s2', S2, false)) + expect(entry(manager, S2)?.completed).toBe(true) + // The user starts a new run without opening the session: running wins. + manager.handleHostEnvelope(status('s3', S2, true)) + expect(entry(manager, S2)?.completed).toBe(false) + manager.handleHostEnvelope(status('s4', S2, false)) + expect(entry(manager, S2)?.completed).toBe(true) + }) + + it('session-removed drops the reminder and a re-add starts clean', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope(added('h1', S1)) + manager.handleHostEnvelope(added('h2', S2)) + manager.select(S1) + manager.handleHostEnvelope(status('s1', S2, true)) + manager.handleHostEnvelope(status('s2', S2, false)) + expect(entry(manager, S2)?.completed).toBe(true) + manager.handleHostEnvelope({ rpcId: 'rm' as never, payload: { type: 'host/session-removed', sessionId: S2 } }) + expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toBeUndefined() + manager.handleHostEnvelope(added('h3', S2)) + expect(entry(manager, S2)?.completed).toBe(false) + }) + + it('a list refresh carrying the running→idle transition arms the reminder', async () => { + const api = new FakeApiClient() + api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] })) + const manager = new SessionManager(api) + await manager.refreshList() + manager.select(S1) + expect(entry(manager, S2)?.completed).toBe(false) + api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: false })] as never[] })) + await manager.refreshList() + expect(entry(manager, S2)?.completed).toBe(true) + }) + + it('never arms for sessions already idle at first observation', async () => { + const api = new FakeApiClient() + api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) + const manager = new SessionManager(api) + await manager.refreshList() + manager.select(S1) + expect(entry(manager, S2)?.completed).toBe(false) + api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 201 })] as never[] })) + await manager.refreshList() + expect(entry(manager, S2)?.completed).toBe(false) + }) + + it('arms a completion that happened during an in-flight first pull (baseline running, replayed idle)', async () => { + const api = new FakeApiClient() + const gate = deferred>>() + api.onList = () => gate.promise + const manager = new SessionManager(api) + const refresh = manager.refreshList() + // The session finishes while the first pull is still in flight; the pull + // response recorded it as running at pull time. + manager.handleHostEnvelope(status('s-mid', S2, false)) + gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] })) + await refresh + expect(entry(manager, S2)?.completed).toBe(true) + }) + + it('arms when a session ran and completed entirely between in-flight mutations (baseline idle)', async () => { + const api = new FakeApiClient() + const gate = deferred>>() + api.onList = () => gate.promise + const manager = new SessionManager(api) + const refresh = manager.refreshList() + // The unknown session starts and finishes while the first pull is in + // flight; the pull-time baseline recorded it idle, so the running→idle + // edge lives entirely inside the replayed mutations. + manager.handleHostEnvelope(status('s-start', S2, true)) + manager.handleHostEnvelope(status('s-finish', S2, false)) + gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) + await refresh + expect(entry(manager, S2)?.completed).toBe(true) + }) +}) diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index b9e06a6ae2..d9c70de729 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -49,6 +49,7 @@ export const zh = { 'status.waitingApproval': '等待审批', 'status.planReview': '计划待审', 'status.waitingAnswer': '等待回答', + 'status.completed': '已完成', 'hover.created': '创建于 {time}', 'hover.copied': '已复制', 'date.ymd': '{y}年{m}月{d}日', @@ -109,6 +110,7 @@ export const en = { 'status.waitingApproval': 'Waiting for approval', 'status.planReview': 'Plan awaiting review', 'status.waitingAnswer': 'Waiting for answer', + 'status.completed': 'Completed', 'hover.created': 'Created {time}', 'hover.copied': 'Copied', 'date.ymd': '{y}-{m}-{d}', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 836325076b..fb64a0be42 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -173,7 +173,7 @@ function assertNever(value: never): never { /** Session status presentation; pending user interaction outranks the running state. */ function sessionStatus( - node: Pick, + node: Pick, t: RowTranslate, ): { state: StateDotState; label: string } { switch (node.pendingInteraction) { @@ -185,10 +185,11 @@ function sessionStatus( default: return assertNever(node.pendingInteraction) } if (node.running) return { state: 'ongoing', label: t('status.running') } + if (node.completed) return { state: 'done', label: t('status.completed') } return { state: 'done', label: t('status.idle') } } -/** Hover-card body: full title, relative time, and interaction/running/idle status. */ +/** Hover-card body: full title, relative time, and interaction/running/completed/idle status. */ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) { const status = sessionStatus(node, t) return ( @@ -251,7 +252,7 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { > - {status.state !== 'done' && ( + {(status.state !== 'done' || result.completed) && ( <> {status.label} @@ -351,8 +352,11 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork drag.drop(rowHalf(e)) }} > + {/* Pending interactions and running outrank the idle state; a + finished-but-unviewed session shows the green done reminder dot + (cleared by opening the session). */} - {status.state !== 'done' && ( + {(status.state !== 'done' || row.completed) && ( <> {status.label} diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 1a9f42504c..90153211ea 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -24,6 +24,8 @@ export interface SessionNode { /** The runtime Session list reports an interaction awaiting this user. */ pendingInteraction?: PendingInteractionStatus running: boolean + /** Finished running while not selected and not yet opened (the green "done" reminder dot). */ + completed: boolean updatedAt: number } @@ -54,6 +56,8 @@ export interface SearchResultNode { /** The runtime Session list reports an interaction awaiting this user. */ pendingInteraction?: PendingInteractionStatus running: boolean + /** Finished running while not selected and not yet opened (the green "done" reminder dot). */ + completed: boolean snippet?: string } @@ -175,6 +179,7 @@ function sessionNode(s: SessionSummary): SessionNode { title: sessionTitle(s), blank: s.blank, running: s.running, + completed: s.completed === true, updatedAt: s.updatedAt, ...(s.pendingInteraction === undefined ? {} : { pendingInteraction: s.pendingInteraction }), } @@ -330,6 +335,7 @@ export function deriveSearchResults( ...(summary.pendingInteraction === undefined ? {} : { pendingInteraction: summary.pendingInteraction }), + completed: summary.completed === true, ...match === undefined ? {} : { snippet: match.snippet }, } }), diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index 1f5387cf43..1c8fd1f703 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -64,6 +64,7 @@ describe('workspace browser rows', () => { title: 'Result title', workspace: 'Workspace context', running: true, + completed: false, snippet: 'matching message excerpt', } render() @@ -85,7 +86,7 @@ describe('workspace browser rows', () => { ] as const)('shows %s ahead of running in search results', (pendingInteraction, label) => { const result: SearchResultNode = { id: sid(pendingInteraction), title: 'Needs input', workspace: 'Project', - pendingInteraction, running: true, + pendingInteraction, running: true, completed: false, } render() const row = screen.getByRole('treeitem') @@ -114,7 +115,7 @@ describe('workspace browser rows', () => { it('renders and opens a selected running Session row', () => { const node: SessionNode = { - id: sid('session'), title: 'Session', blank: false, running: true, updatedAt: 0, + id: sid('session'), title: 'Session', blank: false, running: true, completed: false, updatedAt: 0, } const onOpen = vi.fn() render( @@ -130,6 +131,38 @@ describe('workspace browser rows', () => { expect(onOpen).toHaveBeenCalledWith(node.id) }) + it('shows the green done dot only on a finished, unviewed session (running wins the slot)', () => { + const renderRow = (over: Partial) => render( + , + ) + const stateDot = (view: ReturnType) => + view.container.querySelector('[data-state]') + // No completion reminder, not running: no state dot at all. + const plain = renderRow({}) + expect(stateDot(plain)).toBeNull() + plain.unmount() + // Completed while unviewed: the green done dot. + const done = renderRow({ completed: true }) + expect(done.container.querySelector('[data-state="done"]')).not.toBeNull() + done.unmount() + // Running wins the slot: the animated ongoing dot, no done dot. + const running = renderRow({ completed: true, running: true }) + expect(running.container.querySelector('[data-state="ongoing"]')).not.toBeNull() + expect(running.container.querySelector('[data-state="done"]')).toBeNull() + }) + + it('shows the green done dot on a finished search result row', () => { + render() + expect(screen.getByRole('treeitem').querySelector('[data-state="done"]')).not.toBeNull() + }) + it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => { const onRename = vi.fn() const onDelete = vi.fn() @@ -198,7 +231,7 @@ describe('workspace browser rows', () => { vi.useFakeTimers() try { const node: SessionNode = { - id: sid('s-blank'), title: 'ignored', blank: true, running: false, updatedAt: 0, + id: sid('s-blank'), title: 'ignored', blank: true, running: false, completed: false, updatedAt: 0, } render() @@ -224,7 +257,7 @@ describe('workspace browser rows', () => { const onFork = vi.fn() const onArchive = vi.fn() const node: SessionNode = { - id: sid('s1'), title: 'One', blank: false, running: false, updatedAt: 0, + id: sid('s1'), title: 'One', blank: false, running: false, completed: false, updatedAt: 0, } render() @@ -257,7 +290,7 @@ describe('workspace browser rows', () => { vi.useFakeTimers() try { const node: SessionNode = { - id: sid('s1'), title: 'Hovered', blank: false, running: true, updatedAt: 0, + id: sid('s1'), title: 'Hovered', blank: false, running: true, completed: false, updatedAt: 0, } render() @@ -288,7 +321,7 @@ describe('workspace browser rows', () => { try { const node: SessionNode = { id: sid(pendingInteraction), title: 'Needs input', blank: false, - pendingInteraction, running: true, updatedAt: 0, + pendingInteraction, running: true, completed: false, updatedAt: 0, } const view = render() @@ -314,7 +347,7 @@ describe('workspace browser rows', () => { vi.useFakeTimers() try { const node: SessionNode = { - id: sid('s1'), title: 'Quiet', blank: false, running: false, updatedAt: 0, + id: sid('s1'), title: 'Quiet', blank: false, running: false, completed: false, updatedAt: 0, } render() @@ -327,9 +360,26 @@ describe('workspace browser rows', () => { } }) + it('completed hover card shows the Completed status line', () => { + vi.useFakeTimers() + try { + const node: SessionNode = { + id: sid('s1'), title: 'Done', blank: false, running: false, completed: true, updatedAt: 0, + } + render() + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + // Row's visually-hidden reminder label plus the hover card's status line. + expect(screen.getAllByText('已完成')).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => { const node: SessionNode = { - id: sid('s1'), title: 'Drag me', blank: false, running: false, updatedAt: 0, + id: sid('s1'), title: 'Drag me', blank: false, running: false, completed: false, updatedAt: 0, } const inactive = dragProps() const { rerender } = render( diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index a15fffa3d8..fed1c03eec 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -77,6 +77,22 @@ describe('deriveGroups', () => { expect(strayGroups.map(group => group.key)).toEqual(['first']) }) + it('projects the completion reminder into session and search rows (absent = false)', () => { + const done = { ...summary('done', 3), completed: true } + const plain = summary('plain', 2) + const sessions = list(done, plain) + const groups = deriveGroups( + sessions, [workspace('first', ['done', 'plain'])], noArchive, view(['first']), + ) + const doneNode = groups[0]!.sessions.find(session => session.id === done.id)! + const plainNode = groups[0]!.sessions.find(session => session.id === plain.id)! + expect(doneNode.completed).toBe(true) + expect(plainNode.completed).toBe(false) + expect(deriveFlat(sessions, noArchive).find(node => node.id === done.id)!.completed).toBe(true) + const search = deriveSearchResults(sessions, [workspace('first', ['done', 'plain'])], 'done', noArchive, { items: [], hasMore: false }, 10) + expect(search.items[0]?.completed).toBe(true) + }) + it('hides subagent-origin sessions without hiding ordinary forks', () => { const parent = summary('parent', 1) const fork = { ...summary('fork', 2), parentId: parent.id } @@ -259,6 +275,7 @@ describe('deriveSearchResults', () => { workspace: 'Alpha', running: false, pendingInteraction: 'plan-review', + completed: false, snippet: 'title session body excerpt', }, { @@ -266,12 +283,14 @@ describe('deriveSearchResults', () => { title: 'Ordinary title', workspace: 'Needle Workspace', running: false, + completed: false, }, { id: contentHit.id, title: 'content-hit', workspace: 'c', running: false, + completed: false, snippet: 'body needle excerpt', }, ], From 7313be1d2dcd76b2d7e2abdfa2cbfe5b3c02aa91 Mon Sep 17 00:00:00 2001 From: GeeeekExplorer <2651904866@qq.com> Date: Thu, 6 Aug 2026 00:28:58 +0800 Subject: [PATCH 03/16] docs: agent note for the session completion dot --- ...08-06-session-completed-done-dot.i18n.yaml | 6 +++++ .../2026-08-06-session-completed-done-dot.md | 25 +++++++++++++++++++ ...026-08-06-session-completed-done-dot.zh.md | 25 +++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 .agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.md create mode 100644 .agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.i18n.yaml new file mode 100644 index 0000000000..eb0a37d991 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.md +2026-08-06-session-completed-done-dot.md: bd6911ce137f1272090c86c029710c9f4054ee6d +2026-08-06-session-completed-done-dot.zh.md: 9ec2199a29d1307c3ebd0238e84d5f90d36fe21c diff --git a/.agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.md b/.agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.md new file mode 100644 index 0000000000..bd6911ce13 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.md @@ -0,0 +1,25 @@ +# Agent Note: Session completion dot in the sidebar + +Status: implemented + +English | [中文](2026-08-06-session-completed-done-dot.zh.md) + +## Problem + +A session the operator delegated work to and then left (switched to another conversation) gives no signal when it finishes. Its running indicator stops, but the row then looks identical to any idle session, so the operator must poll the list or discover the finished work late. The pending-interaction amber dot covers sessions that need input, not sessions whose work is simply done. + +## Decision + +`SessionManager` owns a client-side completion-reminder set, a sibling of the pending-interaction bit: a running→idle edge of a session that is not the selected one arms its reminder; `select()`/`selectSubagent()` consume it; starting a new run disarms it and its completion re-arms it; removal prunes it. The bit rides `SessionListEntry` → `SessionSummary` (optional, absent = no reminder) into the workspace browser, whose session and search rows render the existing `StateDot` `done` state — running keeps the ongoing spinner, an idle session without a reminder shows nothing — and whose hover card labels the reminder 已完成 / Completed. + +The reminder is in-memory and per browser. It survives connection generations — a transport blip does not invalidate "you have not looked yet" — but not a page reload. + +## Consequences + +The sidebar row states become three disjoint signals: green = finished and unviewed, amber = awaiting the operator's input, blue = running. No wire, on-disk, or configuration format changes: `SessionSummary.completed` is optional, so existing consumers and test fixtures stay valid, and only the workspace browser reads it. The completion edge is detected eagerly at every list mutation and pull (a snapshot-build-time-only pass would collapse two consecutive status frames into one observation and miss the completion). + +## Alternatives considered + +- **Component-local UI state.** Rejected because the sidebar unmounts on collapse and multiple surfaces (grouped tree, flat list, search) need the same bit; the manager already owns the running transitions and the selection, so a manager-owned set is the one source all surfaces can project. +- **Event-driven arming from status frames only.** Rejected because a list pull can also carry a running→idle transition (a session finished while the refresh was in flight); the reminder is reconciled against every mutation and pull. +- **Persisting the reminder.** Rejected because the reminder means "you have not looked at this session yet" in this browser; reload restores the selection and the user is looking at the list again, so a durable bit would only go stale. diff --git a/.agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.zh.md b/.agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.zh.md new file mode 100644 index 0000000000..9ec2199a29 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 侧边栏会话完成提醒点 + +Status: implemented + +[English](2026-08-06-session-completed-done-dot.md) | 中文 + +## Problem + +操作者派发任务后切换到其他会话,原会话完成时没有任何信号。运行指示停止后,该行与普通空闲会话看起来完全一样,操作者只能反复查看列表或很晚才发现工作已完成。等待交互的琥珀点只覆盖需要操作者输入的会话,不覆盖"只是干完了活"的会话。 + +## Decision + +`SessionManager` 持有客户端侧的完成提醒集合,与待交互位并列:非当前会话发生 running→idle 边沿时点亮其提醒;`select()`/`selectSubagent()` 消费掉提醒;重新开始一轮运行会熄灭提醒并在再次完成时重新点亮;会话被移除时清理提醒。该位经 `SessionListEntry` → `SessionSummary`(可选字段,缺省 = 无提醒)进入工作区浏览区,其会话行与搜索结果行渲染现有的 `StateDot` `done` 状态——运行中仍显示转圈,无提醒的空闲会话不显示任何点——悬停卡片将该提醒标注为"已完成 / Completed"。 + +提醒仅存在于内存中且按浏览器实例隔离。它跨连接代存活——传输抖动不会使"你还没回来看"失效——但页面刷新后重置。 + +## Consequences + +侧边栏行状态成为三个互斥信号:绿 = 已完成且未查看,琥珀 = 等待操作者输入,蓝 = 运行中。无 wire、磁盘或配置格式变更:`SessionSummary.completed` 为可选字段,现有消费者与测试 fixture 保持有效,只有工作区浏览区读取它。完成边沿在每次列表变更与拉取时即时检测(仅在建快照时检测会把连续两个状态帧折叠为一次观察,从而漏掉完成事件)。 + +## Alternatives considered + +- **组件本地 UI 状态。** 已拒绝:侧边栏折叠时会卸载,且多个界面(分组树、单列表、搜索)需要同一状态位;manager 本就持有运行状态迁移与选中状态,manager 持有的集合是所有界面都能投影的唯一事实源。 +- **仅从状态帧做事件驱动点亮。** 已拒绝:列表拉取本身也可能携带 running→idle 迁移(刷新在途时会话已完成);提醒需对每次变更与拉取做对账。 +- **持久化提醒。** 已拒绝:提醒的含义是"此浏览器里你还没查看该会话";刷新会恢复选中状态且用户正看着列表,持久化位只会过期。 From ccebba2349b79a1d46bd40aa9736641a0cc646b3 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 6 Aug 2026 12:13:14 +0800 Subject: [PATCH 04/16] refactor(agent): unify agent-scoped event signatures as payload objects All agent/* and agent-loop/config-start-failed events take one payload object carrying the agent subject; waterfall/serial payloads require a signal and keep next as the final argument. PreStepContext and RequestFailureContext are unfolded into payloads and retired. goal/changed follows the same shape so agentEvents keeps its listener error containment. ReactLoopAgent builds its scope carrier once in the constructor. Regenerates scope resolvers, tool-cordis api catalog, and docs catalogs; updates all affected listeners, tests, and the core-data-structures docs (en + zh). --- apps/cli/src/headless.ts | 2 +- docs/cordis-catalog/events.md | 129 +++++++++--------- docs/core-data-structures/core.md | 14 +- docs/core-data-structures/core.zh.md | 14 +- .../fixtures/subagent-durability-failure.ts | 4 +- .../headless-agent/tests/code-mode.e2e.ts | 2 +- .../tests/fixtures/cli-mock-llm.ts | 2 +- .../tests/fixtures/goal-domain/seed-goal.ts | 2 +- examples/headless-agent/tests/harness.ts | 2 +- packages/acp/acp/src/index.ts | 4 +- packages/acp/acp/tests/turns.spec.ts | 6 +- .../bash/tool-bash/tests/integration.spec.ts | 2 +- packages/compact/compact-basic/src/index.ts | 11 +- .../compact-basic/tests/compact-basic.spec.ts | 5 +- .../tests/compact-loop-repro.spec.ts | 6 +- packages/context/time-context/src/index.ts | 4 +- .../time-context/tests/time-context.spec.ts | 5 +- packages/context/tmux-context/src/index.ts | 4 +- .../tmux-context/tests/tmux-context.spec.ts | 3 +- .../context/workspace-context/src/index.ts | 4 +- .../tests/workspace-context.e2e.ts | 2 +- .../tests/workspace-context.spec.ts | 52 +++---- .../cordis/tool-cordis/src/api-catalog.ts | 56 ++++---- .../tool-cordis/tests/integration.spec.ts | 2 +- packages/core/agent-loop/src/agent.ts | 29 ++-- packages/core/agent-loop/src/index.ts | 12 +- .../agent-loop/tests/agent-initiator.spec.ts | 8 +- packages/core/agent-loop/tests/agent.spec.ts | 16 +-- packages/core/agent-loop/tests/cancel.spec.ts | 20 +-- .../tests/config-session-id.spec.ts | 12 +- .../tests/contract-regressions.spec.ts | 36 ++--- .../agent-loop/tests/coverage-edges.spec.ts | 20 +-- .../agent-loop/tests/interception.spec.ts | 42 +++--- packages/core/agent-loop/tests/loop.spec.ts | 24 ++-- .../core/agent-loop/tests/properties.spec.ts | 4 +- .../agent-loop/tests/request-cache.e2e.ts | 2 +- .../agent-loop/tests/request-error.spec.ts | 8 +- .../tests/request-reconstruction.spec.ts | 18 +-- packages/core/agent-loop/tests/resume.spec.ts | 16 +-- .../agent-loop/tests/scope-lifecycle.spec.ts | 36 ++--- .../core/agent-loop/tests/tool-calls.spec.ts | 2 +- .../core/agent-loop/tests/tool-order.spec.ts | 2 +- packages/core/agent/src/dispatch.ts | 78 +++++++---- packages/core/agent/src/index.ts | 4 +- packages/core/agent/src/invariant.ts | 2 +- packages/core/agent/src/llm-target.ts | 2 +- packages/core/agent/src/types.ts | 113 +++++++-------- packages/core/agent/tests/agent.spec.ts | 22 +-- packages/core/agent/tests/invariant.spec.ts | 14 +- packages/core/agent/tests/llm-target.spec.ts | 8 +- .../core/scope/src/scoped-events.generated.ts | 26 ++-- packages/core/scope/tests/invariant.spec.ts | 30 ++-- .../examples/acp-demo/tests/acp-agent.spec.ts | 2 +- .../agent-spine-demo/tests/agent-core.spec.ts | 2 +- .../examples/cli-demo/tests/cli-demo.spec.ts | 2 +- packages/examples/cli-demo/tests/cli.spec.ts | 4 +- packages/fs/tool-fs/tests/harness.ts | 2 +- packages/goal/goal-session/src/index.ts | 20 +-- .../goal-session/tests/goal-session.spec.ts | 46 ++++--- packages/goal/goal/src/domain.ts | 6 +- packages/goal/goal/src/index.ts | 4 +- packages/goal/goal/tests/goal.spec.ts | 8 +- .../goal/tool-goal/tests/tool-goal.spec.ts | 2 +- packages/guard/repeat-tool-guard/src/index.ts | 2 +- .../tests/repeat-tool-guard.spec.ts | 2 +- packages/hooks/hooks-claude/src/index.ts | 6 +- .../hooks-claude/tests/coverage-cases.ts | 2 +- packages/hooks/hooks-codex/src/index.ts | 6 +- .../hooks/hooks-codex/tests/coverage-cases.ts | 2 +- packages/host/apiproxy/src/api-proxy.ts | 4 +- .../apiproxy/tests/api-proxy-fork.spec.ts | 2 +- .../apiproxy/tests/api-proxy-models.spec.ts | 4 +- packages/llm/llm-retry/src/index.ts | 15 +- packages/llm/llm-retry/tests/retry.spec.ts | 12 +- packages/plan/plan-mode/src/index.ts | 4 +- .../plan/plan-mode/tests/integration.spec.ts | 4 +- .../plan/plan-mode/tests/plan-mode.spec.ts | 5 +- .../session-checkpoint-policy/src/index.ts | 2 +- .../tests/session-checkpoint-policy.spec.ts | 2 +- packages/skill/tool-skill/src/index.ts | 4 +- .../skill/tool-skill/tests/tool-skill.spec.ts | 11 +- .../subagent/subagent-inprocess/src/index.ts | 2 +- .../subagent/subagent-spawn/tests/harness.ts | 2 +- .../subagent/subagent/src/continuation.ts | 6 +- .../subagent/tests/continuation.spec.ts | 32 ++--- .../tests/tool-subagent-report.spec.ts | 8 +- .../session-telemetry/src/coordinator.ts | 2 +- .../session-telemetry/tests/telemetry.spec.ts | 2 +- .../todo/tool-todo/tests/integration.spec.ts | 2 +- packages/ui/jsonrpc/src/server.ts | 2 +- packages/ui/jsonrpc/tests/server.spec.ts | 4 +- 91 files changed, 574 insertions(+), 618 deletions(-) diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 8c40dde156..5ceccb330e 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -111,7 +111,7 @@ export async function runHeadless(task: string): Promise { const abort = new AbortController() const frames = api.events.mux({}, abort.signal) const idle = new Promise((resolve) => { - ctx.on('agent/status', (agent, status) => { + ctx.on('agent/status', ({ agent, status }) => { if (agent.id === created.sessionId && status === 'idle') resolve() }) }) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 5044b0e5ce..9bb45173e6 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -24,16 +24,16 @@ A fully configured agent and live session were published. Setup is composition-o * Synchronous listener failure vetoes publication, while returned-promise * rejection is reported. Detach requested during dispatch waits until every * creation listener has observed the stable entry. - * @param agent - the newly registered agent with its live session and completed setup. + * @param payload.agent - the newly registered agent with its live session and completed setup. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/created'(this: Scoped, agent: Agent): void +'agent/created'(this: Scoped, payload: { agent: Agent }): void ``` Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:178`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:154`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -44,16 +44,16 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco * An agent left the registry; AgentLoop emits this after driver quiescence * and scoped-registration unwind, but before session detachment. Custom * registry users own their driver-ordering contract. - * @param agent - the exact agent removed from the registry. + * @param payload.agent - the exact agent removed from the registry. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/disposed'(this: Scoped, agent: Agent): void +'agent/disposed'(this: Scoped, payload: { agent: Agent }): void ``` Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:187`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -63,19 +63,19 @@ A step or turn errored. The machine reports a failure here even when the error h /** * A step or turn errored. The machine reports a failure here even when * the error has no in-turn position for a durable record. - * @param agent - the agent whose turn errored. - * @param turn - the turn in which the failure surfaced. - * @param step - the step at which the failure surfaced. - * @param error - the failure, verbatim. + * @param payload.agent - the agent whose turn errored. + * @param payload.turn - the turn in which the failure surfaced. + * @param payload.step - the step at which the failure surfaced. + * @param payload.error - the failure, verbatim. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/error'(this: Scoped, agent: Agent, turn: number, step: number, error: unknown): void +'agent/error'(this: Scoped, payload: { agent: Agent; turn: number; step: number; error: unknown }): void ``` Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/types.ts) ### `agent/inbox/claimed` — emit @@ -86,17 +86,18 @@ One message left the inbox inside its open turn. If the proposed step is rejecte * One message left the inbox inside its open turn. If the proposed step * is rejected, the claimed message ends here: it is neither discarded nor * re-emitted as a user/message, and the turn closes without a step. - * @param agent - the agent whose inbox changed. - * @param event - the claimed message and owning turn. + * @param payload.agent - the agent whose inbox changed. + * @param payload.message - the claimed message. + * @param payload.turn - the owning turn. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/inbox/claimed'(this: Scoped, agent: Agent, event: { message: UserMessage; turn: number }): void +'agent/inbox/claimed'(this: Scoped, payload: { agent: Agent; message: UserMessage; turn: number }): void ``` Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:215`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:192`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discarded` — emit @@ -105,17 +106,17 @@ One message was discarded from the live inbox. ```ts cordis-catalog /** * One message was discarded from the live inbox. - * @param agent - the agent whose inbox changed. - * @param event - the discarded message. + * @param payload.agent - the agent whose inbox changed. + * @param payload.message - the discarded message. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/inbox/discarded'(this: Scoped, agent: Agent, event: { message: UserMessage }): void +'agent/inbox/discarded'(this: Scoped, payload: { agent: Agent; message: UserMessage }): void ``` Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:223`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:200`](../../packages/core/agent/src/types.ts) ### `agent/inbox/inserted` — emit @@ -124,17 +125,17 @@ One message entered the live inbox. ```ts cordis-catalog /** * One message entered the live inbox. - * @param agent - the agent whose inbox changed. - * @param event - the inserted message. + * @param payload.agent - the agent whose inbox changed. + * @param payload.message - the inserted message. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/inbox/inserted'(this: Scoped, agent: Agent, event: { message: UserMessage }): void +'agent/inbox/inserted'(this: Scoped, payload: { agent: Agent; message: UserMessage }): void ``` Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:205`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:181`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — waterfall @@ -144,18 +145,20 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p /** * Reject a proposed step or replace the messages that enter it. Calling * `next()` preserves the current messages. - * @param agent - the agent proposing the step. - * @param messages - messages removed from the inbox for this step. - * @param context - proposed turn and step coordinates plus cancellation. + * @param payload.agent - the agent proposing the step. + * @param payload.messages - messages removed from the inbox for this step. + * @param payload.turn - the turn that will own the step. + * @param payload.step - the step proposed by the loop. + * @param payload.signal - the current turn's cancellation signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ -'agent/pre-step'(this: Scoped, agent: Agent, messages: UserMessage[], context: PreStepContext, next: () => Promise): Promise +'agent/pre-step'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise ``` -Types: [Agent](../core-data-structures/core.md) · [PreStepContext](../core-data-structures/core.md) · [PreStepDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) +Types: [Agent](../core-data-structures/core.md) · [PreStepDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:247`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:226`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -167,19 +170,19 @@ Replace the frozen call configuration. `await next()` yields the config the mach * the machine would use (agent options on the first request, the logged * header afterwards); return a replacement to switch. Model-visible * content must use logged channels; this seam cannot mutate messages. - * @param agent - the agent making the model call. - * @param turn - the open turn number. - * @param step - the step whose request this is. - * @param signal - the current turn's explicit abort signal. + * @param payload.agent - the agent making the model call. + * @param payload.turn - the open turn number. + * @param payload.step - the step whose request this is. + * @param payload.signal - the current turn's explicit abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ -'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise): Promise +'agent/request'(this: Scoped, payload: { agent: Agent; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise ``` Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:260`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -191,18 +194,22 @@ Handle one failed model-request attempt before the loop retries or closes its st * its step. A listener returns `{ kind: 'retry' }` without calling `next()` * when it owns recovery, or calls `next()` to delegate. The default * `undefined` leaves the failure terminal. - * @param agent - the agent whose request failed. - * @param context - request coordinates, provider, normalized failure, and serving policy. - * @param signal - the turn abort signal. + * @param payload.agent - the agent whose request failed. + * @param payload.turn - the turn containing the failed request. + * @param payload.step - the step containing the failed request attempt. + * @param payload.provider - the provider selected for the failed request. + * @param payload.failure - serializable facts normalized at the final adapter boundary. + * @param payload.retryPolicy - the policy of the adapter registration that served the failed request. + * @param payload.signal - the turn abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ -'agent/request-error'(this: Scoped, agent: Agent, context: RequestFailureContext, signal: AbortSignal, next: () => Promise): Promise +'agent/request-error'(this: Scoped, payload: { agent: Agent; turn: number; step: number; provider: string; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined; signal: AbortSignal }, next: () => Promise): Promise ``` -Types: [Agent](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [RequestFailureContext](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:272`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:255`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -214,17 +221,17 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to * `agent.inject()` to seed model-facing context. This is a notification, not * a veto; disposal requested by a lifecycle owner is rechecked before the * driver starts. - * @param agent - the agent whose session lifecycle began. - * @param source - why the session started (fresh startup, resume, …). + * @param payload.agent - the agent whose session lifecycle began. + * @param payload.source - why the session started (fresh startup, resume, …). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/session-start'(this: Scoped, agent: Agent, source: SessionStartSource): void +'agent/session-start'(this: Scoped, payload: { agent: Agent; source: SessionStartSource }): void ``` Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:235`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -235,17 +242,17 @@ Agent status changed (`idle` ⇄ `running`). A waking delivery enters `running` * Agent status changed (`idle` ⇄ `running`). A waking delivery enters * `running` synchronously after reserving cancellation; `idle` means no * driver remains scheduled or active. - * @param agent - the agent whose status flipped. - * @param status - the status just entered (the transition's destination). + * @param payload.agent - the agent whose status flipped. + * @param payload.status - the status just entered (the transition's destination). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void +'agent/status'(this: Scoped, payload: { agent: Agent; status: AgentStatus }): void ``` Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:197`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:173`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial @@ -263,18 +270,18 @@ The turn is about to close: the model owes no response (no live tool calls, no f * never short-circuits already-submitted next-step work: same-step * `additionalContexts` or racing steering still runs, and the turn * closes only when that inbox drains. - * @param agent - the agent whose turn is at its stop boundary. - * @param turn - the turn about to close. - * @param signal - the current turn's explicit abort signal. + * @param payload.agent - the agent whose turn is at its stop boundary. + * @param payload.turn - the turn about to close. + * @param payload.signal - the current turn's explicit abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode serial */ -'agent/turn-stopping'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | void +'agent/turn-stopping'(this: Scoped, payload: { agent: Agent; turn: number; signal: AbortSignal }): Promise | void ``` Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:290`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:273`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` @@ -288,11 +295,11 @@ A declarative agent entry failed before it could publish a live agent. Consumers * Consumers that buffer work for the configured identity use this * transient signal to reject that work instead of waiting forever. Normal * factory teardown suppresses failures from the cancelled startup attempt. - * @param sessionId - exact shared agent/session identity that failed startup. - * @param error - persistence, setup, or publication failure. + * @param payload.sessionId - exact shared agent/session identity that failed startup. + * @param payload.error - persistence, setup, or publication failure. * @mode emit */ -'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void +'agent-loop/config-start-failed'(payload: { sessionId: SessionId; error: unknown }): void ``` Types: [SessionId](../core-data-structures/core.md) @@ -456,11 +463,11 @@ Goal mutation accepted by one live agent. The matching `goal/change` session eve * Goal mutation accepted by one live agent. The matching `goal/change` * session event has already committed. Listener failures are contained. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @param agent - agent whose session owns the goal. - * @param change - fresh current projection or clear tombstone. + * @param payload.agent - agent whose session owns the goal. + * @param payload.change - fresh current projection or clear tombstone. * @mode emit */ -'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped, agent: Agent, change: GoalChanged): void +'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped, payload: { agent: Agent; change: GoalChanged }): void ``` Types: [Agent](../core-data-structures/core.md) · [GoalChanged](../core-data-structures/goal.md) · [Scoped](../core-data-structures/scope.md) diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 6886d9f15c..499f20b430 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -607,19 +607,7 @@ Pre-step decisions use the same identified `UserMessage` shape as durable user-r Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) -`agent/pre-step` receives the exclusive claimed batch and the proposed step's coordinates and cancellation signal. The initial proposal runs inside an open turn before any step; a tool continuation may submit an empty claimed batch between steps: - -```ts type-equiv -/** Coordinates and cancellation for a proposed step. */ -interface PreStepContext { - /** Turn that will own the step. */ - readonly turn: number - /** Step proposed by the loop. */ - readonly step: number - /** Current turn cancellation signal. */ - readonly signal: AbortSignal -} -``` +`agent/pre-step` receives one payload carrying the exclusive claimed batch (`messages`), the proposed step's coordinates (`turn`, `step`), and the current turn's cancellation `signal`. The initial proposal runs inside an open turn before any step; a tool continuation may submit an empty claimed batch between steps: It returns a `PreStepDecision`. Reject opens no step. Enter supplies the complete message batch appended after `step/start`; claimed messages omitted by the final decision remain removed, while input inserted after the claim stays pending: diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index f89365dcdd..4b3a1381f9 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -615,19 +615,7 @@ pre-step 决策使用与持久 user-role 输入相同、带标识的 `UserMessag 源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) -`agent/pre-step` 接收独占的已领取批次,以及拟进入步骤的坐标与取消 signal。首次提案在已打开的轮次内、任何步骤开始前运行;工具 continuation 可以在步骤之间提交空的已领取批次: - -```ts type-equiv -/** Coordinates and cancellation for a proposed step. */ -interface PreStepContext { - /** Turn that will own the step. */ - readonly turn: number - /** Step proposed by the loop. */ - readonly step: number - /** Current turn cancellation signal. */ - readonly signal: AbortSignal -} -``` +`agent/pre-step` 接收一个 payload,携带独占的已领取批次(`messages`)、拟进入步骤的坐标(`turn`、`step`)与当前轮次的取消 `signal`。首次提案在已打开的轮次内、任何步骤开始前运行;工具 continuation 可以在步骤之间提交空的已领取批次: 它返回 `PreStepDecision`。reject 不会打开步骤。enter 提供在 `step/start` 后追加的完整消息批次;最终决策省略的已领取消息保持已删除,而领取后插入的输入仍留待后续处理: diff --git a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts index 5dd19bb348..d3ffa4e8a7 100644 --- a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts +++ b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts @@ -84,13 +84,13 @@ export function apply(ctx: Context): void { // runs, so the queued FIFO order is what the transcript records. The first // child enqueue is the initial delegation, which also pins the real child id. let accepted = 0 - ctx.on('agent/inbox/inserted', (agent) => { + ctx.on('agent/inbox/inserted', ({ agent }) => { if (agent.session.header.parentSession === undefined) return if (realChildId === undefined) realChildId = agent.session.header.id accepted += 1 if (accepted >= 3) followupsAccepted.resolve(undefined) }) - ctx.on('agent/pre-step', async (agent, _messages, _context, next) => { + ctx.on('agent/pre-step', async ({ agent }, next) => { if (agent.session.header.parentSession !== undefined) await followupsAccepted.promise return next() }) diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index 648e77156c..ad049ab308 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -302,7 +302,7 @@ describe('Code Mode typed values: keyless real-worker contracts', () => { function waitForIdle(harness: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = harness.on('agent/status', (subject, status) => { + const dispose = harness.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() diff --git a/examples/headless-agent/tests/fixtures/cli-mock-llm.ts b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts index 72aa7b199d..80a4f7e240 100644 --- a/examples/headless-agent/tests/fixtures/cli-mock-llm.ts +++ b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts @@ -59,7 +59,7 @@ export const inject = ['llm'] /** Register the keyless `cli-mock` adapter. */ export function apply(ctx: Context): void { ctx.llm.registerAdapter(['cli-mock'], new CliMockAdapter()) - ctx.on('agent/request', async (_agent, _turn, step, _signal, next) => { + ctx.on('agent/request', async ({ step }, next) => { const config = await next() return step === 2 ? { ...config, reasoningEffort: OFF } : config }) diff --git a/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts b/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts index de64e4599b..d8dc2c2465 100644 --- a/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts +++ b/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts @@ -7,7 +7,7 @@ export const name = 'seed-goal' export const inject = ['goals'] export function apply(ctx: Context): void { - ctx.on('agent/pre-step', (agent, _messages, _context, next) => { + ctx.on('agent/pre-step', ({ agent }, next) => { if (ctx.goals.get(agent) === undefined) { ctx.goals.create(agent, { objective: 'Prove the composed goal survives in the session log', diff --git a/examples/headless-agent/tests/harness.ts b/examples/headless-agent/tests/harness.ts index 756cc58e39..b57a4c2d2d 100644 --- a/examples/headless-agent/tests/harness.ts +++ b/examples/headless-agent/tests/harness.ts @@ -84,7 +84,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio export function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index a794c52901..50549d7ab0 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -184,13 +184,13 @@ export function apply(ctx: Context, config: AcpConfig): void { } }) - ctx.on('agent/inbox/claimed', (agent, { message, turn }) => { + ctx.on('agent/inbox/claimed', ({ agent, message, turn }) => { const record = ownedRecord(agent) const inflight = record?.inflight if (inflight !== undefined && inflight.messageId === message.id) inflight.turn = turn }) - ctx.on('agent/error', (agent, turn, _step, error) => { + ctx.on('agent/error', ({ agent, turn, error }) => { const record = ownedRecord(agent) const inflight = record?.inflight if (record === undefined || inflight === undefined || inflight.turn === turn) return diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index 329aff6d96..f2cffb4010 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -87,7 +87,7 @@ describe('ACP prompt lifecycle', () => { const sessionId = await newSession(harness) const agent = harness.ctx.agents.get(SessionId(sessionId))! let injected = false - harness.ctx.on('agent/inbox/inserted', (subject, { message }) => { + harness.ctx.on('agent/inbox/inserted', ({ agent: subject, message }) => { if (subject === agent && message.source.kind === 'user' && !injected) { injected = true agent.inject(createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } })) @@ -235,7 +235,7 @@ describe('ACP prompt lifecycle', () => { harness = await makeBridgeHarness({ script: [errorResponse('transient boom'), textResponse('recovered')] }) // A recovery policy: schedule one retry for the failed request. let retried = false - harness.ctx.on('agent/request-error', async (_subject) => { + harness.ctx.on('agent/request-error', async () => { if (!retried) { retried = true return { kind: 'retry' } @@ -272,7 +272,7 @@ describe('ACP prompt lifecycle', () => { it('cancels a prompt removed before its turn claims it', async () => { harness = await makeBridgeHarness({ script: [] }) const sessionId = await newSession(harness) - const dispose = harness.ctx.on('agent/inbox/inserted', (agent, { message }) => { + const dispose = harness.ctx.on('agent/inbox/inserted', ({ agent, message }) => { if (message.source.kind === 'user') agent.inbox.remove(message.id) }) diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 933df15509..cba38b6546 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -48,7 +48,7 @@ afterEach(() => { function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 0bf76975ba..211ac8a920 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -144,9 +144,7 @@ export class BasicCompactService extends CompactService { } ctx.on('agent/pre-step', async ( - agent: Agent, - _messages, - { signal }, + { agent, signal }, next, ): Promise => { if (!signal.aborted) { @@ -165,7 +163,7 @@ export class BasicCompactService extends CompactService { return next() }) - ctx.on('agent/status', (agent, status) => { + ctx.on('agent/status', ({ agent, status }) => { if (status === 'idle') this.overflowRetries.delete(agent) }) @@ -178,12 +176,9 @@ export class BasicCompactService extends CompactService { }) ctx.on('agent/request-error', async ( - agent, - context, - signal, + { agent, failure, signal }, next, ) => { - const { failure } = context if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next() this.overflowAgents.set(agent.session, agent) const target = routedTarget(agent.session) diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index fddbaceb80..a8efad741b 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -1372,7 +1372,7 @@ describe('default one-shot summarizer', () => { describe('automatic listener and loader composition', () => { function preStep(ctx: Context, owner: Agent, signal = SIGNAL) { return agentEvents(ctx, owner).waterfall( - 'agent/pre-step', [], { turn: 1, step: 1, signal }, + 'agent/pre-step', { messages: [], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) } @@ -1388,8 +1388,7 @@ describe('automatic listener and loader composition', () => { const turn = owner.session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 1 return agentEvents(ctx, owner).waterfall( 'agent/request-error', - { turn, step: 1, provider: 'test', failure, retryPolicy: undefined }, - signal, + { turn, step: 1, provider: 'test', failure, retryPolicy: undefined, signal }, next, ).then(action => action?.kind === 'retry') } diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index 132135bd48..471207aa97 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -175,7 +175,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() @@ -217,7 +217,7 @@ function overflowHistorySeed(): SessionEvent[] { describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => { it('uses the model actually routed by agent/request for post-step pressure', async () => { const { ctx } = await harness(8) - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({ + ctx.on('agent/request', async (_payload, next) => ({ ...await next(), provider: 'mock', model: 'mock', })) try { @@ -315,7 +315,7 @@ describe('context-overflow recovery across the real loop and compact-basic', () await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TokenMeterService) ctx.llm.registerAdapter(['mock'], adapter) - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({ + ctx.on('agent/request', async (_payload, next) => ({ ...await next(), provider: 'mock', model: 'mock', })) await ctx.plugin(BasicCompactService, { diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index ff939219aa..98f6d41e85 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -157,9 +157,7 @@ export function apply(ctx: Context, config: Config): void { const resolvedTimeZone = formatter.resolvedOptions().timeZone ctx.on('agent/pre-step', async ( - agent: Agent, - _messages, - { turn, step, signal }, + { agent, turn, step, signal }, next, ): Promise => { const decision = await next() diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 1b85595bb9..3a74e80509 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -82,8 +82,7 @@ async function fire( ): Promise { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - [], - { turn, step, signal }, + { messages: [], turn, step, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) if (decision.kind === 'enter') { @@ -366,7 +365,7 @@ describe('real agent-loop request history', () => { ] as const)('does not commit a preparation reading when a downstream pre-step listener %s', async (mode) => { const adapter = new ScriptedAdapter([textResponse('unused')]) const ctx = await loopHarness(adapter) - ctx.on('agent/pre-step', (subject, _messages, _context, next) => { + ctx.on('agent/pre-step', ({ agent: subject }, next) => { if (mode === 'throws') throw new Error('later pre-step failure') subject.cancel({ kind: 'user' }) return next() diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts index 130efb919b..3a743d2c90 100644 --- a/packages/context/tmux-context/src/index.ts +++ b/packages/context/tmux-context/src/index.ts @@ -216,9 +216,7 @@ export function apply(ctx: Context, config: Config): void { validateRefreshInterval(refreshIntervalMs) ctx.on('agent/pre-step', async ( - agent: Agent, - _messages, - { turn, step, signal }, + { agent, turn, step, signal }, next, ): Promise => { const decision = await next() diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index e7b501d462..9756ca2c82 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -138,8 +138,7 @@ async function fire( ): Promise { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - [], - { turn, step, signal }, + { messages: [], turn, step, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) if (decision.kind === 'enter') { diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index be9e2aa806..23db00c43b 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -212,9 +212,7 @@ export function apply(ctx: Context, config: Config): void { } ctx.on('agent/pre-step', async ( - agent: Agent, - messages, - { step, signal }, + { agent, messages, step, signal }, next, ): Promise => { const decision = await next() diff --git a/packages/context/workspace-context/tests/workspace-context.e2e.ts b/packages/context/workspace-context/tests/workspace-context.e2e.ts index 6a8095da0e..c1151428e2 100644 --- a/packages/context/workspace-context/tests/workspace-context.e2e.ts +++ b/packages/context/workspace-context/tests/workspace-context.e2e.ts @@ -57,7 +57,7 @@ async function harness(): Promise<{ ctx: Context; agent: Agent }> { function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index b55b11bbe1..163aa37106 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -209,8 +209,7 @@ async function workspaceContextOf(agent: Agent): Promise { async function syncWorkspaceContext(ctx: Context, agent: Agent): Promise { await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', [], - { turn: 1, step: 1, signal: testToolSignal }, + 'agent/pre-step', { messages: [], turn: 1, step: 1, signal: testToolSignal }, async () => ({ kind: 'enter' as const, messages: [] }), ) } @@ -245,15 +244,13 @@ async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise Promise.resolve({ kind: 'enter' as const, messages: [] }), ) const claimed = agent.inbox.claim('next-step', 1) const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - claimed, - { turn: 1, step: 2, signal }, + { messages: claimed, turn: 1, step: 2, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: claimed }), ) const entered = decision.kind === 'enter' ? decision.messages : [] @@ -968,8 +965,7 @@ describe('workspace context request injection', () => { const original = stubAgent(root) await agentEvents(ctx, original).waterfall( 'agent/pre-step', - [], - { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) const inserted = original.inbox.nextStep[0] @@ -978,12 +974,11 @@ describe('workspace context request injection', () => { await fiber.dispose() await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const resumed = stubAgent(root, [...original.session.events]) - agentEvents(ctx, resumed).emit('agent/session-start', 'resume') + agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = resumed.inbox.claim('next-step', 1) const decision = await agentEvents(ctx, resumed).waterfall( 'agent/pre-step', - claimed, - { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve({ kind: 'enter' as const, messages: claimed }), ) if (decision.kind !== 'enter') throw new Error('recovered baseline was rejected') @@ -1015,8 +1010,7 @@ describe('workspace context request injection', () => { const original = stubAgent(root) await agentEvents(ctx, original).waterfall( 'agent/pre-step', - [], - { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) const stale = original.inbox.nextStep[0] @@ -1026,12 +1020,11 @@ describe('workspace context request injection', () => { await fiber.dispose() await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const resumed = stubAgent(root, [...original.session.events]) - agentEvents(ctx, resumed).emit('agent/session-start', 'resume') + agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const staleClaim = resumed.inbox.claim('next-step', 1) const staleDecision = await agentEvents(ctx, resumed).waterfall( 'agent/pre-step', - staleClaim, - { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: staleClaim, turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve({ kind: 'enter' as const, messages: staleClaim }), ) @@ -1070,8 +1063,7 @@ describe('workspace context request injection', () => { const original = stubAgent(root) await agentEvents(originalCtx, original).waterfall( 'agent/pre-step', - [], - { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) const stale = original.inbox.nextStep[0] @@ -1081,12 +1073,11 @@ describe('workspace context request injection', () => { if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' }) await resumedCtx.plugin(workspaceContext, { dshHome: home, maxBytes }) const resumed = stubAgent(root, [...original.session.events]) - agentEvents(resumedCtx, resumed).emit('agent/session-start', 'resume') + agentEvents(resumedCtx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = resumed.inbox.claim('next-step', 1) const decision = await agentEvents(resumedCtx, resumed).waterfall( 'agent/pre-step', - claimed, - { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve({ kind: 'enter' as const, messages: claimed }), ) @@ -1188,8 +1179,7 @@ describe('workspace context request injection', () => { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - [prompt], - { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: [prompt], turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve(downstream), ) @@ -1246,8 +1236,7 @@ describe('workspace context request injection', () => { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - [], - { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve(downstream), ) @@ -1353,7 +1342,7 @@ describe('workspace context request injection', () => { const resumed = stubAgent(root, [...original.session.events]) // Resume announces its lifecycle start before the first step. - agentEvents(ctx, resumed).emit('agent/session-start', 'resume') + agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) await composeBaselinePrefix(ctx, resumed) const baselines = baselineEvents(resumed) @@ -1401,7 +1390,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'repo rule') const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - ctx.on('agent/pre-step', async (_agent, _messages, _context, next) => { + ctx.on('agent/pre-step', async (_payload, next) => { const decision = await next() if (decision.kind === 'reject') return decision return { @@ -1675,8 +1664,7 @@ describe('workspace context request injection', () => { const reason = new Error('cancel prefix') const pending = agentEvents(ctx, stubAgent(root)).waterfall( 'agent/pre-step', - [], - { turn: 1, step: 1, signal: controller.signal }, + { messages: [], turn: 1, step: 1, signal: controller.signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) @@ -3860,8 +3848,7 @@ describe('workspace context inbox synchronization', () => { controller.abort(new Error('abort pre-step reconciliation')) await expect(agentEvents(ctx, agent).waterfall( - 'agent/pre-step', [], - { turn: 1, step: 1, signal: controller.signal }, + 'agent/pre-step', { messages: [], turn: 1, step: 1, signal: controller.signal }, async () => ({ kind: 'enter' as const, messages: [] }), )).rejects.toThrow('abort pre-step reconciliation') @@ -3971,8 +3958,7 @@ describe('workspace context inbox synchronization', () => { const downstream = { kind: 'enter' as const, messages: claimed } const decision = await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', claimed, - { turn: 1, step: 1, signal: testToolSignal }, + 'agent/pre-step', { messages: claimed, turn: 1, step: 1, signal: testToolSignal }, async () => downstream, ) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 40b354fce3..de83ecfc9e 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1217,92 +1217,92 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'agent-loop/config-start-failed', mode: 'emit', - signature: '\'agent-loop/config-start-failed\'(sessionId: SessionId, error: unknown): void', - jsDoc: '/**\n * A declarative agent entry failed before it could publish a live agent.\n * Consumers that buffer work for the configured identity use this\n * transient signal to reject that work instead of waiting forever. Normal\n * factory teardown suppresses failures from the cancelled startup attempt.\n * @param sessionId - exact shared agent/session identity that failed startup.\n * @param error - persistence, setup, or publication failure.\n * @mode emit\n */', + signature: '\'agent-loop/config-start-failed\'(payload: { sessionId: SessionId; error: unknown }): void', + jsDoc: '/**\n * A declarative agent entry failed before it could publish a live agent.\n * Consumers that buffer work for the configured identity use this\n * transient signal to reject that work instead of waiting forever. Normal\n * factory teardown suppresses failures from the cancelled startup attempt.\n * @param payload.sessionId - exact shared agent/session identity that failed startup.\n * @param payload.error - persistence, setup, or publication failure.\n * @mode emit\n */', summary: 'A declarative agent entry failed before it could publish a live agent.', }, { name: 'agent/created', mode: 'emit', - signature: '\'agent/created\'(this: Scoped, agent: Agent): void', - jsDoc: '/**\n * A fully configured agent and live session were published. Setup is\n * composition-only; `agent/session-start` is the first startup-driving seam.\n * Synchronous listener failure vetoes publication, while returned-promise\n * rejection is reported. Detach requested during dispatch waits until every\n * creation listener has observed the stable entry.\n * @param agent - the newly registered agent with its live session and completed setup.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/created\'(this: Scoped, payload: { agent: Agent }): void', + jsDoc: '/**\n * A fully configured agent and live session were published. Setup is\n * composition-only; `agent/session-start` is the first startup-driving seam.\n * Synchronous listener failure vetoes publication, while returned-promise\n * rejection is reported. Detach requested during dispatch waits until every\n * creation listener has observed the stable entry.\n * @param payload.agent - the newly registered agent with its live session and completed setup.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'A fully configured agent and live session were published.', }, { name: 'agent/disposed', mode: 'emit', - signature: '\'agent/disposed\'(this: Scoped, agent: Agent): void', - jsDoc: '/**\n * An agent left the registry; AgentLoop emits this after driver quiescence\n * and scoped-registration unwind, but before session detachment. Custom\n * registry users own their driver-ordering contract.\n * @param agent - the exact agent removed from the registry.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/disposed\'(this: Scoped, payload: { agent: Agent }): void', + jsDoc: '/**\n * An agent left the registry; AgentLoop emits this after driver quiescence\n * and scoped-registration unwind, but before session detachment. Custom\n * registry users own their driver-ordering contract.\n * @param payload.agent - the exact agent removed from the registry.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'An agent left the registry; AgentLoop emits this after driver quiescence and scoped-registration unwind, but before session detachment.', }, { name: 'agent/error', mode: 'emit', - signature: '\'agent/error\'(this: Scoped, agent: Agent, turn: number, step: number, error: unknown): void', - jsDoc: '/**\n * A step or turn errored. The machine reports a failure here even when\n * the error has no in-turn position for a durable record.\n * @param agent - the agent whose turn errored.\n * @param turn - the turn in which the failure surfaced.\n * @param step - the step at which the failure surfaced.\n * @param error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/error\'(this: Scoped, payload: { agent: Agent; turn: number; step: number; error: unknown }): void', + jsDoc: '/**\n * A step or turn errored. The machine reports a failure here even when\n * the error has no in-turn position for a durable record.\n * @param payload.agent - the agent whose turn errored.\n * @param payload.turn - the turn in which the failure surfaced.\n * @param payload.step - the step at which the failure surfaced.\n * @param payload.error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'A step or turn errored.', }, { name: 'agent/inbox/claimed', mode: 'emit', - signature: '\'agent/inbox/claimed\'(this: Scoped, agent: Agent, event: { message: UserMessage; turn: number }): void', - jsDoc: '/**\n * One message left the inbox inside its open turn. If the proposed step\n * is rejected, the claimed message ends here: it is neither discarded nor\n * re-emitted as a user/message, and the turn closes without a step.\n * @param agent - the agent whose inbox changed.\n * @param event - the claimed message and owning turn.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/inbox/claimed\'(this: Scoped, payload: { agent: Agent; message: UserMessage; turn: number }): void', + jsDoc: '/**\n * One message left the inbox inside its open turn. If the proposed step\n * is rejected, the claimed message ends here: it is neither discarded nor\n * re-emitted as a user/message, and the turn closes without a step.\n * @param payload.agent - the agent whose inbox changed.\n * @param payload.message - the claimed message.\n * @param payload.turn - the owning turn.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'One message left the inbox inside its open turn.', }, { name: 'agent/inbox/discarded', mode: 'emit', - signature: '\'agent/inbox/discarded\'(this: Scoped, agent: Agent, event: { message: UserMessage }): void', - jsDoc: '/**\n * One message was discarded from the live inbox.\n * @param agent - the agent whose inbox changed.\n * @param event - the discarded message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/inbox/discarded\'(this: Scoped, payload: { agent: Agent; message: UserMessage }): void', + jsDoc: '/**\n * One message was discarded from the live inbox.\n * @param payload.agent - the agent whose inbox changed.\n * @param payload.message - the discarded message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'One message was discarded from the live inbox.', }, { name: 'agent/inbox/inserted', mode: 'emit', - signature: '\'agent/inbox/inserted\'(this: Scoped, agent: Agent, event: { message: UserMessage }): void', - jsDoc: '/**\n * One message entered the live inbox.\n * @param agent - the agent whose inbox changed.\n * @param event - the inserted message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/inbox/inserted\'(this: Scoped, payload: { agent: Agent; message: UserMessage }): void', + jsDoc: '/**\n * One message entered the live inbox.\n * @param payload.agent - the agent whose inbox changed.\n * @param payload.message - the inserted message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'One message entered the live inbox.', }, { name: 'agent/pre-step', mode: 'waterfall', - signature: '\'agent/pre-step\'(this: Scoped, agent: Agent, messages: UserMessage[], context: PreStepContext, next: () => Promise): Promise', - jsDoc: '/**\n * Reject a proposed step or replace the messages that enter it. Calling\n * `next()` preserves the current messages.\n * @param agent - the agent proposing the step.\n * @param messages - messages removed from the inbox for this step.\n * @param context - proposed turn and step coordinates plus cancellation.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + signature: '\'agent/pre-step\'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise', + jsDoc: '/**\n * Reject a proposed step or replace the messages that enter it. Calling\n * `next()` preserves the current messages.\n * @param payload.agent - the agent proposing the step.\n * @param payload.messages - messages removed from the inbox for this step.\n * @param payload.turn - the turn that will own the step.\n * @param payload.step - the step proposed by the loop.\n * @param payload.signal - the current turn\'s cancellation signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', summary: 'Reject a proposed step or replace the messages that enter it.', }, { name: 'agent/request', mode: 'waterfall', - signature: '\'agent/request\'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Replace the frozen call configuration. `await next()` yields the config\n * the machine would use (agent options on the first request, the logged\n * header afterwards); return a replacement to switch. Model-visible\n * content must use logged channels; this seam cannot mutate messages.\n * @param agent - the agent making the model call.\n * @param turn - the open turn number.\n * @param step - the step whose request this is.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n*/', + signature: '\'agent/request\'(this: Scoped, payload: { agent: Agent; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise', + jsDoc: '/**\n * Replace the frozen call configuration. `await next()` yields the config\n * the machine would use (agent options on the first request, the logged\n * header afterwards); return a replacement to switch. Model-visible\n * content must use logged channels; this seam cannot mutate messages.\n * @param payload.agent - the agent making the model call.\n * @param payload.turn - the open turn number.\n * @param payload.step - the step whose request this is.\n * @param payload.signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n*/', summary: 'Replace the frozen call configuration.', }, { name: 'agent/request-error', mode: 'waterfall', - signature: '\'agent/request-error\'(this: Scoped, agent: Agent, context: RequestFailureContext, signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Handle one failed model-request attempt before the loop retries or closes\n * its step. A listener returns `{ kind: \'retry\' }` without calling `next()`\n * when it owns recovery, or calls `next()` to delegate. The default\n * `undefined` leaves the failure terminal.\n * @param agent - the agent whose request failed.\n * @param context - request coordinates, provider, normalized failure, and serving policy.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + signature: '\'agent/request-error\'(this: Scoped, payload: { agent: Agent; turn: number; step: number; provider: string; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined; signal: AbortSignal }, next: () => Promise): Promise', + jsDoc: '/**\n * Handle one failed model-request attempt before the loop retries or closes\n * its step. A listener returns `{ kind: \'retry\' }` without calling `next()`\n * when it owns recovery, or calls `next()` to delegate. The default\n * `undefined` leaves the failure terminal.\n * @param payload.agent - the agent whose request failed.\n * @param payload.turn - the turn containing the failed request.\n * @param payload.step - the step containing the failed request attempt.\n * @param payload.provider - the provider selected for the failed request.\n * @param payload.failure - serializable facts normalized at the final adapter boundary.\n * @param payload.retryPolicy - the policy of the adapter registration that served the failed request.\n * @param payload.signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', summary: 'Handle one failed model-request attempt before the loop retries or closes its step.', }, { name: 'agent/session-start', mode: 'emit', - signature: '\'agent/session-start\'(this: Scoped, agent: Agent, source: SessionStartSource): void', - jsDoc: '/**\n * The session lifecycle began, once before the first turn. Use\n * `agent.inject()` to seed model-facing context. This is a notification, not\n * a veto; disposal requested by a lifecycle owner is rechecked before the\n * driver starts.\n * @param agent - the agent whose session lifecycle began.\n * @param source - why the session started (fresh startup, resume, …).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/session-start\'(this: Scoped, payload: { agent: Agent; source: SessionStartSource }): void', + jsDoc: '/**\n * The session lifecycle began, once before the first turn. Use\n * `agent.inject()` to seed model-facing context. This is a notification, not\n * a veto; disposal requested by a lifecycle owner is rechecked before the\n * driver starts.\n * @param payload.agent - the agent whose session lifecycle began.\n * @param payload.source - why the session started (fresh startup, resume, …).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'The session lifecycle began, once before the first turn.', }, { name: 'agent/status', mode: 'emit', - signature: '\'agent/status\'(this: Scoped, agent: Agent, status: AgentStatus): void', - jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`). A waking delivery enters\n * `running` synchronously after reserving cancellation; `idle` means no\n * driver remains scheduled or active.\n * @param agent - the agent whose status flipped.\n * @param status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/status\'(this: Scoped, payload: { agent: Agent; status: AgentStatus }): void', + jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`). A waking delivery enters\n * `running` synchronously after reserving cancellation; `idle` means no\n * driver remains scheduled or active.\n * @param payload.agent - the agent whose status flipped.\n * @param payload.status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'Agent status changed (`idle` ⇄ `running`).', }, { name: 'agent/turn-stopping', mode: 'serial', - signature: '\'agent/turn-stopping\'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | void', - jsDoc: '/**\n * The turn is about to close: the model owes no response (no live tool\n * calls, no fresh steering). Awaited before the boundary commits — a\n * listener that objects steers (`agent.steer(...)`) and the machine\n * re-reads its inbox: fresh steering runs another step, none closes the\n * turn. Data decides, so listener order cannot change the outcome. The\n * inverse control (stop a tool loop early) is data too: a tool result\n * carrying `concludesTurn` ends the turn at its step. The conclusion\n * never short-circuits already-submitted next-step work: same-step\n * `additionalContexts` or racing steering still runs, and the turn\n * closes only when that inbox drains.\n * @param agent - the agent whose turn is at its stop boundary.\n * @param turn - the turn about to close.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', + signature: '\'agent/turn-stopping\'(this: Scoped, payload: { agent: Agent; turn: number; signal: AbortSignal }): Promise | void', + jsDoc: '/**\n * The turn is about to close: the model owes no response (no live tool\n * calls, no fresh steering). Awaited before the boundary commits — a\n * listener that objects steers (`agent.steer(...)`) and the machine\n * re-reads its inbox: fresh steering runs another step, none closes the\n * turn. Data decides, so listener order cannot change the outcome. The\n * inverse control (stop a tool loop early) is data too: a tool result\n * carrying `concludesTurn` ends the turn at its step. The conclusion\n * never short-circuits already-submitted next-step work: same-step\n * `additionalContexts` or racing steering still runs, and the turn\n * closes only when that inbox drains.\n * @param payload.agent - the agent whose turn is at its stop boundary.\n * @param payload.turn - the turn about to close.\n * @param payload.signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', summary: 'The turn is about to close: the model owes no response (no live tool calls, no fresh steering).', }, { @@ -1357,8 +1357,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'goal/changed', mode: 'emit', - signature: '\'goal/changed\'(this: import(\'@deepseek-ai/dsh-scope\').Scoped, agent: Agent, change: GoalChanged): void', - jsDoc: '/**\n * Goal mutation accepted by one live agent. The matching `goal/change`\n * session event has already committed. Listener failures are contained.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - agent whose session owns the goal.\n * @param change - fresh current projection or clear tombstone.\n * @mode emit\n */', + signature: '\'goal/changed\'(this: import(\'@deepseek-ai/dsh-scope\').Scoped, payload: { agent: Agent; change: GoalChanged }): void', + jsDoc: '/**\n * Goal mutation accepted by one live agent. The matching `goal/change`\n * session event has already committed. Listener failures are contained.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param payload.agent - agent whose session owns the goal.\n * @param payload.change - fresh current projection or clear tombstone.\n * @mode emit\n */', summary: 'Goal mutation accepted by one live agent.', }, { diff --git a/packages/cordis/tool-cordis/tests/integration.spec.ts b/packages/cordis/tool-cordis/tests/integration.spec.ts index 01a748a284..488ab996b3 100644 --- a/packages/cordis/tool-cordis/tests/integration.spec.ts +++ b/packages/cordis/tool-cordis/tests/integration.spec.ts @@ -28,7 +28,7 @@ async function harness(adapter: MockAdapter): Promise { function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 1931d6efb0..9ca98c5723 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -11,9 +11,10 @@ import type { AgentStatus, CancelOptions, InboxTarget, + PreStepDecision, RequestErrorAction, } from '@deepseek-ai/dsh-agent' -import { Inbox, agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent' +import { Inbox, agentCarrier, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent' import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm' import { BlockAssembler, @@ -23,7 +24,7 @@ import { errorChain, markAgentLoopRequest, } from '@deepseek-ai/dsh-llm' -import type { Scope } from '@deepseek-ai/dsh-scope' +import type { Scope, Scoped } from '@deepseek-ai/dsh-scope' import { createScope } from '@deepseek-ai/dsh-scope' import type { EpochHeader, RequestContext, Session, SessionId, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session' import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session' @@ -68,6 +69,9 @@ export class ReactLoopAgent implements Agent { readonly scope: Scope readonly ctx: Context + /** Fused scope carrier, built once in the constructor for every dispatch. */ + readonly carrier: Scoped + /** Whether this loop instance has appended its initial/resume request anchor. */ private requestHeaderLogged = false private readonly runtimeContext: RuntimeContextProjection @@ -78,6 +82,7 @@ export class ReactLoopAgent implements Agent { public readonly options: AgentOptions, public readonly session: Session, ) { + this.carrier = agentCarrier(this) this.inbox = new Inbox(session, { inserted: (message) => { emitAgentEvent(loopCtx, this, 'agent/inbox/inserted', { message }) }, discarded: (message) => { emitAgentEvent(loopCtx, this, 'agent/inbox/discarded', { message }) }, @@ -100,7 +105,7 @@ export class ReactLoopAgent implements Agent { this.phase = next const status = this.status if (status !== previousStatus) { - emitAgentEvent(this.loopCtx, this, 'agent/status', status) + emitAgentEvent(this.loopCtx, this, 'agent/status', { status }) } } @@ -178,7 +183,7 @@ export class ReactLoopAgent implements Agent { private throwError(error: unknown): never { const turn = this.phase.kind === 'running' ? this.phase.turn : this.phase.lastTurn const step = this.phase.kind === 'running' ? this.phase.step : 0 - emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error) + emitAgentEvent(this.loopCtx, this, 'agent/error', { turn, step, error }) throw error } @@ -203,9 +208,9 @@ export class ReactLoopAgent implements Agent { const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal)) signal.throwIfAborted() const context = this.runtimeContext.project(renderContextSnapshot(assembly)) - const decision = await agentEvents(this.loopCtx, this).waterfall( - 'agent/pre-step', claimed, { ...position, signal }, - () => Promise.resolve({ + const decision = await this.loopCtx.waterfall( + this.carrier, 'agent/pre-step', { agent: this, messages: claimed, ...position, signal }, + (): Promise => Promise.resolve({ kind: 'enter', messages: context === undefined ? claimed : [...claimed, context], }), @@ -265,7 +270,7 @@ export class ReactLoopAgent implements Agent { } signal.throwIfAborted() if (turnEnds && this.inbox.nextStep.length === 0) { - await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, signal) + await this.loopCtx.serial(this.carrier, 'agent/turn-stopping', { agent: this, turn, signal }) signal.throwIfAborted() } if (turnEnds && this.inbox.nextStep.length === 0) break @@ -323,13 +328,15 @@ export class ReactLoopAgent implements Agent { const finish = assembler.finish if (finish.kind === 'error' || finish.kind === 'aborted') { const action = await this.loopCtx.waterfall( - agentCarrier(this), 'agent/request-error', this, { + this.carrier, 'agent/request-error', { + agent: this, turn, step, provider: request.provider, failure: finish.failure, retryPolicy: preparedCall?.retryPolicy, - }, signal, + signal, + }, () => Promise.resolve(undefined), ) signal.throwIfAborted() @@ -405,7 +412,7 @@ export class ReactLoopAgent implements Agent { }, )) const proposedConfig = await this.loopCtx.waterfall( - agentCarrier(this), 'agent/request', this, turn, step, signal, + this.carrier, 'agent/request', { agent: this, turn, step, signal }, () => Promise.resolve(seedConfig), ) signal.throwIfAborted() diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 3f77973d92..a589f3c131 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -175,11 +175,11 @@ declare module 'cordis' { * Consumers that buffer work for the configured identity use this * transient signal to reject that work instead of waiting forever. Normal * factory teardown suppresses failures from the cancelled startup attempt. - * @param sessionId - exact shared agent/session identity that failed startup. - * @param error - persistence, setup, or publication failure. + * @param payload.sessionId - exact shared agent/session identity that failed startup. + * @param payload.error - persistence, setup, or publication failure. * @mode emit */ - 'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void + 'agent-loop/config-start-failed'(payload: { sessionId: SessionId; error: unknown }): void } } @@ -351,7 +351,7 @@ export class AgentLoop extends Service implements AgentFactory { ): void { if (!this.ownership.isActive()) return this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${errorChain(error)}`) - const args: unknown[] = ['agent-loop/config-start-failed', sessionId, error] + const args: unknown[] = ['agent-loop/config-start-failed', { sessionId, error }] for (const callback of this.ctx.events.dispatch('emit', args)) { try { const returned: unknown = callback(...args) @@ -400,7 +400,7 @@ export class AgentLoop extends Service implements AgentFactory { released.resolve() } } - const disposeAgentListener = ownerCtx.on('agent/disposed', checkReleased) + const disposeAgentListener = ownerCtx.on('agent/disposed', () => { checkReleased() }) const disposeSessionListener = ownerCtx.on('session/disposed', checkReleased) try { checkReleased() @@ -525,7 +525,7 @@ export class AgentLoop extends Service implements AgentFactory { // A synchronous announce/session-start listener may have started // teardown; the machine is already live (delivery works from the // session-start seam), so only the liveness recheck is owed. - emitAgentEvent(loopCtx, agent, 'agent/session-start', source) + emitAgentEvent(loopCtx, agent, 'agent/session-start', { source }) assertLive() return { agent, dispose } }, diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index bbc3e548e2..5af6e70fe0 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -31,7 +31,7 @@ async function harness(adapter: LlmAdapter): Promise { function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() @@ -164,18 +164,18 @@ describe('AgentLoop initiator scope', () => { if (context.agent === agent) capture(context.signal) return next() }) - ctx.on('agent/pre-step', async (subject, _message, { signal }, next) => { + ctx.on('agent/pre-step', async ({ agent: subject, signal }, next) => { if (subject === agent) { expect(ctx.agents.requireInitiator()).toBe(agent) preStepSignals.push(signal) } return next() }) - ctx.on('agent/request', async (subject, _turn, _step, signal, next) => { + ctx.on('agent/request', async ({ agent: subject, signal }, next) => { if (subject === agent) capture(signal) return next() }) - ctx.on('agent/turn-stopping', (subject, _turn, signal) => { + ctx.on('agent/turn-stopping', ({ agent: subject, signal }) => { if (subject === agent) capture(signal) }) ctx.tools.register(defineContentToolFixture({ diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 1692f19291..7ac8a0dd64 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -60,17 +60,17 @@ describe('Agent', () => { ctx.on('session/event', (session, event) => { if (session === agent.session && event.type === 'turn/start') lifecycle.push('turn/start') }) - ctx.on('agent/inbox/inserted', (subject, event) => { - if (subject === agent) inserted.push(event) + ctx.on('agent/inbox/inserted', ({ agent: subject, message }) => { + if (subject === agent) inserted.push({ message }) }) - ctx.on('agent/inbox/claimed', (subject, event) => { + ctx.on('agent/inbox/claimed', ({ agent: subject, message, turn }) => { if (subject === agent) { lifecycle.push('agent/inbox/claimed') - claimed.push(event) + claimed.push({ message, turn }) } }) - ctx.on('agent/inbox/discarded', (subject, event) => { - if (subject === agent) discarded.push(event) + ctx.on('agent/inbox/discarded', ({ agent: subject, message }) => { + if (subject === agent) discarded.push({ message }) }) const context = createUserMessage({ content: [{ type: 'text', text: 'discard me' }], @@ -114,7 +114,7 @@ describe('Agent', () => { const ctx = await harness(new MockAdapter([textResponse('ok')])) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const statuses: string[] = [] - ctx.on('agent/status', (subject, status) => { + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent) statuses.push(status) }) @@ -152,7 +152,7 @@ describe('Agent', () => { const ctx = await harness(new MockAdapter([textResponse('ok')])) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/status', (_subject, status) => { + ctx.on('agent/status', ({ status }) => { throw new Error(`bad ${status} listener`) }) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 87f2d0991d..5c0deed621 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -40,7 +40,7 @@ function send(agent: Agent, text: string) { /** Resolve on the agent's next idle transition (event-based, not status poll). */ function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose(); resolve() } }) }) @@ -156,7 +156,7 @@ describe('Agent.cancel()', () => { const running = Promise.withResolvers() let disposalDone: Promise | undefined - ctx.on('agent/status', (subject, status) => { + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject !== agent || status !== 'running') return disposalDone = handle.dispose() running.resolve(undefined) @@ -200,7 +200,7 @@ describe('Agent.cancel()', () => { const replacementRegistered = Promise.withResolvers() let replacementObservation: Promise<{ status: string; requests: number; turns: number }> | undefined - ctx.on('agent/status', (subject, status) => { + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject !== agent || status !== 'idle' || replacementObservation !== undefined) return send(agent, 'cancelled replacement') replacementObservation = agent.whenIdle().then(() => ({ @@ -239,7 +239,7 @@ describe('Agent.cancel()', () => { const replacementRegistered = Promise.withResolvers() let replacementIdle: Promise | undefined - ctx.on('agent/status', (subject, status) => { + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject !== agent || status !== 'idle' || replacementIdle !== undefined) return send(agent, 'cancelled replacement') agent.cancel({ kind: 'user' }) @@ -440,7 +440,7 @@ describe('Agent.cancel()', () => { }) let cancelled = false - ctx.on('agent/turn-stopping', (subject) => { + ctx.on('agent/turn-stopping', ({ agent: subject }) => { if (subject === agent && !cancelled) { cancelled = true agent.cancel({ kind: 'user' }) @@ -465,7 +465,7 @@ describe('Agent.cancel()', () => { // durable turn-start commit and must drop the reserved work. let streamed = false ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true }) - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'running') agent.cancel({ kind: 'user' }) }) @@ -485,7 +485,7 @@ describe('Agent.cancel()', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let replaced = false - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject !== agent || status !== 'running' || replaced) return replaced = true agent.cancel({ kind: 'user' }) @@ -664,7 +664,7 @@ describe('Agent.cancel()', () => { switch (stage) { case 'pre-step': - ctx.on('agent/pre-step', async (subject, _message, { signal }, next) => { + ctx.on('agent/pre-step', async ({ agent: subject, signal }, next) => { if (subject === agent) await blockUntilAbort(signal) return next() }) @@ -679,13 +679,13 @@ describe('Agent.cancel()', () => { }) break case 'request': - ctx.on('agent/request', async (subject, _turn, _step, signal, next) => { + ctx.on('agent/request', async ({ agent: subject, signal }, next) => { if (subject === agent) await blockUntilAbort(signal) return next() }) break case 'stopping': - ctx.on('agent/turn-stopping', async (subject, _turn, signal) => { + ctx.on('agent/turn-stopping', async ({ agent: subject, signal }) => { if (subject === agent) await blockUntilAbort(signal) }) break diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index c0be39e41b..74608e5f1a 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -19,7 +19,7 @@ afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose(); resolve() } }) }) @@ -170,7 +170,7 @@ describe('config-driven session id', () => { await cleanupStarted.promise expect(first.status).toBe('idle') const failures: unknown[] = [] - ctx.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) }) + ctx.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) }) const secondLoop = await ctx.plugin(AgentLoop, config) await new Promise(resolve => setTimeout(resolve, 0)) expect(ctx.agents.get(sessionId)).toBe(first) @@ -234,7 +234,7 @@ describe('config-driven session id', () => { const failures: { sessionId: SessionId; error: unknown }[] = [] ctx.on('agent-loop/config-start-failed', () => { throw listenerFailure }) ctx.on('agent-loop/config-start-failed', () => Promise.reject(asyncListenerFailure) as never) - ctx.on('agent-loop/config-start-failed', (sessionId, error) => { + ctx.on('agent-loop/config-start-failed', ({ sessionId, error }) => { failures.push({ sessionId, error }) }) vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(failure) @@ -274,7 +274,7 @@ describe('config-driven session id', () => { // Deliberately violate the normal Error-only rejection rule to exercise the unknown boundary. // oxlint-disable-next-line typescript/prefer-promise-reject-errors ctx.on('agent-loop/config-start-failed', () => Promise.reject(unrenderable) as never) - ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) }) + ctx.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) }) vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(unrenderable) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) @@ -307,7 +307,7 @@ describe('config-driven session id', () => { const released = vi.fn() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const failures: unknown[] = [] - ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) }) + ctx.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) }) const loop = await ctx.plugin(AgentLoop, { agents: [{ id: 'main', sessionId: SessionId('config-exact-dispose'), model: 'mock' }], @@ -479,7 +479,7 @@ describe('startup reporting after factory teardown', () => { gate.promise.catch(() => undefined) vi.spyOn(ctx.sessionPersistence, 'list').mockReturnValue(gate.promise) const failures: unknown[] = [] - ctx.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) }) + ctx.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) }) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const loop = await ctx.plugin(AgentLoop, { diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index ad3a3ef507..b6540d9912 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -40,7 +40,7 @@ async function harness(adapter: MockAdapter) { function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() @@ -191,7 +191,7 @@ describe('abort during tool execution ends the turn', () => { const adapter = new MockAdapter([textResponse('must not run')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a-empty-batch'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/pre-step', (subject, _messages, _context, next) => { + ctx.on('agent/pre-step', ({ agent: subject }, next) => { if (subject !== agent) return next() return Promise.resolve({ kind: 'enter', messages: [] }) }) @@ -288,7 +288,7 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'leave an unmatched historical call') await waitForIdle(ctx, agent) - const disposeInjection = ctx.on('agent/pre-step', async (subject, _messages, { turn }, next) => { + const disposeInjection = ctx.on('agent/pre-step', async ({ agent: subject, turn }, next) => { const decision = await next() if (subject === agent && turn === 2 && decision.kind === 'enter') { disposeInjection() @@ -382,7 +382,7 @@ describe('disposal leaves the two-state status contract balanced', () => { const statuses: string[] = [] const reasons: TurnEndReason[] = [] - ctx.on('agent/status', (_agent, status) => void statuses.push(status)) + ctx.on('agent/status', ({ status }) => void statuses.push(status)) ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') @@ -411,7 +411,7 @@ describe('disposal leaves the two-state status contract balanced', () => { agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) - ctx.on('agent/status', (_agent, status) => { + ctx.on('agent/status', ({ status }) => { if (status === 'idle') throw new Error('broken status listener') }) @@ -457,7 +457,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model — router plugin decides - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { + ctx.on('agent/request', async (_payload, next) => { return { ...await next(), provider: 'mock', model: 'mock' } }) @@ -540,7 +540,7 @@ describe('turn numbering continues across seeded sessions', () => { ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) }) forked.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } })) await new Promise((resolve) => { - ctx2.on('agent/status', (subject, status) => { + ctx2.on('agent/status', ({ agent: subject, status }) => { if (subject === forked && status === 'idle') resolve() }) }) @@ -586,7 +586,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () const reasons: TurnEndReason[] = [] const errors: unknown[] = [] - ctx.on('agent/error', (_agent, turn, step, error) => { + ctx.on('agent/error', ({ turn, step, error }) => { expect({ turn, step }).toEqual({ turn: 1, step: 1 }) errors.push(error) }) @@ -710,7 +710,7 @@ describe('turn and step boundary recovery', () => { if (event.type === 'step/start' && !threw) { threw = true; throw new Error('boom step-start') } }) const errors: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) @@ -743,7 +743,7 @@ describe('turn and step boundary recovery', () => { } }) const errors: Error[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) @@ -800,7 +800,7 @@ describe('turn and step boundary recovery', () => { } }) const errors: Error[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) @@ -893,14 +893,14 @@ describe('turn and step boundary recovery', () => { }, { inject: ['agentLoop'] })) let threw = false - ctx.on('agent/pre-step', (_subject, _messages, _context, next) => { + ctx.on('agent/pre-step', (_payload, next) => { if (threw) return next() threw = true void fiber.dispose() throw new Error('boom pre-step during disposal') }) const errorEmits: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errorEmits.push(error) }) @@ -926,7 +926,7 @@ describe('turn and step boundary recovery', () => { if (!threw && event.type === 'turn/start') { threw = true; throw new Error('boom turn/start append') } }) const errors: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) @@ -959,7 +959,7 @@ describe('turn and step boundary recovery', () => { if (event.type === 'step/end' && !threw) { threw = true; throw new Error('boom step-end') } }) const errors: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) @@ -1000,7 +1000,7 @@ describe('turn and step boundary recovery', () => { if (!threw && event.type === 'step/end') { threw = true; throw new Error('boom step/end listener') } }) const errors: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) @@ -1215,7 +1215,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await mountInvariants(ctx) ctx.llm.registerAdapter(['mock'], adapter) - ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => { + ctx.on('agent/pre-step', async (_payload, next) => { await blocker return next() }) @@ -1261,7 +1261,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await mountInvariants(ctx) ctx.llm.registerAdapter(['mock'], adapter) - ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => { + ctx.on('agent/pre-step', async (_payload, next) => { await blocker return next() }) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 273ef022fd..617c305071 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -28,7 +28,7 @@ async function harness(adapter: MockAdapter) { function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() @@ -120,7 +120,7 @@ describe('thrown-value propagation', () => { }) const errors: unknown[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) + ctx.on('agent/error', ({ error }) => void errors.push(error)) send(agent, 'fails before turn start') send(agent, 'survives as the next item') @@ -143,7 +143,7 @@ describe('thrown-value propagation', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threwOnce = false - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { + ctx.on('agent/request', async (_payload, next) => { if (!threwOnce) { threwOnce = true throw { code: 500 } @@ -167,7 +167,7 @@ describe('durable error rendering', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threwOnce = false - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { + ctx.on('agent/request', async (_payload, next) => { if (!threwOnce) { threwOnce = true throw new LlmError('server overloaded', 'RATE_LIMIT') @@ -250,7 +250,7 @@ describe('request-error action edges', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('retry-after-cancel'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request-error', async (subject) => { + ctx.on('agent/request-error', async ({ agent: subject }) => { subject.cancel({ kind: 'user' }) return { kind: 'retry' } }) @@ -271,7 +271,7 @@ describe('request-error action edges', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('retry-raced'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request-error', async (subject, _context, signal, next) => { + ctx.on('agent/request-error', async ({ agent: subject, signal }, next) => { await next() subject.cancel({ kind: 'user' }) expect(signal.aborted).toBe(true) @@ -350,7 +350,7 @@ describe('persistent step-close rejection', () => { if (event.type === 'step/end') throw new Error('step close permanently rejected') }) const statuses: string[] = [] - ctx.on('agent/status', (subject, status) => { if (subject === agent) statuses.push(status) }) + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent) statuses.push(status) }) send(agent, 'go') await agent.whenIdle() @@ -406,7 +406,7 @@ describe('turn close failure containment', () => { } }) const errors: unknown[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) }) + ctx.on('agent/error', ({ error }) => { errors.push(error) }) send(agent, 'go') await agent.whenIdle() @@ -484,11 +484,11 @@ describe('driver bookkeeping edges', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('reject-next-step'), { provider: 'mock', model: 'mock' }) let proposals = 0 - ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => { + ctx.on('agent/pre-step', async (_payload, next) => { proposals += 1 return proposals === 2 ? { kind: 'reject' } : next() }) - ctx.on('agent/turn-stopping', (subject) => { + ctx.on('agent/turn-stopping', ({ agent: subject }) => { subject.inject(createUserMessage({ content: [{ type: 'text', text: 'do not enter the next step' }], source: { kind: 'plugin', plugin: 'test' }, diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 86a3d664c5..a26f463b0d 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -41,7 +41,7 @@ async function harness(adapter: MockAdapter) { function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() @@ -65,7 +65,7 @@ describe('agent/pre-step', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const seen: string[] = [] - ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => { + ctx.on('agent/pre-step', async ({ messages }, next) => { seen.push(messages[0]!.content.map(b => (b.type === 'text' ? b.text : '')).join('')) return next() }) @@ -92,8 +92,8 @@ describe('agent/pre-step', () => { })) const agent = ctx.agentLoop.create(SessionId('prompt-coordinates'), { provider: 'mock', model: 'mock' }) const seen: Array<{ turn: number; step: number; messages: number }> = [] - ctx.on('agent/pre-step', async (_agent, messages, context, next) => { - seen.push({ turn: context.turn, step: context.step, messages: messages.length }) + ctx.on('agent/pre-step', async ({ messages, turn, step }, next) => { + seen.push({ turn, step, messages: messages.length }) return next() }) @@ -113,7 +113,7 @@ describe('agent/pre-step', () => { const entered = Promise.withResolvers() const decision = Promise.withResolvers() const observed: UserMessage[] = [] - ctx.on('agent/pre-step', async (subject, messages) => { + ctx.on('agent/pre-step', async ({ agent: subject, messages }) => { if (subject !== agent) return { kind: 'enter', messages } const message = messages[0]! expect(Object.isFrozen(message)).toBe(true) @@ -161,7 +161,7 @@ describe('agent/pre-step', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/pre-step', async (_agent, messages): Promise => + ctx.on('agent/pre-step', async ({ messages }): Promise => ({ kind: 'enter', messages: [{ ...messages[0]!, content: [{ type: 'text', text: 'REWRITTEN' }] }], @@ -182,7 +182,7 @@ describe('agent/pre-step', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/pre-step', async (_agent, messages): Promise => + ctx.on('agent/pre-step', async ({ messages }): Promise => ({ kind: 'enter', messages: [...messages, createUserMessage({ @@ -211,15 +211,15 @@ describe('agent/pre-step', () => { provider: 'mock', model: 'mock', }) - ctx.on('agent/turn-stopping', (subject) => { + ctx.on('agent/turn-stopping', ({ agent: subject }) => { subject.inject(createUserMessage({ content: [{ type: 'text', text: 'pending context' }], source: { kind: 'plugin', plugin: 'test' }, })) }) - ctx.on('agent/pre-step', async (_subject, _messages, context, next) => { + ctx.on('agent/pre-step', async ({ step }, next) => { const decision = await next() - return context.step === 1 || decision.kind === 'reject' + return step === 1 || decision.kind === 'reject' ? decision : { kind: 'enter', messages: [] } }) @@ -262,7 +262,7 @@ describe('agent/pre-step', () => { const decision = Promise.withResolvers() let claimed: UserMessage[] = [] let firstProposal = true - ctx.on('agent/pre-step', async (_agent, messages) => { + ctx.on('agent/pre-step', async ({ messages }) => { if (!firstProposal) return { kind: 'enter', messages } firstProposal = false claimed = messages @@ -372,14 +372,14 @@ describe('agent/pre-step', () => { provider: 'mock', model: 'mock', }) - ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => { + ctx.on('agent/pre-step', async ({ messages }, next) => { const decision = await next() return messages.some(message => message.content.some(block => block.type === 'text' && block.text === 'blocked prompt')) ? { kind: 'reject' as const } : decision }) - ctx.on('agent/pre-step', async (subject, messages, _signal, next) => { + ctx.on('agent/pre-step', async ({ agent: subject, messages }, next) => { if (messages.some(message => message.content.some(block => block.type === 'text' && block.text === 'blocked prompt'))) { subject.inject(createUserMessage({ @@ -482,7 +482,7 @@ describe('agent/pre-step', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/pre-step', async (_agent, messages, _signal, next): Promise => { + ctx.on('agent/pre-step', async ({ messages }, next): Promise => { const text = messages.flatMap(message => message.content) .map(b => (b.type === 'text' ? b.text : '')).join('') return text === 'secret' @@ -519,17 +519,17 @@ describe('agent/pre-step', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threw = false - ctx.on('agent/pre-step', async (_agent, messages) => { + ctx.on('agent/pre-step', async ({ messages }) => { if (!threw) { threw = true; throw new Error('prompt hook broke') } return { kind: 'enter' as const, messages } }) const errors: Error[] = [] const reasons: TurnEndReason[] = [] const statuses: string[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) - ctx.on('agent/status', (subject, status) => { if (subject === agent) statuses.push(status) }) + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent) statuses.push(status) }) ctx.on('session/event', (session, event) => { if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -559,7 +559,7 @@ describe('agent/session-start', () => { const ctx = await harness(adapter) const sources: SessionStartSource[] = [] - ctx.on('agent/session-start', (_agent, source) => void sources.push(source)) + ctx.on('agent/session-start', ({ source }) => void sources.push(source)) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // fires synchronously at create, before any turn @@ -576,7 +576,7 @@ describe('agent/session-start', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - ctx.on('agent/session-start', (agent) => { + ctx.on('agent/session-start', ({ agent }) => { agent.inject(createUserMessage({ content: [{ type: 'text', text: 'session preamble' }], source: { kind: 'plugin', plugin: 'test' } })) }) @@ -724,11 +724,11 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se name: 'native-guard', apply(ctx: Context) { // 1. SessionStart: seed a standing instruction. - ctx.on('agent/session-start', (agent, source) => { + ctx.on('agent/session-start', ({ agent, source }) => { agent.inject(createUserMessage({ content: [{ type: 'text', text: `policy active (started: ${source})` }], source: { kind: 'plugin', plugin: 'native-guard' } })) }) // 2. PreStep: reject a forbidden prompt, annotate the rest. - ctx.on('agent/pre-step', async (_agent, messages, _signal, next): Promise => { + ctx.on('agent/pre-step', async ({ messages }, next): Promise => { const text = messages.flatMap(message => message.content) .map(b => (b.type === 'text' ? b.text : '')).join('') if (text.includes('rm -rf')) { diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 94e39a08a2..c8d048ca47 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -28,7 +28,7 @@ async function harness(adapter: MockAdapter, persona = '') { /** Wait for the agent's next transition to idle after a waking send. */ function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() @@ -216,7 +216,7 @@ describe('agent loop', () => { const adapter = new MockAdapter([textResponse('ok after rescue')]) const ctx = await harness(adapter, 'In {{cwd}}.') const errors: Error[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -263,7 +263,7 @@ describe('agent loop', () => { assembly.variables['model'] = 'mock' return next() }) - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { + ctx.on('agent/request', async (_payload, next) => { const config = await next() return { ...config, provider: 'mock', model: 'mock' } }) @@ -553,7 +553,7 @@ describe('agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('failed-steering'), { provider: 'mock', model: 'mock' }) let fail = true - ctx.on('agent/pre-step', (subject, _messages, _context, next) => { + ctx.on('agent/pre-step', ({ agent: subject }, next) => { if (subject !== agent || !fail) return next() fail = false subject.steer(createUserMessage({ content: [{ type: 'text', text: 'pending steering' }], source: { kind: 'user' } })) @@ -713,7 +713,7 @@ describe('agent loop', () => { let steps = 0 ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ }) - ctx.on('agent/turn-stopping', (subject) => { + ctx.on('agent/turn-stopping', ({ agent: subject }) => { if (steps < 3) { subject.steer(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'plugin', plugin: 'loop-test' } })) } @@ -785,7 +785,7 @@ describe('agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { + ctx.on('agent/request', async (_payload, next) => { const config = await next() // The seed is frozen — config is not a mutable per-call knob; a switch // is proposed by returning a replacement, and the loop logs it. @@ -816,7 +816,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const fires: { turn: number; step: number; signal: AbortSignal }[] = [] - ctx.on('agent/pre-step', (subject, _messages, { turn, step, signal }, next) => { + ctx.on('agent/pre-step', ({ agent: subject, turn, step, signal }, next) => { if (subject === agent) fires.push({ turn, step, signal }) return next() }) @@ -837,7 +837,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let boundaryOpen = true - ctx.on('agent/pre-step', (subject, _messages, _context, next) => { + ctx.on('agent/pre-step', ({ agent: subject }, next) => { if (subject === agent) boundaryOpen = subject.session.events.at(-1)?.type === 'step/start' return next() }) @@ -855,13 +855,13 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let throwOnce = true - ctx.on('agent/pre-step', (_agent, _messages, _context, next) => { + ctx.on('agent/pre-step', (_payload, next) => { if (throwOnce) { throwOnce = false; throw new Error('boom in pre-step') } return next() }) const errors: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) @@ -933,7 +933,7 @@ describe('agent loop', () => { ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ }) // Force exactly one continuation (step 1 → step 2), then defer to default // (step 2 is a plain stop with no tool calls → stops). - ctx.on('agent/turn-stopping', (subject) => { + ctx.on('agent/turn-stopping', ({ agent: subject }) => { if (steps < 2) { subject.steer(createUserMessage({ content: [{ type: 'text', text: 'continue after truncation' }], source: { kind: 'plugin', plugin: 'max-tokens-test' } })) } @@ -1296,7 +1296,7 @@ describe('agent loop', () => { const errors: unknown[] = [] const reasons: TurnEndReason[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => { + ctx.on('agent/error', ({ error }) => { errors.push(error) }) ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index c5dec8c142..0add31bd1f 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -50,7 +50,7 @@ async function harness() { /** Resolve on the agent's next transition to idle (event-based, not polled). */ function nextIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() @@ -63,7 +63,7 @@ function nextIdle(ctx: Context, agent: Agent): Promise { * the seen list plus a disposer for the listener (per the registry convention). */ function recordStatus(ctx: Context, agent: Agent): { seen: string[]; dispose: () => void } { const seen: string[] = [] - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent) seen.push(status) }) return { seen, dispose } diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index 287badfc15..0c7c65e483 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -59,7 +59,7 @@ async function loopHarness(): Promise { function waitForIdle(context: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = context.on('agent/status', (subject, status) => { + const dispose = context.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() diff --git a/packages/core/agent-loop/tests/request-error.spec.ts b/packages/core/agent-loop/tests/request-error.spec.ts index 96b6bfc045..d143bd79ae 100644 --- a/packages/core/agent-loop/tests/request-error.spec.ts +++ b/packages/core/agent-loop/tests/request-error.spec.ts @@ -62,12 +62,12 @@ describe('agent/request-error', () => { retryPolicy: ResolvedRetryPolicy | undefined }[] = [] const statuses: string[] = [] - ctx.on('agent/status', (subject, status) => { + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent) statuses.push(status) }) - ctx.on('agent/request-error', async (subject, context) => { + ctx.on('agent/request-error', async ({ agent: subject, turn, step, failure, retryPolicy }) => { expect(subject).toBe(agent) - seen.push(context) + seen.push({ turn, step, failure, retryPolicy }) return { kind: 'retry' } }) @@ -102,7 +102,7 @@ describe('agent/request-error', () => { const adapter = new MockAdapter([fail('busy', 'RATE_LIMIT'), textResponse('unused')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('request-error-cancel'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request-error', async (subject) => { + ctx.on('agent/request-error', async ({ agent: subject }) => { subject.cancel({ kind: 'user' }) return { kind: 'retry' } }) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 565bb73f63..62a7ae5e71 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -38,7 +38,7 @@ async function harnessRoutes( function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() @@ -122,7 +122,7 @@ describe('request stability across the loop', () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')], reasoning) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('effort'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request', async (_agent, turn, _step, _signal, next) => { + ctx.on('agent/request', async ({ turn }, next) => { const config = await next() return turn === 2 ? { ...config, reasoningEffort: ReasoningEffortId('max') } : config }) @@ -198,7 +198,7 @@ describe('request stability across the loop', () => { provider: 'deepseek', model: 'deepseek-model', }) - ctx.on('agent/request', async (_agent, turn, _step, _signal, next) => { + ctx.on('agent/request', async ({ turn }, next) => { const config = await next() return turn === 2 ? { ...config, provider: 'other', model: 'other-model' } @@ -232,7 +232,7 @@ describe('request stability across the loop', () => { model: 'deepseek-model', maxTokens: 4_096, }) - ctx.on('agent/request', async (_agent, turn, _step, _signal, next) => { + ctx.on('agent/request', async ({ turn }, next) => { const config = await next() return turn === 2 ? { ...config, provider: 'other', model: 'other-model' } @@ -460,7 +460,7 @@ describe('request stability across the loop', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let injected = false - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { + ctx.on('agent/request', async (_payload, next) => { if (!injected) { injected = true agent.inject(createUserMessage({ content: [{ type: 'text', text: '[late context]' }], source: { kind: 'plugin', plugin: 'test' } })) @@ -539,7 +539,7 @@ describe('request stability across the loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { + ctx.on('agent/request', async (_payload, next) => { const config = await next() // next() resolves the SAME frozen seed — in-place shaping after // delegation is unrepresentable, so a "mutate what next() returned" @@ -576,7 +576,7 @@ describe('request stability across the loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'now with guidance' }) - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({ + ctx.on('agent/request', async (_payload, next) => ({ ...await next(), temperature: 0.5, maxTokens: 99, stop: [''], })) send(agent, 'again') @@ -658,7 +658,7 @@ describe('request/context capacity records', () => { send(agent, 'first') await waitForIdle(ctx, agent) - ctx.on('agent/request', (subject, _turn, _step, _signal, next) => subject === agent + ctx.on('agent/request', ({ agent: subject }, next) => subject === agent ? Promise.resolve({ provider: 'mock', model: 'large' }) : next()) send(agent, 'second') @@ -686,7 +686,7 @@ describe('request/context capacity records', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('capacity-clear'), { provider: 'mock', model: 'known' }) let model = 'known' - ctx.on('agent/request', (subject, _turn, _step, _signal, next) => subject === agent + ctx.on('agent/request', ({ agent: subject }, next) => subject === agent ? Promise.resolve({ provider: 'mock', model }) : next()) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 62f9e9059e..964ef82aa6 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -66,7 +66,7 @@ function preparationFromSnapshot( function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose(); resolve() } }) }) @@ -260,7 +260,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const adapter1 = new MockAdapter([textResponse('a')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) const sources1: string[] = [] - ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source)) + ctx1.on('agent/session-start', ({ source }) => void sources1.push(source)) const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent expect(sources1).toEqual(['startup']) a1.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })) @@ -279,7 +279,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) const sources2: string[] = [] - ctx2.on('agent/session-start', (_agent, source) => void sources2.push(source)) + ctx2.on('agent/session-start', ({ source }) => void sources2.push(source)) await ctx2.agents.resume({ resumeSessionId: SessionId('start-sess') }) expect(sources2).toEqual(['resume']) await ctx2.fiber.dispose() @@ -298,11 +298,11 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', expect(ctx.agents.get(sessionId)?.session).toBe(session) order.push('session/created') }) - ctx.on('agent/created', (agent) => { + ctx.on('agent/created', ({ agent }) => { expect(agent.status).toBe('idle') order.push('agent/created') }) - ctx.on('agent/session-start', (agent) => { + ctx.on('agent/session-start', ({ agent }) => { expect(() => { agent.cancel({ kind: 'user' }) }).not.toThrow() order.push('agent/session-start') }) @@ -882,7 +882,7 @@ describe('configured-start failure edges', () => { configured.llm.registerAdapter(['mock'], new MockAdapter([])) configured.sessionPersistence.prepare = (id, signal) => ctx.sessionPersistence.prepare(id, signal) const configFailures: unknown[] = [] - configured.on('agent-loop/config-start-failed', (_id, error) => { configFailures.push(error) }) + configured.on('agent-loop/config-start-failed', ({ error }) => { configFailures.push(error) }) const configWarnings: string[] = [] const configWarn = configured.logger.warn.bind(configured.logger) configured.logger.warn = ((...args: unknown[]) => { @@ -915,7 +915,7 @@ describe('configured-start failure edges', () => { return gate.promise } const failures: unknown[] = [] - ctx.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) }) + ctx.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) }) const configured = new Context() await configured.plugin(LlmService) @@ -926,7 +926,7 @@ describe('configured-start failure edges', () => { await configured.plugin(SessionPersistenceJsonl, { root }) configured.llm.registerAdapter(['mock'], new MockAdapter([])) configured.sessionPersistence.prepare = (id, signal) => ctx.sessionPersistence.prepare(id, signal) - configured.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) }) + configured.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) }) const loop = await configured.plugin(AgentLoop, { agents: [{ id: 'main', resumeSessionId: sessionId, provider: 'mock', model: 'mock' }], }) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index a618a130cf..3f3e0a43d8 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -31,7 +31,7 @@ async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok' function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() @@ -199,7 +199,7 @@ describe('agent scope lifecycle', () => { const b = ctx.agentLoop.create(SessionId('b'), { provider: 'mock', model: 'mock' }) const heard: string[] = [] - a.ctx.on('agent/status', (subject, status) => void heard.push(`a-sees:${subject.id}:${status}`)) + a.ctx.on('agent/status', ({ agent: subject, status }) => void heard.push(`a-sees:${subject.id}:${status}`)) a.ctx.on('session/event', (_s, event) => { if (event.type === 'user/message') heard.push('a-sees:user-message') }) @@ -217,7 +217,7 @@ describe('agent scope lifecycle', () => { it('runs setup in the guaranteed slot: scoped world complete before session-start and the first assembly', async () => { const ctx = await harness() const order: string[] = [] - ctx.on('agent/session-start', (agent) => { + ctx.on('agent/session-start', ({ agent }) => { order.push('session-start') // The scoped section is already registered by the time session-start fires. void ctx.systemPrompt.assemble(assembleContextFor(agent)).then((assembly) => { @@ -673,19 +673,19 @@ describe('agent scope lifecycle', () => { ctx.on('session/created', (session) => { if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-created') }) - ctx.on('agent/created', (agent) => { + ctx.on('agent/created', ({ agent }) => { if (agent.id !== SessionId('agent-created-barrier-s')) return lifecycle.push('agent-created:dispose') disposeCurrentLifecycle(ownerCtx) }) - ctx.on('agent/created', (agent) => { + ctx.on('agent/created', ({ agent }) => { if (agent.id !== SessionId('agent-created-barrier-s')) return expect(ctx.agents.get(agent.id)).toBe(agent) expect(ctx.sessions.get(agent.session.id)).toBe(agent.session) agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') }) lifecycle.push('agent-created:observer') }) - ctx.on('agent/disposed', (agent) => { + ctx.on('agent/disposed', ({ agent }) => { if (agent.id === SessionId('agent-created-barrier-s')) lifecycle.push('agent-disposed') }) ctx.on('session/disposed', (session) => { @@ -720,8 +720,8 @@ describe('agent scope lifecycle', () => { const starts: string[] = [] let ownerCtx!: Context let creating!: ReturnType - ctx.on('agent/session-start', agent => void starts.push(agent.id)) - ctx.on('agent/created', (agent) => { + ctx.on('agent/session-start', ({ agent }) => void starts.push(agent.id)) + ctx.on('agent/created', ({ agent }) => { if (agent.id === SessionId('listener-dispose-s')) disposeCurrentLifecycle(ownerCtx) }) @@ -749,15 +749,15 @@ describe('agent scope lifecycle', () => { const statuses: string[] = [] let scopeDisposed = false let observerSawLive = false - ctx.on('agent/status', (agent, status) => { + ctx.on('agent/status', ({ agent, status }) => { if (agent.id === SessionId('session-start-dispose-s')) statuses.push(status) }) - ctx.on('agent/session-start', (agent) => { + ctx.on('agent/session-start', ({ agent }) => { if (agent.id !== SessionId('session-start-dispose-s')) return announced = agent disposeCurrentLifecycle(ownerCtx) }) - ctx.on('agent/session-start', (agent) => { + ctx.on('agent/session-start', ({ agent }) => { if (agent.id !== SessionId('session-start-dispose-s')) return expect(ctx.agents.get(agent.id)).toBe(agent) expect(ctx.sessions.get(agent.session.id)).toBe(agent.session) @@ -840,7 +840,7 @@ describe('agent scope lifecycle', () => { const ctx = await harness() let boom = true const disposed: string[] = [] - ctx.on('agent/disposed', agent => void disposed.push(agent.id)) + ctx.on('agent/disposed', ({ agent }) => void disposed.push(agent.id)) ctx.on('session/created', () => { if (boom) { boom = false; throw new Error('boom created') } }) @@ -861,11 +861,11 @@ describe('agent scope lifecycle', () => { const lifecycle: string[] = [] ctx.on('session/created', (session) => { lifecycle.push(`session-created:${session.id}`) }) ctx.on('session/disposed', (session) => { lifecycle.push(`session-disposed:${session.id}`) }) - ctx.on('agent/created', (agent) => { + ctx.on('agent/created', ({ agent }) => { lifecycle.push(`agent-created:${agent.id}`) throw new Error('agent observer failed') }) - ctx.on('agent/disposed', (agent) => { lifecycle.push(`agent-disposed:${agent.id}`) }) + ctx.on('agent/disposed', ({ agent }) => { lifecycle.push(`agent-disposed:${agent.id}`) }) await expect(ctx.agents.create({ sessionId: SessionId('partial-session'), @@ -911,10 +911,10 @@ describe('agent scope lifecycle', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const other = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' }) const heard: string[] = [] - agent.ctx.on('agent/error', (subject: Agent, turn: number) => void heard.push(`${subject.id}:${turn}`)) + agent.ctx.on('agent/error', ({ agent: subject, turn }) => void heard.push(`${subject.id}:${turn}`)) - agentEvents(ctx, other).emit('agent/error', 1, 0, new Error('not for a1')) - agentEvents(ctx, agent).emit('agent/error', 2, 0, new Error('for a1')) + agentEvents(ctx, other).emit('agent/error', { turn: 1, step: 0, error: new Error('not for a1') }) + agentEvents(ctx, agent).emit('agent/error', { turn: 2, step: 0, error: new Error('for a1') }) expect(heard).toEqual(['a1:2']) }) @@ -1064,7 +1064,7 @@ describe('agent scope lifecycle', () => { }) const agent = handle.agent let reentered = false - ctx.on('agent/status', (subject, status) => { + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject !== agent || status !== 'idle' || reentered) return reentered = true agent.followup(createUserMessage({ content: [{ type: 'text', text: 'reentrant' }], source: { kind: 'user' } })) diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index f3548cca52..89b2e300bd 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -31,7 +31,7 @@ async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) { function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose(); resolve() } }) }) diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index 8aec38004f..9fa321697a 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -33,7 +33,7 @@ async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['too function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index b28586b6b8..925d46796c 100644 --- a/packages/core/agent/src/dispatch.ts +++ b/packages/core/agent/src/dispatch.ts @@ -17,25 +17,38 @@ type Params = F extends (...args: infer P) => unknown ? P : never type Return = F extends (...args: never[]) => infer R ? R : never /** - * The event names whose subject is an agent: handler parameters start with an - * `Agent` AND the handler declares a `Scoped` `this` (the scope-carrier - * contract). The `this` check keeps accidental first-parameter-happens-to-be- - * an-Agent events (or zero-arg events, whose parameter tuple would satisfy a - * bare rest-tuple check via callability) out of the fused-dispatch surface. + * The event names whose subject is an agent: the handler's first parameter is + * a payload object carrying the `agent` subject AND the handler declares a + * `Scoped` `this` (the scope-carrier contract). The `this` check keeps + * accidental payload-happens-to-carry-an-Agent events (or zero-arg events, + * whose parameter tuple would satisfy a bare rest-tuple check via callability) + * out of the fused-dispatch surface. */ export type AgentSubjectEvent = { [K in keyof Events]: Events[K] extends (this: Scoped, ...args: infer P) => unknown - ? P extends [Agent, ...unknown[]] ? K : never + ? P extends [infer Payload, ...unknown[]] + ? Payload extends { agent: Agent } ? K : never + : never : never }[keyof Events] -/** The event arguments AFTER the injected agent subject. */ -type Tail = Params extends [Agent, ...infer R] ? R : never +/** The full payload object of one agent-subject event. */ +type PayloadOf = Params extends [infer Payload, ...unknown[]] ? Payload : never + +/** The event arguments AFTER the payload: the waterfall `next` when present. */ +type Tail = Params extends [unknown, ...infer R] ? R : never + +/** + * The payload as emit-side callers pass it: the full payload minus the agent + * field, which the fused dispatcher injects so subject and scope key cannot + * diverge. + */ +type PayloadRest = Omit & object, 'agent'> /** * The fused dispatcher {@link agentEvents} returns: each method dispatches the * named agent-subject event with the agent's scope carrier as `thisArg` and - * the agent itself injected as the first event argument. + * the agent itself injected into the payload. */ export interface AgentEventDispatch { /** @@ -44,30 +57,35 @@ export interface AgentEventDispatch { * contained per listener, so a notification cannot veto lifecycle progress * or starve a later observer. * @param name - the agent-subject event to emit. - * @param rest - the event's arguments after the injected agent. + * @param payload - the event's payload fields; `agent` is injected. */ - emit(name: K, ...rest: Tail): void + emit(name: K, payload: PayloadRest): void /** * Awaited in-order dispatch (Cordis `serial`) in the agent's scope. * @param name - the agent-subject event to dispatch. - * @param rest - the event's arguments after the injected agent. + * @param payload - the event's payload fields; `agent` is injected. * @returns the serial chain's result (the first bail value, if any). */ - serial(name: K, ...rest: Tail): Promise>> + serial(name: K, payload: PayloadRest): Promise>> /** * Around-middleware dispatch (Cordis `waterfall`) in the agent's scope. The * declared event parameters already end with the `next` callback, so `rest` - * is exactly the event's arguments after the injected agent — the final - * element being the innermost `next` (the default the listener chain wraps). + * is exactly the event's arguments after the payload — the final element + * being the innermost `next` (the default the listener chain wraps). * @param name - the agent-subject event to dispatch. - * @param rest - the event's arguments after the injected agent. + * @param payload - the event's payload fields; `agent` is injected. + * @param rest - the event's arguments after the payload (the `next` callback). * @returns the waterfall's composed result. */ - waterfall(name: K, ...rest: Tail): Return + waterfall(name: K, payload: PayloadRest, ...rest: Tail): Return } /** - * Return the fused scope carrier for one agent subject. + * Build the fused scope carrier for one agent subject. + * + * The carrier is a stateless routing object; callers that dispatch repeatedly + * for the same agent (the loop driver) build it once in the agent's + * constructor and reuse it, so hot-path dispatches never allocate. * @param agent - the subject agent and scope key. * @returns the carrier passed as the event dispatcher `this` value. */ @@ -84,17 +102,21 @@ export function agentCarrier(agent: Agent): Scoped { export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { const carrier = agentCarrier(agent) // The ordinary dispatch methods forward through Cordis' variadic mixins. The - // fused (carrier, name, agent, ...rest) tuple is provably a valid argument + // fused (carrier, name, payload, ...rest) tuple is provably a valid argument // list for the matching thisArg overload, but TypeScript cannot relate the // generic Tail spread back to that overload's conditional parameter // tuple — hence one contained, shape-preserving cast per method. + const fused = (payload: PayloadRest): PayloadOf => + // The dispatcher owns the subject injection; callers pass PayloadRest, so + // the fused record is exactly the declared payload. + ({ agent, ...payload } as PayloadOf) return { - emit(name, ...rest) { + emit(name, payload) { // Cordis emit invokes callbacks through Array.map: one synchronous throw // starves later listeners, and returned promises are discarded. Agent // notifications are non-vetoing, so resolve the same filtered callback // set ourselves and contain both failure modes independently. - const args: unknown[] = [carrier, name, agent, ...rest] + const args: unknown[] = [carrier, name, fused(payload)] const callbacks = ctx.events.dispatch('emit', args) for (const callback of callbacks) { try { @@ -107,15 +129,15 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { } } }, - async serial(name, ...rest) { + async serial(name, payload) { // oxlint-disable-next-line typescript/unbound-method -- the events mixin accessor returns a pre-bound function const serial = ctx.serial as (thisArg: Scoped, name: string, ...args: unknown[]) => Promise - return await serial(carrier, name, agent, ...rest) + return await serial(carrier, name, fused(payload)) }, - waterfall(name, ...rest) { + waterfall(name, payload, ...rest) { // oxlint-disable-next-line typescript/unbound-method -- the events mixin accessor returns a pre-bound function const waterfall = ctx.waterfall as (thisArg: Scoped, name: string, ...args: unknown[]) => never - return waterfall(carrier, name, agent, ...rest) + return waterfall(carrier, name, fused(payload), ...rest) }, } } @@ -125,15 +147,15 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { * @param ctx - the context to dispatch through. * @param agent - the subject agent and scope key. * @param name - the agent-subject event to emit. - * @param rest - the event arguments after the injected agent. + * @param payload - the event's payload fields; `agent` is injected. */ export function emitAgentEvent( ctx: Context, agent: Agent, name: K, - ...rest: Tail + payload: PayloadRest, ): void { - agentEvents(ctx, agent).emit(name, ...rest) + agentEvents(ctx, agent).emit(name, payload) } /** diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 0a16a2bf53..55cb94d8f9 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -498,7 +498,7 @@ export class AgentRegistry extends Service { /** Emit the paired disposal edge through the entry's stable carrier. */ private emitDisposed(entry: AgentEntry): void { - const args: unknown[] = [entry.carrier, 'agent/disposed', entry.agent] + const args: unknown[] = [entry.carrier, 'agent/disposed', { agent: entry.agent }] for (const callback of this.ctx.events.dispatch('emit', args)) { try { const returned: unknown = callback(...args) @@ -530,7 +530,7 @@ export class AgentRegistry extends Service { // lifecycle edge; detach still pairs a partially delivered first edge. entry.announcing = true entry.announced = true - const args: unknown[] = [entry.carrier, 'agent/created', entry.agent] + const args: unknown[] = [entry.carrier, 'agent/created', { agent: entry.agent }] try { for (const callback of this.ctx.events.dispatch('emit', args)) { // A synchronous creation failure vetoes publication and rolls back. diff --git a/packages/core/agent/src/invariant.ts b/packages/core/agent/src/invariant.ts index f2d9a69539..a561e862cb 100644 --- a/packages/core/agent/src/invariant.ts +++ b/packages/core/agent/src/invariant.ts @@ -14,7 +14,7 @@ export const inject = ['invariants'] /** Install the agent contribution into its child registration fiber. */ const install: InvariantInstaller = (ctx, fail) => { const lastStatus = new WeakMap() - ctx.on('agent/status', (agent, status) => { + ctx.on('agent/status', ({ agent, status }) => { const previous = lastStatus.get(agent) if (previous === status) { fail(`agent/status repeated ${status} (no-op transition)`) diff --git a/packages/core/agent/src/llm-target.ts b/packages/core/agent/src/llm-target.ts index 7b5d1a4df6..e23ea9d750 100644 --- a/packages/core/agent/src/llm-target.ts +++ b/packages/core/agent/src/llm-target.ts @@ -53,7 +53,7 @@ export function installAgentLlmTarget(agentCtx: Context, target: AgentLlmTargetR }) const disposeRequest = agentCtx.on( 'agent/request', - async (_agent, _turn, _step, _signal, next): Promise => { + async (_payload, next): Promise => { const resolved = await next() const selected = target.assembled if (selected === undefined) return resolved diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index e634762494..fae9267347 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -48,35 +48,11 @@ export interface CancelOptions { */ export type AgentStatus = 'idle' | 'running' -/** Coordinates and cancellation for a proposed step. */ -export interface PreStepContext { - /** Turn that will own the step. */ - readonly turn: number - /** Step proposed by the loop. */ - readonly step: number - /** Current turn cancellation signal. */ - readonly signal: AbortSignal -} - /** Whether and with which messages the loop enters a proposed step. */ export type PreStepDecision = | { kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] } -/** One failed model-request attempt presented to recovery listeners. */ -export interface RequestFailureContext { - /** Turn containing the failed request. */ - readonly turn: number - /** Step containing the failed request attempt. */ - readonly step: number - /** Provider selected for the failed request. */ - readonly provider: string - /** Serializable facts normalized at the final adapter boundary. */ - readonly failure: LlmFailure - /** Policy of the adapter registration that served the failed request. */ - readonly retryPolicy: ResolvedRetryPolicy | undefined -} - /** Action returned by a listener that owns model-request recovery. */ export type RequestErrorAction = { kind: 'retry' } | undefined @@ -171,105 +147,112 @@ declare module 'cordis' { * Synchronous listener failure vetoes publication, while returned-promise * rejection is reported. Detach requested during dispatch waits until every * creation listener has observed the stable entry. - * @param agent - the newly registered agent with its live session and completed setup. + * @param payload.agent - the newly registered agent with its live session and completed setup. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/created'(this: Scoped, agent: Agent): void + 'agent/created'(this: Scoped, payload: { agent: Agent }): void /** * An agent left the registry; AgentLoop emits this after driver quiescence * and scoped-registration unwind, but before session detachment. Custom * registry users own their driver-ordering contract. - * @param agent - the exact agent removed from the registry. + * @param payload.agent - the exact agent removed from the registry. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/disposed'(this: Scoped, agent: Agent): void + 'agent/disposed'(this: Scoped, payload: { agent: Agent }): void /** * Agent status changed (`idle` ⇄ `running`). A waking delivery enters * `running` synchronously after reserving cancellation; `idle` means no * driver remains scheduled or active. - * @param agent - the agent whose status flipped. - * @param status - the status just entered (the transition's destination). + * @param payload.agent - the agent whose status flipped. + * @param payload.status - the status just entered (the transition's destination). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void + 'agent/status'(this: Scoped, payload: { agent: Agent; status: AgentStatus }): void /** * One message entered the live inbox. - * @param agent - the agent whose inbox changed. - * @param event - the inserted message. + * @param payload.agent - the agent whose inbox changed. + * @param payload.message - the inserted message. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/inserted'(this: Scoped, agent: Agent, event: { message: UserMessage }): void + 'agent/inbox/inserted'(this: Scoped, payload: { agent: Agent; message: UserMessage }): void /** * One message left the inbox inside its open turn. If the proposed step * is rejected, the claimed message ends here: it is neither discarded nor * re-emitted as a user/message, and the turn closes without a step. - * @param agent - the agent whose inbox changed. - * @param event - the claimed message and owning turn. + * @param payload.agent - the agent whose inbox changed. + * @param payload.message - the claimed message. + * @param payload.turn - the owning turn. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/claimed'(this: Scoped, agent: Agent, event: { message: UserMessage; turn: number }): void + 'agent/inbox/claimed'(this: Scoped, payload: { agent: Agent; message: UserMessage; turn: number }): void /** * One message was discarded from the live inbox. - * @param agent - the agent whose inbox changed. - * @param event - the discarded message. + * @param payload.agent - the agent whose inbox changed. + * @param payload.message - the discarded message. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/discarded'(this: Scoped, agent: Agent, event: { message: UserMessage }): void + 'agent/inbox/discarded'(this: Scoped, payload: { agent: Agent; message: UserMessage }): void // ---- session lifecycle (emit) ---- /** * The session lifecycle began, once before the first turn. Use * `agent.inject()` to seed model-facing context. This is a notification, not * a veto; disposal requested by a lifecycle owner is rechecked before the * driver starts. - * @param agent - the agent whose session lifecycle began. - * @param source - why the session started (fresh startup, resume, …). + * @param payload.agent - the agent whose session lifecycle began. + * @param payload.source - why the session started (fresh startup, resume, …). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/session-start'(this: Scoped, agent: Agent, source: SessionStartSource): void + 'agent/session-start'(this: Scoped, payload: { agent: Agent; source: SessionStartSource }): void // ---- the machine's extension seams ---- /** * Reject a proposed step or replace the messages that enter it. Calling * `next()` preserves the current messages. - * @param agent - the agent proposing the step. - * @param messages - messages removed from the inbox for this step. - * @param context - proposed turn and step coordinates plus cancellation. + * @param payload.agent - the agent proposing the step. + * @param payload.messages - messages removed from the inbox for this step. + * @param payload.turn - the turn that will own the step. + * @param payload.step - the step proposed by the loop. + * @param payload.signal - the current turn's cancellation signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ - 'agent/pre-step'(this: Scoped, agent: Agent, messages: UserMessage[], context: PreStepContext, next: () => Promise): Promise + 'agent/pre-step'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise /** * Replace the frozen call configuration. `await next()` yields the config * the machine would use (agent options on the first request, the logged * header afterwards); return a replacement to switch. Model-visible * content must use logged channels; this seam cannot mutate messages. - * @param agent - the agent making the model call. - * @param turn - the open turn number. - * @param step - the step whose request this is. - * @param signal - the current turn's explicit abort signal. + * @param payload.agent - the agent making the model call. + * @param payload.turn - the open turn number. + * @param payload.step - the step whose request this is. + * @param payload.signal - the current turn's explicit abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ - 'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise): Promise + 'agent/request'(this: Scoped, payload: { agent: Agent; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise /** * Handle one failed model-request attempt before the loop retries or closes * its step. A listener returns `{ kind: 'retry' }` without calling `next()` * when it owns recovery, or calls `next()` to delegate. The default * `undefined` leaves the failure terminal. - * @param agent - the agent whose request failed. - * @param context - request coordinates, provider, normalized failure, and serving policy. - * @param signal - the turn abort signal. + * @param payload.agent - the agent whose request failed. + * @param payload.turn - the turn containing the failed request. + * @param payload.step - the step containing the failed request attempt. + * @param payload.provider - the provider selected for the failed request. + * @param payload.failure - serializable facts normalized at the final adapter boundary. + * @param payload.retryPolicy - the policy of the adapter registration that served the failed request. + * @param payload.signal - the turn abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ - 'agent/request-error'(this: Scoped, agent: Agent, context: RequestFailureContext, signal: AbortSignal, next: () => Promise): Promise + 'agent/request-error'(this: Scoped, payload: { agent: Agent; turn: number; step: number; provider: string; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined; signal: AbortSignal }, next: () => Promise): Promise /** * The turn is about to close: the model owes no response (no live tool * calls, no fresh steering). Awaited before the boundary commits — a @@ -281,25 +264,25 @@ declare module 'cordis' { * never short-circuits already-submitted next-step work: same-step * `additionalContexts` or racing steering still runs, and the turn * closes only when that inbox drains. - * @param agent - the agent whose turn is at its stop boundary. - * @param turn - the turn about to close. - * @param signal - the current turn's explicit abort signal. + * @param payload.agent - the agent whose turn is at its stop boundary. + * @param payload.turn - the turn about to close. + * @param payload.signal - the current turn's explicit abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode serial */ - 'agent/turn-stopping'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | void + 'agent/turn-stopping'(this: Scoped, payload: { agent: Agent; turn: number; signal: AbortSignal }): Promise | void // ---- error notifications (emit) ---- /** * A step or turn errored. The machine reports a failure here even when * the error has no in-turn position for a durable record. - * @param agent - the agent whose turn errored. - * @param turn - the turn in which the failure surfaced. - * @param step - the step at which the failure surfaced. - * @param error - the failure, verbatim. + * @param payload.agent - the agent whose turn errored. + * @param payload.turn - the turn in which the failure surfaced. + * @param payload.step - the step at which the failure surfaced. + * @param payload.error - the failure, verbatim. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/error'(this: Scoped, agent: Agent, turn: number, step: number, error: unknown): void + 'agent/error'(this: Scoped, payload: { agent: Agent; turn: number; step: number; error: unknown }): void } } diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 313850faa4..cf8248a1c7 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -145,8 +145,8 @@ describe('AgentRegistry', () => { const ctx = new Context() await ctx.plugin(AgentRegistry) const lifecycle: string[] = [] - ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`)) - ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`)) + ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`)) + ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`)) const agent = stubAgent('a1') const dispose = ctx.agents.register(agent) @@ -195,9 +195,9 @@ describe('AgentRegistry', () => { const ctx = new Context() await ctx.plugin(AgentRegistry) const lifecycle: string[] = [] - ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`)) + ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`)) ctx.on('agent/created', () => { throw new Error('creation veto') }) - ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`)) + ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`)) expect(() => ctx.agents.register(stubAgent('vetoed'))).toThrow('creation veto') expect(ctx.agents.get(SessionId('vetoed'))).toBeUndefined() @@ -213,7 +213,7 @@ describe('AgentRegistry', () => { ctx.on('agent/created', () => Promise.reject(new Error('created async')) as never) ctx.on('agent/disposed', () => { throw new Error('disposed sync') }) ctx.on('agent/disposed', () => Promise.reject(new Error('disposed async')) as never) - ctx.on('agent/disposed', agent => void heard.push(agent.id)) + ctx.on('agent/disposed', ({ agent }) => void heard.push(agent.id)) const dispose = ctx.agents.register(stubAgent('contained')) await Promise.resolve() @@ -232,8 +232,8 @@ describe('AgentRegistry', () => { const ctx = new Context() await ctx.plugin(AgentRegistry) const lifecycle: string[] = [] - ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`)) - ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`)) + ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`)) + ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`)) const first = stubAgent('split') const detachFirst = ctx.agents.enter(first, undefined) @@ -280,9 +280,9 @@ describe('agentEvents()', () => { const agent = stubAgent('event') ctx.on('agent/status', () => { throw new Error('sync listener') }) ctx.on('agent/status', () => Promise.reject(new Error('async listener')) as never) - ctx.on('agent/status', (_agent, status) => void heard.push(status)) + ctx.on('agent/status', ({ status }) => void heard.push(status)) - agentEvents(ctx, agent).emit('agent/status', 'running') + agentEvents(ctx, agent).emit('agent/status', { status: 'running' }) await Promise.resolve() expect(heard).toEqual(['running']) expect(warnings).toEqual([ @@ -296,12 +296,12 @@ describe('agentEvents()', () => { const agent = stubAgent('serial-event') const signal = new AbortController().signal const heard: Array<{ agent: Agent; turn: number; signal: AbortSignal }> = [] - ctx.on('agent/turn-stopping', async (subject, turn, receivedSignal) => { + ctx.on('agent/turn-stopping', async ({ agent: subject, turn, signal: receivedSignal }) => { await Promise.resolve() heard.push({ agent: subject, turn, signal: receivedSignal }) }) - await agentEvents(ctx, agent).serial('agent/turn-stopping', 3, signal) + await agentEvents(ctx, agent).serial('agent/turn-stopping', { turn: 3, signal }) expect(heard).toEqual([{ agent, turn: 3, signal }]) }) diff --git a/packages/core/agent/tests/invariant.spec.ts b/packages/core/agent/tests/invariant.spec.ts index 158376a3d7..458a10714d 100644 --- a/packages/core/agent/tests/invariant.spec.ts +++ b/packages/core/agent/tests/invariant.spec.ts @@ -21,17 +21,17 @@ describe('agent status invariants', () => { const ctx = await setup() const agent = mockAgent('a1') expect(() => { - ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') - ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') - ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') + ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'idle' }) + ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'running' }) + ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'idle' }) }).not.toThrow() }) it('rejects a no-op transition', async () => { const ctx = await setup() const agent = mockAgent('a3') - ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') - expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') }) + ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'running' }) + expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'running' }) }) .toThrow(/no-op transition/) }) @@ -39,7 +39,7 @@ describe('agent status invariants', () => { const ctx = await setup() const a = mockAgent('a5') const b = mockAgent('b5') - ctx.emit(scopeTarget(a, a), 'agent/status', a, 'running') - expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', b, 'running') }).not.toThrow() + ctx.emit(scopeTarget(a, a), 'agent/status', { agent: a, status: 'running' }) + expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', { agent: b, status: 'running' }) }).not.toThrow() }) }) diff --git a/packages/core/agent/tests/llm-target.spec.ts b/packages/core/agent/tests/llm-target.spec.ts index d3ec2f96bd..991a69ea32 100644 --- a/packages/core/agent/tests/llm-target.spec.ts +++ b/packages/core/agent/tests/llm-target.spec.ts @@ -21,7 +21,7 @@ describe('installAgentLlmTarget()', () => { expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) await expect(agentEvents(ctx, agent).waterfall( - 'agent/request', 1, 0, signal, () => Promise.resolve(seed), + 'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed), )).resolves.toBe(seed) target.current = { @@ -32,7 +32,7 @@ describe('installAgentLlmTarget()', () => { expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' }) target.current = { provider: 'beta', model: 'b1' } await expect(agentEvents(ctx, agent).waterfall( - 'agent/request', 1, 0, signal, () => Promise.resolve(seed), + 'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed), )).resolves.toEqual({ provider: 'alpha', model: 'a1', @@ -48,13 +48,13 @@ describe('installAgentLlmTarget()', () => { temperature: 0.2, } await expect(agentEvents(ctx, agent).waterfall( - 'agent/request', 1, 1, signal, () => Promise.resolve(inherited), + 'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve(inherited), )).resolves.toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 }) dispose() expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) await expect(agentEvents(ctx, agent).waterfall( - 'agent/request', 2, 0, signal, () => Promise.resolve(seed), + 'agent/request', { turn: 2, step: 0, signal }, () => Promise.resolve(seed), )).resolves.toBe(seed) await ctx.fiber.dispose() }) diff --git a/packages/core/scope/src/scoped-events.generated.ts b/packages/core/scope/src/scoped-events.generated.ts index e544c47987..672914c5c3 100644 --- a/packages/core/scope/src/scoped-events.generated.ts +++ b/packages/core/scope/src/scoped-events.generated.ts @@ -8,20 +8,20 @@ type ScopedSubjectResolver = (args: readonly unknown[]) => unknown const scopedSubjectResolvers: Readonly> = Object.freeze({ - 'agent/created': args => args[0], - 'agent/disposed': args => args[0], - 'agent/error': args => args[0], - 'agent/inbox/claimed': args => args[0], - 'agent/inbox/discarded': args => args[0], - 'agent/inbox/inserted': args => args[0], - 'agent/pre-step': args => args[0], - 'agent/request': args => args[0], - 'agent/request-error': args => args[0], - 'agent/session-start': args => args[0], - 'agent/status': args => args[0], - 'agent/turn-stopping': args => args[0], + 'agent/created': args => (args[0] as Record)['agent'], + 'agent/disposed': args => (args[0] as Record)['agent'], + 'agent/error': args => (args[0] as Record)['agent'], + 'agent/inbox/claimed': args => (args[0] as Record)['agent'], + 'agent/inbox/discarded': args => (args[0] as Record)['agent'], + 'agent/inbox/inserted': args => (args[0] as Record)['agent'], + 'agent/pre-step': args => (args[0] as Record)['agent'], + 'agent/request': args => (args[0] as Record)['agent'], + 'agent/request-error': args => (args[0] as Record)['agent'], + 'agent/session-start': args => (args[0] as Record)['agent'], + 'agent/status': args => (args[0] as Record)['agent'], + 'agent/turn-stopping': args => (args[0] as Record)['agent'], 'approval/request': args => (args[0] as Record)['agent'], - 'goal/changed': args => args[0], + 'goal/changed': args => (args[0] as Record)['agent'], 'session/created': null, 'session/disposed': null, 'session/event': null, diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts index d647344537..8744bb9aa7 100644 --- a/packages/core/scope/tests/invariant.spec.ts +++ b/packages/core/scope/tests/invariant.spec.ts @@ -28,7 +28,7 @@ describe('scoped-dispatch invariants', () => { const ctx = await setup() expect(() => { emit(ctx, undefined, 'ordinary/event', []) }).not.toThrow() const agent = { id: 'a1' } - expect(() => { emit(ctx, undefined, 'agent/error', [agent, 1, 0, new Error('x')]) }) + expect(() => { emit(ctx, undefined, 'agent/error', [{ agent, turn: 1, step: 0, error: new Error('x') }]) }) .toThrow(/dispatched without a scope carrier/) }) @@ -45,34 +45,34 @@ describe('scoped-dispatch invariants', () => { source: { kind: 'user' }, }) const agentRows = { - 'agent/created': [agent], - 'agent/disposed': [agent], - 'agent/status': [agent, 'idle'], - 'agent/inbox/inserted': [agent, { message }], - 'agent/inbox/claimed': [agent, { message, turn: 1 }], - 'agent/inbox/discarded': [agent, { message }], - 'agent/session-start': [agent, 'startup'], - 'agent/pre-step': [agent, [message], { turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter', messages: [message] })], - 'agent/request': [agent, 1, 1, signal, () => Promise.resolve(config)], + 'agent/created': [{ agent }], + 'agent/disposed': [{ agent }], + 'agent/status': [{ agent, status: 'idle' }], + 'agent/inbox/inserted': [{ agent, message }], + 'agent/inbox/claimed': [{ agent, message, turn: 1 }], + 'agent/inbox/discarded': [{ agent, message }], + 'agent/session-start': [{ agent, source: 'startup' }], + 'agent/pre-step': [{ agent, messages: [message], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter', messages: [message] })], + 'agent/request': [{ agent, turn: 1, step: 1, signal }, () => Promise.resolve(config)], 'agent/request-error': [ - agent, { + agent, turn: 1, step: 1, provider: 'p', failure: { message: 'request', code: 'UNKNOWN' }, retryPolicy: undefined, + signal, }, - signal, () => Promise.resolve(undefined), ], - 'agent/turn-stopping': [agent, 1, signal], - 'agent/error': [agent, 1, 0, new Error('x')], + 'agent/turn-stopping': [{ agent, turn: 1, signal }], + 'agent/error': [{ agent, turn: 1, step: 0, error: new Error('x') }], } satisfies { [K in AgentEventName]: EventArgs } const rows: Array<[string, unknown[]]> = [ ...Object.entries(agentRows), ['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]], - ['goal/changed', [agent, { operation: 'create', ref: { id: 'goal-a', revision: 1 } }]], + ['goal/changed', [{ agent, change: { operation: 'create', ref: { id: 'goal-a', revision: 1 } } }]], ['system-prompt/assemble', [[], { scope: agent }]], ['tools/code-dispatch-log', [{ exec: { callId: 'c', name: 't', arguments: {} }, agent, subCallId: 'c:code:1', name: 't', isError: false, content: [] }, () => Promise.resolve([])]], ['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]], diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index 1a8938534f..9c60bee081 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -48,7 +48,7 @@ async function composePrefix(ctx: Context): Promise { const agent = ctx.agentLoop.create(SessionId(`acp-demo-prefix-${randomUUID()}`), {}, { cwd: '/tmp' }) const signal = new AbortController().signal const decision = await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', [], { turn: 1, step: 1, signal }, + 'agent/pre-step', { messages: [], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter', messages: [] }), ) if (decision.kind === 'enter') { diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 99e8ed7c91..4c1c825c38 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -41,7 +41,7 @@ async function composePrefix(ctx: Context, cwd: string): Promise { const agent = ctx.agentLoop.create(SessionId('agent-spine-prefix'), {}, { cwd }) const signal = new AbortController().signal const decision = await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', [], { turn: 1, step: 1, signal }, + 'agent/pre-step', { messages: [], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter', messages: [] }), ) if (decision.kind === 'enter') { diff --git a/packages/examples/cli-demo/tests/cli-demo.spec.ts b/packages/examples/cli-demo/tests/cli-demo.spec.ts index 933466b2e3..574e18c583 100644 --- a/packages/examples/cli-demo/tests/cli-demo.spec.ts +++ b/packages/examples/cli-demo/tests/cli-demo.spec.ts @@ -45,7 +45,7 @@ async function composePrefix(ctx: Context): Promise { const agent = ctx.agentLoop.create(SessionId(`cli-demo-prefix-${randomUUID()}`), {}, { cwd: '/tmp' }) const signal = new AbortController().signal const decision = await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', [], { turn: 1, step: 1, signal }, + 'agent/pre-step', { messages: [], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter', messages: [] }), ) if (decision.kind === 'enter') { diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index 39afe0a24e..9fd87aaf52 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -401,7 +401,7 @@ describe('runOneShot and executeCli', () => { if (session === agent.session && event.type === 'assistant/message' && event.data.turn === 1) startupStarted() }) - ctx.on('agent/turn-stopping', async (subject, turn) => { + ctx.on('agent/turn-stopping', async ({ agent: subject, turn }) => { if (subject === agent && turn === 1) await releaseStartup.promise }) agent.followup(createUserMessage({ @@ -432,7 +432,7 @@ describe('runOneShot and executeCli', () => { } let replacementQueued = false - ctx.on('agent/status', (subject, status) => { + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject !== agent || status !== 'idle' || replacementQueued) return replacementQueued = true agent.followup(createUserMessage({ diff --git a/packages/fs/tool-fs/tests/harness.ts b/packages/fs/tool-fs/tests/harness.ts index eff2588ec2..0d700e61b6 100644 --- a/packages/fs/tool-fs/tests/harness.ts +++ b/packages/fs/tool-fs/tests/harness.ts @@ -25,7 +25,7 @@ export async function fsHarness(fsCwd: string, persona = ''): Promise { export function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index b81d1cb599..5c92f048d8 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -243,20 +243,20 @@ export function apply(ctx: Context): void { // One composite effect keeps the step fence installed until this // plugin's own scheduling tasks settle. ctx.effect(function* () { - ctx.on('agent/error', (agent) => { + ctx.on('agent/error', ({ agent }) => { const state = stateFor(agent) disarm(state) }) - ctx.on('agent/created', (agent) => { stateFor(agent) }) - ctx.on('agent/disposed', (agent) => { states.delete(agent) }) - ctx.on('agent/session-start', (agent) => { + ctx.on('agent/created', ({ agent }) => { stateFor(agent) }) + ctx.on('agent/disposed', ({ agent }) => { states.delete(agent) }) + ctx.on('agent/session-start', ({ agent }) => { const state = stateFor(agent) state.attempt = undefined state.competingQueued = false state.needsCheckpoint = false }) - ctx.on('agent/status', (agent, status) => { + ctx.on('agent/status', ({ agent, status }) => { const state = stateFor(agent) if (status === 'idle') { state.competingQueued = false @@ -275,13 +275,13 @@ export function apply(ctx: Context): void { requestDrive(state) } }) - ctx.on('goal/changed', (agent) => { + ctx.on('goal/changed', ({ agent }) => { const state = stateFor(agent) state.needsCheckpoint = true requestDrive(state) }) - ctx.on('agent/inbox/inserted', (agent, { message }) => { + ctx.on('agent/inbox/inserted', ({ agent, message }) => { if (!agent.inbox.nextTurn.some(candidate => candidate.id === message.id)) return const state = stateFor(agent) const attempt = state.attempt @@ -289,14 +289,14 @@ export function apply(ctx: Context): void { state.competingQueued = true if (attempt?.phase === 'queued') attempt.stale = true }) - ctx.on('agent/inbox/claimed', (agent, { message }) => { + ctx.on('agent/inbox/claimed', ({ agent, message }) => { const state = stateFor(agent) const attempt = state.attempt if (attempt !== undefined && sameQueued(message.content, message.source, attempt)) { attempt.phase = 'claimed' } }) - ctx.on('agent/inbox/discarded', (agent, { message }) => { + ctx.on('agent/inbox/discarded', ({ agent, message }) => { const state = stateFor(agent) const attempt = state.attempt if (attempt !== undefined && sameQueued(message.content, message.source, attempt)) { @@ -346,7 +346,7 @@ export function apply(ctx: Context): void { && source.round === goal.roundsStarted + 1 } - ctx.on('agent/pre-step', async (agent, messages, { signal }, next): Promise => { + ctx.on('agent/pre-step', async ({ agent, messages, signal }, next): Promise => { const submitted = messages.find((message): message is UserMessage & { source: GoalMessageSource } => isGoalRoundSource(message.source)) if (submitted === undefined) return next() diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index d9fd63c940..2d14abf158 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -107,7 +107,7 @@ function onInboxMessage( agent: Agent, listener: (message: UserMessage) => void, ): () => void { - return ctx.on('agent/inbox/inserted', (subject, { message }) => { + return ctx.on('agent/inbox/inserted', ({ agent: subject, message }) => { if (subject === agent) listener(message) }) } @@ -118,7 +118,7 @@ function onClaimedMessage( agent: Agent, listener: (message: UserMessage) => void, ): () => void { - return ctx.on('agent/inbox/claimed', (subject, { message }) => { + return ctx.on('agent/inbox/claimed', ({ agent: subject, message }) => { if (subject === agent) listener(message) }) } @@ -247,7 +247,7 @@ describe('same-session goal driving', () => { it('maps a downstream step rejection to blocked without entering the round', async () => { const test = await harness([]) - test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => messages[0]?.source.kind === 'goal' + test.ctx.on('agent/pre-step', ({ messages }, next) => messages[0]?.source.kind === 'goal' ? Promise.resolve({ kind: 'reject' as const }) : next()) test.ctx.goals.create(test.agent, { objective: 'respect policy' }) @@ -265,10 +265,10 @@ describe('same-session goal driving', () => { it('does not reserve again when a stopped-goal observer queues cancel-scoped work', async () => { const test = await harness([textResponse('human follow-up')]) - test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => messages[0]?.source.kind === 'goal' + test.ctx.on('agent/pre-step', ({ messages }, next) => messages[0]?.source.kind === 'goal' ? Promise.resolve({ kind: 'reject' as const }) : next()) - test.ctx.on('goal/changed', (agent, change) => { + test.ctx.on('goal/changed', ({ agent, change }) => { if (change.operation === 'block') agent.followup(createUserMessage({ content: [{ type: 'text', text: 'inspect the blocker' }], source: { kind: 'user' } })) }) test.ctx.goals.create(test.agent, { objective: 'stop and inspect' }) @@ -370,7 +370,7 @@ describe('same-session goal driving', () => { it('rechecks revision after downstream prompt hooks before admitting', async () => { const test = await harness([textResponse('new revision')]) let edited = false - test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', ({ agent, messages }, next) => { if (messages[0]?.source.kind === 'goal' && !edited) { edited = true const current = test.ctx.goals.get(agent) @@ -389,7 +389,7 @@ describe('same-session goal driving', () => { it('does not block a goal that downstream paused before rejecting its prompt', async () => { const test = await harness([]) - test.ctx.on('agent/pre-step', async (agent, messages, _context, next) => { + test.ctx.on('agent/pre-step', async ({ agent, messages }, next) => { if (!messages.some(message => message.source.kind === 'goal' && message.source.round > 0)) { return next() } @@ -432,7 +432,7 @@ describe('same-session goal driving', () => { test.agent.inbox.prepend('next-step', roundZeroContext) }) let edited = false - test.ctx.on('agent/pre-step', async (agent, messages, _context, next) => { + test.ctx.on('agent/pre-step', async ({ agent, messages }, next) => { const decision = await next() if (!messages.some(message => message.source.kind === 'goal' && message.source.round > 0) || edited) return decision edited = true @@ -513,8 +513,10 @@ describe('same-session goal driving', () => { const test = await harness([]) test.ctx.on('session/flush', () => Promise.reject(new Error('clear checkpoint failed'))) agentEvents(test.ctx, test.agent).emit('goal/changed', { - operation: 'clear', - ref: { id: GoalId('cleared-goal'), revision: 2 }, + change: { + operation: 'clear', + ref: { id: GoalId('cleared-goal'), revision: 2 }, + }, }) await new Promise((resolve) => { setImmediate(resolve) }) @@ -529,7 +531,7 @@ describe('same-session goal driving', () => { ]) // The llm-retry shape: schedule one retry for the failed goal-round request. let retried = false - test.ctx.on('agent/request-error', async (_subject) => { + test.ctx.on('agent/request-error', async (_payload) => { if (!retried) { retried = true return { kind: 'retry' } @@ -552,7 +554,7 @@ describe('same-session goal driving', () => { // attempt through cancel-requested) and THEN throws: the catch finds no // matching reservation and must not reschedule a paused goal. let fired = false - test.ctx.on('agent/pre-step', async (agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', async ({ agent, messages }, next) => { if (messages[0]?.source.kind === 'goal' && !fired) { fired = true agent.cancel({ kind: 'user' }) @@ -576,7 +578,7 @@ describe('same-session goal driving', () => { // Registered after goal-session's own listener: the throw propagates back // through goal-session's next() await, dropping the whole step proposal. let threw = false - test.ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', async ({ messages }, next) => { if (messages[0]?.source.kind === 'goal' && !threw) { threw = true throw new Error('downstream pre-step hook exploded') @@ -598,7 +600,7 @@ describe('same-session goal driving', () => { textResponse('goal round ran'), ]) let retried = false - test.ctx.on('agent/request-error', async (_subject) => { + test.ctx.on('agent/request-error', async (_payload) => { if (!retried) { retried = true return { kind: 'retry' } @@ -721,7 +723,7 @@ describe('same-session goal driving', () => { it('fails a post-hook read closed before the prompt can enter history', async () => { const test = await harness([]) let armed = true - test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', ({ messages }, next) => { if (messages[0]?.source.kind === 'goal' && armed) { armed = false vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { @@ -809,7 +811,7 @@ describe('same-session goal driving', () => { it('rejects the step when downstream cancellation clears the reservation', async () => { const test = await harness([]) let cancelled = false - test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', ({ agent, messages }, next) => { if (messages[0]?.source.kind === 'goal' && !cancelled) { cancelled = true agent.cancel({ kind: 'user' }) @@ -864,7 +866,7 @@ describe('same-session goal driving', () => { it('resets process-local scheduling state at a session-start edge', async () => { const test = await harness([textResponse('after explicit resume')]) const created = test.ctx.goals.create(test.agent, { objective: 'restart safely', maxGoalRounds: 1 }) - agentEvents(test.ctx, test.agent).emit('agent/session-start', 'resume') + agentEvents(test.ctx, test.agent).emit('agent/session-start', { source: 'resume' }) await Promise.resolve() expect(test.ctx.goals.get(test.agent)).toMatchObject({ activation: 'disarmed', roundsStarted: 0 }) @@ -898,7 +900,7 @@ describe('same-session goal driving', () => { const test = await harness([textResponse('round one')]) test.ctx.on('session/event', (session, event) => { if (session === test.agent.session && event.type === 'turn/end') { - agentEvents(test.ctx, test.agent).emit('agent/error', event.data.turn, 1, new Error('post-turn flush failed')) + agentEvents(test.ctx, test.agent).emit('agent/error', { turn: event.data.turn, step: 1, error: new Error('post-turn flush failed') }) } }) test.ctx.goals.create(test.agent, { objective: 'stop when durability is lost', maxGoalRounds: 8 }) @@ -923,7 +925,7 @@ describe('same-session goal driving', () => { await handle.dispose() const warn = vi.spyOn(test.ctx.logger, 'warn') - agentEvents(test.ctx, handle.agent).emit('agent/error', closed.data.turn, 1, new Error('late flush failure')) + agentEvents(test.ctx, handle.agent).emit('agent/error', { turn: closed.data.turn, step: 1, error: new Error('late flush failure') }) expect(test.ctx.agents.get(handle.agent.id)).toBeUndefined() expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('goal-session')) @@ -959,7 +961,7 @@ describe('same-session goal driving', () => { it('waits for work queued by a pause observer before considering the next round', async () => { const test = await harness(['hang', textResponse('inspection answer')]) - test.ctx.on('goal/changed', (agent, change) => { + test.ctx.on('goal/changed', ({ agent, change }) => { if (agent === test.agent && change.operation === 'pause') { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'inspect the pause' }], source: { kind: 'user' } })) } @@ -982,7 +984,7 @@ describe('same-session goal driving', () => { it('does not re-block a goal the downstream veto already saw cancelled', async () => { const test = await harness([]) let vetoed = false - test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', ({ agent, messages }, next) => { if (messages[0]?.source.kind === 'goal' && !vetoed) { vetoed = true agent.cancel({ kind: 'user' }) @@ -1007,7 +1009,7 @@ describe('same-session goal driving', () => { it('awaits a claimed reservation stuck in pre-step during teardown without cancelling', async () => { const test = await harness([]) let release: (() => void) | undefined - test.ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', async ({ messages }, next) => { if (messages[0]?.source.kind === 'goal' && release === undefined) { await new Promise((resolve) => { release = resolve }) } diff --git a/packages/goal/goal/src/domain.ts b/packages/goal/goal/src/domain.ts index 377ba402e2..fec44de2f3 100644 --- a/packages/goal/goal/src/domain.ts +++ b/packages/goal/goal/src/domain.ts @@ -134,10 +134,10 @@ declare module 'cordis' { * Goal mutation accepted by one live agent. The matching `goal/change` * session event has already committed. Listener failures are contained. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @param agent - agent whose session owns the goal. - * @param change - fresh current projection or clear tombstone. + * @param payload.agent - agent whose session owns the goal. + * @param payload.change - fresh current projection or clear tombstone. * @mode emit */ - 'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped, agent: Agent, change: GoalChanged): void + 'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped, payload: { agent: Agent; change: GoalChanged }): void } } diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index f6a4a99fc6..1cd3c6074a 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -193,7 +193,7 @@ export class GoalService extends Service { this.resolved = { defaultMaxGoalRounds: resolveMaxGoalRounds(config.defaultMaxGoalRounds ?? 256), } - ctx.on('agent/session-start', (agent) => { + ctx.on('agent/session-start', ({ agent }) => { this.cache(agent.session).activation = 'disarmed' }) // The `goal` projection unit: last-wins fold of goal/change whole values @@ -547,7 +547,7 @@ export class GoalService extends Service { ref: { ...ref }, ...goal === undefined ? {} : { goal }, } - agentEvents(this.ctx, agent).emit('goal/changed', notification) + agentEvents(this.ctx, agent).emit('goal/changed', { change: notification }) } /** Build a detached current view. */ diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 58661481cf..38eea7cf61 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -83,7 +83,7 @@ describe('GoalService creation and replay', () => { vi.setSystemTime(1_700_000_000_000) const { ctx, agent, session } = await harness({ defaultMaxGoalRounds: 17 }) const seen: string[] = [] - ctx.on('goal/changed', (_subject, change) => { seen.push(change.operation) }) + ctx.on('goal/changed', ({ change }) => { seen.push(change.operation) }) const goal = ctx.goals.create(agent, { objective: ' finish the feature ' }) @@ -191,7 +191,7 @@ describe('GoalService creation and replay', () => { const { ctx, agent, session } = await harness() let goal = ctx.goals.create(agent, { objective: 'stay stopped after resume' }) expect(goal.activation).toBe('armed') - agentEvents(ctx, agent).emit('agent/session-start', 'resume') + agentEvents(ctx, agent).emit('agent/session-start', { source: 'resume' }) expect(ctx.goals.get(agent)?.activation).toBe('disarmed') goal = ctx.goals.resume(agent, goal) expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 2 }) @@ -223,7 +223,7 @@ describe('GoalService creation and replay', () => { await fiber.dispose() expect(ctx.get('goals')).toBeUndefined() - agentEvents(ctx, stub.agent).emit('agent/session-start', 'resume') + agentEvents(ctx, stub.agent).emit('agent/session-start', { source: 'resume' }) expect(first.get(stub.agent)).toMatchObject({ id: goal.id, activation: 'armed' }) await ctx.plugin(GoalService) @@ -384,7 +384,7 @@ describe('GoalService mutations', () => { const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) const seen: string[] = [] ctx.on('goal/changed', () => { throw new Error('broken observer') }) - ctx.on('goal/changed', (_subject, change) => { seen.push(change.operation) }) + ctx.on('goal/changed', ({ change }) => { seen.push(change.operation) }) expect(ctx.goals.create(agent, { objective: 'notify' }).phase).toBe('active') expect(seen).toEqual(['create']) expect(warn).toHaveBeenCalledWith(expect.stringContaining('broken observer')) diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 3b1e892ec5..df6c5a9b4b 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -399,7 +399,7 @@ describe('goal tool state transitions', () => { let turn = openTurn(root, { kind: 'user' }) const created = ctx.goals.create(root.agent, { objective: 'continue later' }) closeTurn(root, turn) - agentEvents(ctx, root.agent).emit('agent/session-start', 'resume') + agentEvents(ctx, root.agent).emit('agent/session-start', { source: 'resume' }) expect(ctx.goals.get(root.agent)?.activation).toBe('disarmed') turn = openTurn(root, { kind: 'user' }, '继续') const resumed = await execute(ctx, 'update_goal', { diff --git a/packages/guard/repeat-tool-guard/src/index.ts b/packages/guard/repeat-tool-guard/src/index.ts index d58d4f0528..125f7ac998 100644 --- a/packages/guard/repeat-tool-guard/src/index.ts +++ b/packages/guard/repeat-tool-guard/src/index.ts @@ -223,7 +223,7 @@ export function apply(ctx: Context, config: Config): void { // A user interjection changes the context; repetition across it is not a // loop. Pure reset hook: always delegates (attaching nothing, vetoing // nothing). - ctx.on('agent/pre-step', (agent, messages, _context, next): Promise => { + ctx.on('agent/pre-step', ({ agent, messages }, next): Promise => { if (messages.some(message => message.source.kind === 'user')) chains.delete(agent) return next() }) diff --git a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts index a7c31a2a09..8f13ec1c03 100644 --- a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts +++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts @@ -32,7 +32,7 @@ async function harness(config: Config = {}): Promise { } function waitForIdle(ctx: Context, agent: Agent): Promise { - return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) + return new Promise((resolve) => { const d = ctx.on('agent/status', ({ agent: s, status: st }) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) } /** Every injected-context user message in the agent's log, flattened to joined text + source for terse assertions. */ diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 344c42e94f..77b3a2711b 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -203,7 +203,7 @@ export function apply(ctx: Context, config: Config): void { // SessionStart injects context when its detached hook resolves; a slow hook // may miss the first request. // TODO(session-start-gating): add a startup gate before promising first-turn delivery. - ctx.on('agent/session-start', (agent, source) => { + ctx.on('agent/session-start', ({ agent, source }) => { detached.track(runPoint('SessionStart', source, sessionStartPayload(ctx, agent, source), { agent, signal: detached.signal }) .then((merged) => { const context = contextFrom(merged) @@ -216,7 +216,7 @@ export function apply(ctx: Context, config: Config): void { // --- UserPromptSubmit → PreStepDecision. The prompt text is the payload; no // matcher subject (CC ignores matchers for this event). --- - ctx.on('agent/pre-step', async (agent, messages, { turn, signal }, next): Promise => { + ctx.on('agent/pre-step', async ({ agent, messages, turn, signal }, next): Promise => { if (messages.length === 0) return next() const content = messages.flatMap(message => message.content) const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, turn, signal }) @@ -267,7 +267,7 @@ export function apply(ctx: Context, config: Config): void { // A blocking Stop hook steers at the stopping boundary, which makes the // machine observe pending input and run another step. // TODO(stop-loop-guard): cap consecutive forced continuations; hooks must self-limit meanwhile. - ctx.on('agent/turn-stopping', async (agent, turn, signal): Promise => { + ctx.on('agent/turn-stopping', async ({ agent, turn, signal }): Promise => { const merged = await runPoint('Stop', '', stopPayload(ctx, agent), { agent, turn, signal }) if (merged.decision === 'deny') { // A blocking Stop hook forces continuation. diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index f69d876d11..b7ea693583 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -520,7 +520,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(path, adapter) - ctx.on('agent/pre-step', async (_agent, messages) => ({ + ctx.on('agent/pre-step', async ({ messages }) => ({ kind: 'enter' as const, messages: [{ ...messages[0]!, diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index e96c1a555a..304deef63c 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -185,7 +185,7 @@ export function apply(ctx: Context, config: Config): void { // SessionStart injects plain stdout when its detached hook resolves; a slow // hook may miss the first request. // TODO(session-start-gating): add a startup gate before promising first-turn delivery. - ctx.on('agent/session-start', (agent, source) => { + ctx.on('agent/session-start', ({ agent, source }) => { detached.track(runPoint('SessionStart', source, { ...base(ctx, agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal }) .then((merged) => { const context = contextFrom(merged) @@ -196,7 +196,7 @@ export function apply(ctx: Context, config: Config): void { }) // UserPromptSubmit → PreStepDecision. Codex supports reject, not rewrite or ask. - ctx.on('agent/pre-step', async (agent, messages, { turn, signal }, next): Promise => { + ctx.on('agent/pre-step', async ({ agent, messages, turn, signal }, next): Promise => { if (messages.length === 0) return next() const payload = { ...base(ctx, agent, 'UserPromptSubmit', model), @@ -257,7 +257,7 @@ export function apply(ctx: Context, config: Config): void { // TODO(stop-loop-guard): Codex supplies `stop_hook_active` so a Stop hook can // avoid continuing the same turn indefinitely. It is always false here, so an // unconditionally blocking hook force-continues every step until it self-limits. - ctx.on('agent/turn-stopping', async (agent, turn, signal): Promise => { + ctx.on('agent/turn-stopping', async ({ agent, turn, signal }): Promise => { const merged = await runPoint('Stop', '', { ...turnBase(ctx, agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn, signal }) /* jscpd:ignore-end */ if (merged.decision === 'deny') { diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index 664f3cb2b4..942feaaa05 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -128,7 +128,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.on('agent/pre-step', async (_agent, messages) => ({ + ctx.on('agent/pre-step', async ({ messages }) => ({ kind: 'enter' as const, messages: [{ ...messages[0]!, diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 838e4f3a92..90a8ecabb1 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -2595,10 +2595,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ctx.on('session/disposed', (session: Session) => { queue.push(frame({ type: 'host/session-removed', sessionId: session.id })) }), - ctx.on('agent/status', (agent: Agent, status: AgentStatus) => { + ctx.on('agent/status', ({ agent, status }: { agent: Agent; status: AgentStatus }) => { queue.push(frame({ type: 'host/session-status', sessionId: agent.id, running: status === 'running' })) }), - ctx.on('agent/error', (agent: Agent, _turn: number, _step: number, error: unknown) => { + ctx.on('agent/error', ({ agent, error }: { agent: Agent; error: unknown }) => { queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: errorChain(error) })) }), ctx.on('domain/changed', (change) => { diff --git a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts index 9f6ef65e2f..83955f2d8b 100644 --- a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts @@ -280,7 +280,7 @@ describe('sessions.fork', () => { }) const fallback: LlmCallConfig = { provider: 'default-provider', model: 'default-model' } await expect(agentEvents(child.ctx, child).waterfall( - 'agent/request', 1, 0, new AbortController().signal, () => Promise.resolve(fallback), + 'agent/request', { turn: 1, step: 0, signal: new AbortController().signal }, () => Promise.resolve(fallback), )).resolves.toMatchObject({ provider: 'inherited-provider', model: 'inherited-model', diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 2a4754f144..c2dfdae7a7 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -181,13 +181,13 @@ describe('Web session model selection', () => { reasoningEffort: 'max', }) await expect(agentEvents(ctx, agent).waterfall( - 'agent/request', 1, 0, signal, () => Promise.resolve(seed), + 'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed), )).resolves.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' }) expect((await ctx.systemPrompt.assemble()).variables) .toMatchObject({ provider: 'deepseek-official', model: 'private-preview' }) await expect(agentEvents(ctx, agent).waterfall( - 'agent/request', 1, 1, signal, () => Promise.resolve(seed), + 'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve(seed), )).resolves.toMatchObject({ provider: 'deepseek-official', model: 'private-preview', diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index fd756a39cc..620e367742 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -5,9 +5,9 @@ * @module @deepseek-ai/dsh-llm-retry */ -import type { Context } from 'cordis' +import type { Context, Events } from 'cordis' import z from 'schemastery' -import type { Agent, RequestErrorAction, RequestFailureContext } from '@deepseek-ai/dsh-agent' +import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent' import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' @@ -172,12 +172,9 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna } async function recover( - agent: Agent, - context: RequestFailureContext, - signal: AbortSignal, + { agent, turn, step, provider, failure, retryPolicy: policy, signal }: Parameters[0], next: () => Promise, ): Promise { - const { turn, step, provider, failure, retryPolicy: policy } = context if (policy === undefined) return next() if (policy.mode === 'always') { if (signal.aborted || lifetime.signal.aborted) return @@ -228,16 +225,14 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna } const disposeListener = ctx.on('agent/request-error', ( - agent: Agent, - context: RequestFailureContext, - signal: AbortSignal, + payload, next: () => Promise, ) => { // A waterfall may have captured this callback before its registration was // removed. Lifetime cancellation must prevent that stale callback from // entering a downstream policy after disposal. if (lifetime.signal.aborted) return Promise.resolve(undefined) - return track(recover(agent, context, signal, next)) + return track(recover(payload, next)) }) ctx.effect(() => async () => { diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index d1500fa781..ac0ed687fa 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -506,7 +506,7 @@ describe('provider-routed retry policy', () => { ;({ ctx: context } = await harness(adapter, { other: alwaysConfig({ initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }), }, (ctx) => { - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({ + ctx.on('agent/request', async (_payload, next) => ({ ...await next(), provider: 'other', })) @@ -543,7 +543,7 @@ describe('provider-routed retry policy', () => { backoff: { initialDelayMs: 1, maxDelayMs: 1 }, }), }, (ctx) => { - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({ + ctx.on('agent/request', async (_payload, next) => ({ ...await next(), provider: adapter.requests.length === 0 ? 'mock' : 'other', })) @@ -881,7 +881,7 @@ describe('provider-routed retry policy', () => { context = mounted.ctx const downstream = Promise.withResolvers() const entered = Promise.withResolvers() - context.on('agent/request-error', (agent) => { + context.on('agent/request-error', ({ agent }) => { agent.cancel({ kind: 'user' }) entered.resolve(undefined) return downstream.promise @@ -917,7 +917,7 @@ describe('provider-routed retry policy', () => { const captured = Promise.withResolvers() let invokeCaptured: (() => Promise) | undefined const mounted = await harness(adapter, {}, (ctx) => { - ctx.on('agent/request-error', (_agent, _context, _signal, next) => { + ctx.on('agent/request-error', (_payload, next) => { return new Promise((resolve) => { invokeCaptured = async () => { resolve(await next()) } captured.resolve(undefined) @@ -926,7 +926,7 @@ describe('provider-routed retry policy', () => { }) context = mounted.ctx let downstreamCalls = 0 - context.on('agent/request-error', async (_agent, _context, _signal, next) => { + context.on('agent/request-error', async (_payload, next) => { downstreamCalls += 1 return next() }) @@ -980,7 +980,7 @@ describe('provider-routed retry policy', () => { textResponse('must not run'), ]) ;({ ctx: context } = await harness(adapter, { mock: policy }, (ctx) => { - ctx.on('agent/request-error', async (agent, _context, _signal, next) => { + ctx.on('agent/request-error', async ({ agent }, next) => { agent.cancel({ kind: 'user' }) return next() }) diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 835b4b9036..2c99047322 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -202,9 +202,7 @@ export class PlanModeService extends Service { // the session. A failed append remains pending for a later boundary, and // policy cannot block the step. ctx.on('agent/pre-step', async ( - agent, - _messages, - { signal }, + { agent, signal }, next, ): Promise => { const decision = await next() diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index 6e614a36a0..34678714fa 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -42,7 +42,7 @@ async function harness(adapter: MockAdapter): Promise { function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() @@ -139,7 +139,7 @@ describe('plan mode through the agent loop', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-plan-retry-flip'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request-error', async (subject, _context, _signal, next) => { + ctx.on('agent/request-error', async ({ agent: subject }, next) => { if (subject !== agent) return next() ctx.planMode.set(agent, true) return { kind: 'retry' } diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index 87a295e90c..63abed59ea 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -45,7 +45,7 @@ async function agentWithSession(ctx: Context, id = 'agent-1', { active }: { acti // Seeded plan state lands before the creation announcement, matching resume. if (active !== undefined) session.append('plan/mode', { active }) // The loop announces creation after publication. - ctx.emit('agent/created', agent) + ctx.emit('agent/created', { agent }) return agent } @@ -74,8 +74,7 @@ async function boundary(ctx: Context, agent: Agent & { session: Session }, type: const signal = new AbortController().signal const decision = await events.waterfall( 'agent/pre-step', - [message], - { turn: 1, step: 1, signal }, + { messages: [message], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [message] }), ) if (decision.kind === 'enter') { diff --git a/packages/session-persistence/session-checkpoint-policy/src/index.ts b/packages/session-persistence/session-checkpoint-policy/src/index.ts index c26a65e8ad..804ed0dcb1 100644 --- a/packages/session-persistence/session-checkpoint-policy/src/index.ts +++ b/packages/session-persistence/session-checkpoint-policy/src/index.ts @@ -76,7 +76,7 @@ export function apply(ctx: Context): void { // Before each request, persist everything committed by the preceding step; // the first step's call is an intentional no-op beyond any prompt intake. - ctx.on('agent/pre-step', async (agent, _messages, _context, next): Promise => { + ctx.on('agent/pre-step', async ({ agent }, next): Promise => { await ctx.sessions.flush(agent.session) return next() }) diff --git a/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts b/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts index b619871156..dde59610c5 100644 --- a/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts +++ b/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts @@ -228,7 +228,7 @@ describe('session-checkpoint-policy tool and step boundaries', () => { ctx.on('session/flush', (current) => { flushed.push(current.id) }) const signal = new AbortController().signal await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', [], { turn: 1, step: 1, signal }, + 'agent/pre-step', { messages: [], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter', messages: [] }), ) expect(flushed).toEqual([session.id]) diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index b343fee8b0..f4ae3ca595 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -135,9 +135,7 @@ export function apply(ctx: Context, config: Config = {}): void { // Register after the tool so reverse teardown removes guidance first. Exact definition // identity prevents a scoped shadow merely named `skill` from inheriting this catalog. ctx.on('agent/pre-step', async ( - agent: Agent, - _messages, - { signal }, + { agent, signal }, next, ): Promise => { const decision = await next() diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index c3562564a0..5599ce7a8d 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -86,8 +86,7 @@ async function fireStep(ctx: Context, agent: Agent, turn: number, step: number): const signal = new AbortController().signal const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - [], - { turn, step, signal }, + { messages: [], turn, step, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) if (decision.kind === 'enter') { @@ -105,8 +104,7 @@ async function proposeStep( const signal = new AbortController().signal return await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - messages, - { turn: 1, step: 1, signal }, + { messages, turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter' as const, messages }), ) } @@ -131,8 +129,7 @@ async function composePrefix(ctx: Context, cwd: string, signal = new AbortContro async function composePrefixForAgent(ctx: Context, agent: Agent, signal = new AbortController().signal): Promise { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - [], - { turn: 1, step: 1, signal }, + { messages: [], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) if (decision.kind === 'enter') { @@ -234,7 +231,7 @@ describe('dsh-tool-skill', () => { source: 'runtime', content: 'User-only body.', }) - ctx.on('agent/pre-step', async (_agent, _messages, _context, next) => { + ctx.on('agent/pre-step', async (_payload, next) => { const decision = await next() if (decision.kind === 'reject') return decision return { diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 04ce4e23f9..acb4e4d36e 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -75,7 +75,7 @@ function prePublicationAbort(): Error { /** Append one one-shot descriptor inside the child's initial turn before its first request. */ function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescriptorData): void { let appended = false - childCtx.on('agent/pre-step', async (agent, _messages, _context, next) => { + childCtx.on('agent/pre-step', async ({ agent }, next) => { const decision = await next() if (!appended && decision.kind === 'enter') { appended = true diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts index 389ef5e2a7..33de6d0cc6 100644 --- a/packages/subagent/subagent-spawn/tests/harness.ts +++ b/packages/subagent/subagent-spawn/tests/harness.ts @@ -42,7 +42,7 @@ export async function spawnHarness(workdir: string): Promise { export function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 3644180056..d8a6af4cf7 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -283,7 +283,7 @@ export class SubagentContinuationManager { // child-first ordering. const scope = ctx.plugin(function activationOwner() {}) this.ownerCtx = scope.ctx - ctx.on('agent/disposed', (agent) => { + ctx.on('agent/disposed', ({ agent }) => { this.closingScopes.delete(agent) }) ctx.effect(function* (this: SubagentContinuationManager) { @@ -854,12 +854,12 @@ export class SubagentContinuationManager { // quiet Agent from one whose accepted turn has not been admitted yet. // Registered through the child's own scoped context, so scope filtering // already restricts both listeners to this exact agent. - handle.agent.ctx.on('agent/inbox/claimed', (_agent, { message }) => { + handle.agent.ctx.on('agent/inbox/claimed', ({ message }) => { /* v8 ignore next -- a claim of an id this manager never admitted needs * another sender on the same child, which no current path allows. */ if (activation.accepted.delete(message.id)) this.wake(activation) }) - handle.agent.ctx.on('agent/inbox/discarded', (_agent, { message }) => { + handle.agent.ctx.on('agent/inbox/discarded', ({ message }) => { if (activation.accepted.delete(message.id)) this.wake(activation) }) // Agent creation committed setup at its publication boundary; diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 7b7a2ab541..dca06add38 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -159,10 +159,10 @@ describe('SubagentService.startContinuable', () => { it('returns both identities at inbox acceptance, without waiting for the turn or the log', async () => { const { ctx, parent, adapter } = await setup([textResponse('first answer')]) const enqueued: { id: MessageId; loggedYet: boolean }[] = [] - ctx.on('agent/inbox/inserted', (agent, accepted) => { + ctx.on('agent/inbox/inserted', ({ agent, message }) => { // Acceptance is the boundary `startContinuable` resolves at, so observe // the log state exactly there rather than after later microtasks. - enqueued.push({ id: accepted.message.id, loggedYet: hasUserText(agent.session.events, 'child task') }) + enqueued.push({ id: message.id, loggedYet: hasUserText(agent.session.events, 'child task') }) }) const started = await ctx.subagents.startContinuable(startSpec(parent)) @@ -231,7 +231,7 @@ describe('SubagentService.startContinuable', () => { const { ctx, parent } = await setup([textResponse('unused')]) const controller = new AbortController() // Abort inside the child's creation window: setup runs before publication. - ctx.on('agent/created', (child) => { + ctx.on('agent/created', ({ agent: child }) => { if (child !== parent) controller.abort('caller gave up') }) @@ -753,7 +753,7 @@ describe('continuable durability and teardown', () => { await vi.waitFor(() => { expect(ctx.agents.get(grandchild.childId)).toBeDefined() }) const disposals: SessionId[] = [] - ctx.on('agent/disposed', (agent) => { disposals.push(agent.id) }) + ctx.on('agent/disposed', ({ agent }) => { disposals.push(agent.id) }) const drained = drainManager(ctx) // Let the held model call observe its cancellation so quiescence can settle. hold.resolve(undefined) @@ -984,7 +984,7 @@ describe('continuable durability and teardown', () => { const drains: Promise[] = [] const accepted: MessageId[] = [] ctx.on('subagent/start', () => { drains.push(drainManager(ctx)) }) - ctx.on('agent/inbox/inserted', (_agent, item) => { accepted.push(item.message.id) }) + ctx.on('agent/inbox/inserted', ({ message }) => { accepted.push(message.id) }) await expect(ctx.subagents.startContinuable(startSpec(parent))) .rejects.toMatchObject({ code: 'DRAINING' }) @@ -998,12 +998,12 @@ describe('continuable durability and teardown', () => { const { ctx, parent } = await setup([]) const order: string[] = [] const drains: Promise[] = [] - ctx.on('agent/created', (child) => { + ctx.on('agent/created', ({ agent: child }) => { if (child === parent) return const draining = drainManager(ctx).then(() => { order.push('drain') }) drains.push(draining) }) - ctx.on('agent/disposed', (child) => { + ctx.on('agent/disposed', ({ agent: child }) => { if (child !== parent) order.push('disposed') }) @@ -1025,8 +1025,8 @@ describe('continuable durability and teardown', () => { await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! const order: string[] = [] - child.ctx.on('agent/inbox/inserted', (_agent, accepted) => { - if (accepted.message.content.some(block => block.type === 'text' && block.text === 'before drain')) { + child.ctx.on('agent/inbox/inserted', ({ message }) => { + if (message.content.some(block => block.type === 'text' && block.text === 'before drain')) { order.push('enqueue') } }) @@ -1208,7 +1208,7 @@ describe('continuable review regressions', () => { const ends: SubagentRunEndInfo[] = [] ctx.on('subagent/end', (info) => { ends.push(info) }) // Block the resumed prompt so this epoch produces nothing of its own. - ctx.on('agent/pre-step', async (subject, _messages, _context, next) => { + ctx.on('agent/pre-step', async ({ agent: subject }, next) => { if (subject === parent) return next() return { kind: 'reject' } }) @@ -1356,8 +1356,8 @@ describe('continuable review regressions', () => { // Cancel from the synchronous enqueue observer: the discard fires after the // id is recorded but before `followup()` returns. - const off = child.ctx.on('agent/inbox/inserted', (_agent, accepted) => { - if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) { + const off = child.ctx.on('agent/inbox/inserted', ({ message }) => { + if (message.content.some(block => block.type === 'text' && block.text === 'doomed')) { child.cancel({ kind: 'user' }) } }) @@ -1388,8 +1388,8 @@ describe('continuable review regressions', () => { await followup(ctx, parent, started.childId, message('queued')) expect(activation.accepted.size).toBe(1) - const off = child.ctx.on('agent/inbox/inserted', (_agent, accepted) => { - if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) { + const off = child.ctx.on('agent/inbox/inserted', ({ message }) => { + if (message.content.some(block => block.type === 'text' && block.text === 'doomed')) { child.cancel({ kind: 'user' }) } }) @@ -1406,7 +1406,7 @@ describe('continuable review regressions', () => { const ends: SubagentRunEndInfo[] = [] ctx.on('subagent/end', (info) => { ends.push(info) }) // Block admission so the child's only turn never opens. - ctx.on('agent/pre-step', async (subject, _messages, _context, next) => { + ctx.on('agent/pre-step', async ({ agent: subject }, next) => { if (subject === parent) return next() return { kind: 'reject' } }) @@ -1428,7 +1428,7 @@ describe('continuable review regressions', () => { const registeredAtEnqueue: boolean[] = [] // A synchronous inbox observer runs before the admitting microtask, the // exact window where `Agent.status` is still idle. - ctx.on('agent/inbox/inserted', (agent) => { + ctx.on('agent/inbox/inserted', ({ agent }) => { if (agent.session.header.parentSession !== undefined) { registeredAtEnqueue.push(ctx.agents.get(agent.id) === agent) } diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index 64c29d5122..ac90b4612b 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -164,9 +164,9 @@ describe('dsh-tool-subagent-report', () => { const { started, child } = await startChild(ctx, parent) const parentRequests = adapter.requests.filter(request => request.sessionId === parent.id).length const enqueues: string[] = [] - ctx.on('agent/inbox/inserted', (agent, item) => { + ctx.on('agent/inbox/inserted', ({ agent, message }) => { if (agent === parent) { - enqueues.push(agent.inbox.nextTurn.some(message => message.id === item.message.id) ? 'queued' : 'steering') + enqueues.push(agent.inbox.nextTurn.some(queued => queued.id === message.id) ? 'queued' : 'steering') } }) @@ -190,9 +190,9 @@ describe('dsh-tool-subagent-report', () => { const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'wakeup' } }) const { child } = await startChild(ctx, parent) const enqueues: string[] = [] - ctx.on('agent/inbox/inserted', (agent, item) => { + ctx.on('agent/inbox/inserted', ({ agent, message }) => { if (agent === parent) { - enqueues.push(agent.inbox.nextTurn.some(message => message.id === item.message.id) ? 'queued' : 'steering') + enqueues.push(agent.inbox.nextTurn.some(queued => queued.id === message.id) ? 'queued' : 'steering') } }) diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index 0bebbcc561..5cc17ddb79 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -89,7 +89,7 @@ export class TelemetryCoordinator { this.hintFlush(session) }) }) - ctx.on('agent/error', (agent, turn, step, error) => { + ctx.on('agent/error', ({ agent, turn, step, error }) => { this.contain(() => { this.relayAgentError(agent, turn, step, error) }) diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index 02ca434c0d..8bdf71ff7b 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -427,7 +427,7 @@ describe('TelemetryCoordinator lifecycle and containment', () => { const session = liveSession(ctx, 'erring') // Only the members the relay reads; the full Agent surface is irrelevant here. const agent = { id: 'agent-1', session } as Agent - ctx.emit('agent/error', agent, 3, 2, error) + ctx.emit('agent/error', { agent, turn: 3, step: 2, error }) const record = backend.records.find(r => r.channel === 'ops')! expect(record.severity).toBe('error') expect(record.attributes).toMatchObject({ diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index aff2958de3..f8be1ec27f 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -25,7 +25,7 @@ async function harness(adapter: MockAdapter): Promise { function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index e44b171c37..797e0cbfea 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -72,7 +72,7 @@ export class HarnessSdkServer { const payload: SessionEventNotification = { sessionId: String(session.id), event } this.transport.notify('session.event', payload) })) - this.disposers.push(ctx.on('agent/status', (agent, status) => { + this.disposers.push(ctx.on('agent/status', ({ agent, status }) => { this.transport.notify('session.status', { sessionId: String(agent.session.id), status }) })) this.disposers.push(ctx.on('session/created', (session) => { diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 78f3e11983..c9d1944781 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -254,8 +254,8 @@ describe('HarnessSdkServer', () => { session, } satisfies Pick) as Agent - ctx.emit('agent/status', agent, 'running') - ctx.emit('agent/status', agent, 'idle') + ctx.emit('agent/status', { agent, status: 'running' }) + ctx.emit('agent/status', { agent, status: 'idle' }) expect(transport.notifications.filter(notification => notification.method === 'session.status')) .toEqual([ From ee44980c513b906a63cf5a31fad848d3aea62c71 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 6 Aug 2026 12:16:23 +0800 Subject: [PATCH 05/16] docs(session): refresh generated catalogs after agent event payload rework Regenerate persistence catalog (types.ts line drift from retired PreStepContext/RequestFailureContext) and drop the retired PreStepContext entry from the type-equiv manifest. --- docs/persistence-catalog.md | 2 +- scripts/type-equiv.manifest.json | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index caff1e7685..54c711d6c4 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -100,7 +100,7 @@ Sources: [`packages/core/session/src/types.ts:308`](../packages/core/session/src } ``` -Source: [`packages/core/agent/src/types.ts:313`](../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:296`](../packages/core/agent/src/types.ts) ### `approval/*` diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 5d85466347..34015adfc1 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -116,11 +116,6 @@ "symbol": "Agent", "source": "packages/core/agent/src/types.ts" }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "PreStepContext", - "source": "packages/core/agent/src/types.ts" - }, { "doc": "docs/core-data-structures/core.md", "symbol": "PreStepDecision", From f535f590d621dde9b008ee2439714cd246eb019f Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 6 Aug 2026 12:17:54 +0800 Subject: [PATCH 06/16] docs: re-record core translation pair and refresh doc graphs --- docs/core-data-structures/core.i18n.yaml | 4 ++-- docs/event-producer-consumer.md | 24 ++++++++++++------------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 6712d12b9f..8f6a1e829f 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -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 docs/core-data-structures/core.md -core.md: 6886d9f15c37a6f3fd9cd825fb4c3f24d577db10 -core.zh.md: f89365dcdd620cd749c7ccca9ee2cc2da71118ac +core.md: 499f20b430854dd9a3c614604c7275502d95c40a +core.zh.md: 4b3a1381f95d229f4b7fc3465abfce57288f5276 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 2377ca0d69..0cd5907f5f 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,18 +8,18 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:182`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:178`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:187`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/types.ts:215`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/types.ts:223`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/types.ts:205`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/types.ts:247`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:260`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:272`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:235`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:197`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`jsonrpc`](../packages/ui/jsonrpc) | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:290`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:154`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:163`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/types.ts:192`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/types.ts:200`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/types.ts:181`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/types.ts:226`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:173`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`jsonrpc`](../packages/ui/jsonrpc) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:273`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy` | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | From 262d0446428ff531609745318007558ff47227e5 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 6 Aug 2026 13:44:23 +0800 Subject: [PATCH 07/16] fix(agent): route loop dispatches through prebuilt fused dispatcher Address review feedback on PR #1738: - ReactLoopAgent builds its AgentEventDispatch once in the constructor and routes every emit/serial/waterfall through it, so hot-path dispatches no longer allocate a carrier and dispatcher per call; the public carrier field is gone (fused dispatcher is private). - agentEvents accepts an optional prebuilt carrier. - The fused payload builder spreads the payload before the injected agent so a structurally acceptable payload carrying an agent field can never override the subject. - Regenerate doc graphs; re-record core + architecture + affected Agent Note translation pairs; add payload-object event contract Agent Note. --- ...07-16-explicit-turn-cancellation.i18n.yaml | 4 +-- .../2026-07-16-explicit-turn-cancellation.md | 4 +-- ...026-07-16-explicit-turn-cancellation.zh.md | 4 +-- ...8-06-agent-event-payload-objects.i18n.yaml | 6 ++++ .../2026-08-06-agent-event-payload-objects.md | 27 ++++++++++++++ ...26-08-06-agent-event-payload-objects.zh.md | 27 ++++++++++++++ ...06-18-compaction-capability-seam.i18n.yaml | 4 +-- .../2026-06-18-compaction-capability-seam.md | 2 +- ...026-06-18-compaction-capability-seam.zh.md | 2 +- .../2026-06-30-interception-seams.i18n.yaml | 4 +-- .../feature/2026-06-30-interception-seams.md | 4 +-- .../2026-06-30-interception-seams.zh.md | 4 +-- docs/architecture.i18n.yaml | 4 +-- docs/architecture.md | 4 +-- docs/architecture.zh.md | 4 +-- docs/core-data-structures/core.i18n.yaml | 4 +-- docs/event-producer-consumer.md | 10 +++--- packages/core/agent-loop/src/agent.ts | 36 +++++++++---------- packages/core/agent/README.i18n.yaml | 4 +-- packages/core/agent/README.md | 2 +- packages/core/agent/README.zh.md | 2 +- packages/core/agent/src/dispatch.ts | 26 ++++++++------ packages/core/agent/tests/agent.spec.ts | 16 +++++++++ 23 files changed, 143 insertions(+), 61 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.md create mode 100644 .agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml index 9c5d00eae0..820299cf2e 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md -2026-07-16-explicit-turn-cancellation.md: cce649976c9f4f596d5306b9fe8c3fd49a0e1adc -2026-07-16-explicit-turn-cancellation.zh.md: 6f8b83fdb42af03c97dc2e8a9345a01acc6019fc +2026-07-16-explicit-turn-cancellation.md: ca56c77a097e3008a50c2aec24040a4f4b6f0ba3 +2026-07-16-explicit-turn-cancellation.zh.md: bf410e5c7284a9c9914edbd14445074e71dd6943 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md index cce649976c..ca56c77a09 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md @@ -20,7 +20,7 @@ AgentLoop privately owns one `TurnCancellation` per prospective turn. It install The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective `cancel()` emits the observe-only `agent/cancel-requested` notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer belongs to the next turn. If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work. -The explicit event signatures keep their positional form and place `signal` inside `PreStepContext` or immediately before a waterfall's final `next`. Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn. +The explicit event signatures pass a single payload object: agent-scoped events carry `agent` and `signal` in the payload with `next` last, and the remaining seams keep `signal` immediately before a waterfall's final `next`. `PreStepContext` and `RequestFailureContext` are retired, with their fields folded into the `agent/pre-step` and `agent/request-error` payloads ([payload-object events](2026-08-06-agent-event-payload-objects.md)). Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn. `ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority. The cause reader is private to the loop and states the machine-private slot invariant (only `cancel()` aborts a turn controller, always with a canonical frozen cause) instead of re-validating the reason structurally; no public helper reads a cause off an arbitrary signal. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam. @@ -44,7 +44,7 @@ Initiator-scope tests assert that every hook still observes the exact Agent and **Define speculative `superseded`, `timeout`, and `shutdown` variants now.** No current Agent cancellation producer implements those semantics. `shutdown` is already lifecycle disposal, and timeout or supersession should enter the union only with an owning policy and unique terminal meaning. -**Expose public turn or step context wrappers.** Existing positional seams already identify Agent, turn, and step. A wrapper would widen every API, duplicate ownership, and tempt callers to treat a captured object as durable authority. +**Expose public turn or step context wrappers.** Existing seams already identify Agent, turn, and step. A wrapper would widen every API, duplicate ownership, and tempt callers to treat a captured object as durable authority. **Abandon uncooperative work after a grace period.** Returning idle while same-process work still runs breaks teardown and resource-ownership guarantees. Hard termination requires a worker or process isolation boundary and is outside this control seam. diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md index 6f8b83fdb4..bf410e5c72 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md @@ -20,7 +20,7 @@ AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它 对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作属于下一个轮次。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 -显式事件签名保留位置参数形式,并把 `signal` 放入 `PreStepContext`,或放在 waterfall(瀑布式事件)的最后一个参数 `next` 之前。pre-step 进入决策、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 +显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 seam 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](2026-08-06-agent-event-payload-objects.md))。pre-step 进入决策、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 `ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带规范的冻结 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。 @@ -44,7 +44,7 @@ Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时 **现在就定义推测性的 `superseded`、`timeout` 和 `shutdown` 变体。** 当前没有 Agent 取消生产方实现这些语义。`shutdown` 已经属于生命周期 dispose;超时或替代只有在拥有明确归属策略和唯一终态含义时才应进入联合类型。 -**公开轮次或步骤上下文包装类型。** 现有位置参数 seam 已经标识 Agent、轮次和步骤。包装类型会加宽所有 API、重复归属,并诱导调用方把捕获的对象当成持久权限。 +**公开轮次或步骤上下文包装类型。** 现有 seam 已经标识 Agent、轮次和步骤。包装类型会加宽所有 API、重复归属,并诱导调用方把捕获的对象当成持久权限。 **在宽限期后放弃不协作的工作。** 同进程工作仍在运行时就报告空闲状态,会破坏资源清理与资源归属保证。硬终止需要 worker 或进程隔离边界,不属于该控制 seam。 diff --git a/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.i18n.yaml new file mode 100644 index 0000000000..b6e58aabc7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.md +2026-08-06-agent-event-payload-objects.md: 470c8fb3f9282005829846307778d3d1088c3888 +2026-08-06-agent-event-payload-objects.zh.md: ff201a7c3134c0ef809c9a798d65412541f9f1e7 diff --git a/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.md b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.md new file mode 100644 index 0000000000..470c8fb3f9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.md @@ -0,0 +1,27 @@ +# Agent Note: Agent-scoped events dispatch a single payload object + +Status: implemented + +English | [中文](2026-08-06-agent-event-payload-objects.zh.md) + +## Problem + +Agent-scoped events historically took positional arguments: a leading `agent` subject, event-specific fields, and a trailing `next` for waterfall/serial events. Adding a field or retiring a context type (as with `PreStepContext` and `RequestFailureContext`) rewrote every listener and emitter across packages, and the contract stayed spread across the parameter list instead of one named payload. + +## Decision + +Every agent-scoped event takes exactly one payload object as its first argument. The payload always carries the subject (`agent`), the event's fields, and the cancellation `signal` when the event has one; `next` remains the last argument of waterfall/serial events. The affected events are the twelve `agent/*` events, `agent-loop/config-start-failed` (the only one without a subject), and `goal/changed`. + +`PreStepContext` and `RequestFailureContext` are retired; their fields live directly in the `agent/pre-step` and `agent/request-error` payloads. + +Dispatch is fused: `agentEvents(ctx, agent)` (and the one-shot `emitAgentEvent`) injects the subject so the scope carrier key and the payload's `agent` cannot diverge, and the injected subject wins even over a structurally acceptable payload that happens to carry an `agent` field. `ReactLoopAgent` builds its dispatcher once in the constructor and routes every emit, serial, and waterfall through it, so hot-path dispatches allocate nothing. + +## Alternatives considered + +**Keep positional signatures.** Adding a field or retiring a context type would keep rewriting every listener and emitter, and the contract would stay spread across the parameter list instead of one named payload. + +**Hand-build the subject at each dispatch site.** The loop's intermediate design called `ctx.waterfall(this.carrier, …)` with a manually constructed `{ agent: this, … }` payload; it avoided per-dispatch allocation but duplicated the subject injection and let the scope key and the payload subject diverge. The fused dispatcher is the single injection point for every dispatch mode. + +## Consequences + +Listener signatures name the full payload once, so extending a payload or retiring a context type is a one-shape change across all listeners and emitters. The subject/scope coupling is enforced by the dispatcher for every dispatch mode, and the loop's hot paths stay allocation-free. diff --git a/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.zh.md b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.zh.md new file mode 100644 index 0000000000..ff201a7c31 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.zh.md @@ -0,0 +1,27 @@ +# Agent Note: Agent 作用域事件 dispatch 单个 payload 对象 + +Status: implemented + +[English](2026-08-06-agent-event-payload-objects.md) | 中文 + +## 问题 + +Agent 作用域事件历来采用位置参数:开头的 `agent` 主体、事件专属字段,以及末尾用于 waterfall(瀑布式事件)/serial 事件的 `next`。新增字段或退役上下文类型(如 `PreStepContext` 与 `RequestFailureContext`)都会迫使跨包重写每个监听器和 emitter,契约也一直分散在参数列表中,而不是集中在一个具名 payload 中。 + +## 决策 + +每个 agent 作用域事件都将恰好一个 payload 对象作为其第一个参数。payload 始终携带主体(`agent`)、事件的字段,以及事件有取消信号时的取消 `signal`;`next` 仍然是 waterfall/serial 事件的最后一个参数。受影响的事件是十二个 `agent/*` 事件、`agent-loop/config-start-failed`(唯一没有主体的事件)以及 `goal/changed`。 + +`PreStepContext` 与 `RequestFailureContext` 已退役;它们的字段直接存在于 `agent/pre-step` 与 `agent/request-error` 的 payload 中。 + +dispatch 是融合的:`agentEvents(ctx, agent)`(以及一次性 `emitAgentEvent`)注入主体,使作用域载体键与 payload 的 `agent` 不可能分叉;即使某个结构上可接受的 payload 恰好携带 `agent` 字段,注入的主体仍然优先。`ReactLoopAgent` 在构造函数中构建一次 dispatcher,并将每个 emit、serial 和 waterfall 都经由它路由,因此热路径上的 dispatch 不产生任何分配。 + +## 考虑过的替代方案 + +**保留位置签名。** 新增字段或退役上下文类型依旧会重写每个监听器和 emitter,契约也会继续分散在参数列表中,而不是集中在一个具名 payload 中。 + +**在每个 dispatch 位置手工构造主体。** loop 的中间设计调用 `ctx.waterfall(this.carrier, …)`,传入手工构造的 `{ agent: this, … }` payload;它避免了每次 dispatch 的分配,却重复了主体注入,并让作用域键与 payload 主体分叉。融合的 dispatcher 是每种 dispatch 模式的唯一注入点。 + +## 后果 + +监听器签名一次性命名完整 payload,因此扩展 payload 或退役上下文类型,对所有监听器和 emitter 都是一次形状变更。主体/作用域耦合由 dispatcher 在每种 dispatch 模式下强制执行,且 loop 的热路径保持零分配。 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml index d337e7e0e3..c981d84400 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md -2026-06-18-compaction-capability-seam.md: 27dbde9f2349681cf47c4d25b16399b26ed9e1ca -2026-06-18-compaction-capability-seam.zh.md: 1fe9ece2861bd6d75633a866a4a11eaadbf7ef26 +2026-06-18-compaction-capability-seam.md: 26e6e2468c7bea661d85c8fb994adf8b109105ee +2026-06-18-compaction-capability-seam.zh.md: 8f9cd1f6bc31e648a5b923e816cad75ebc1a0bd8 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md index 27dbde9f23..26e6e2468c 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -119,7 +119,7 @@ The lifecycle boundary makes crash state unambiguous: ## Consequences - **Packages**: `packages/compact/compact` supplies the interface, `compact-basic` supplies the backend, `compact-tool-result-prune` supplies optional deterministic rewriting, and `command-compact` supplies human `/compact`. `packages/llm/token-meter` owns replay-aware measurement independently. -- **Automatic seams**: `agent/pre-step` (`@mode waterfall`) handles pressure before request derivation and `agent/request-error` (`@mode waterfall`) handles final request failures after the failed step closes. Pre-step receives the claimed batch and `PreStepContext`, with no compaction-only prompt/prefix payload. +- **Automatic seams**: `agent/pre-step` (`@mode waterfall`) handles pressure before request derivation and `agent/request-error` (`@mode waterfall`) handles final request failures after the failed step closes. The pre-step payload carries the claimed batch, turn, step, and signal (see the [payload-object events decision](../architecture/2026-08-06-agent-event-payload-objects.md)), with no compaction-only prompt/prefix payload. - **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry. - **`dsh-compact`** owns `COMPACT_CHECKPOINT_SOURCE`, `isCompactCheckpointSource(source)`, `toolPairingBalancedBefore(session, seq)`, and `toolPairingBalancedAfter(session, seq)`. The marker identifies replacement summaries across backend implementations. The cached surface-edge checks prevent `compactRegion` and `compactIfNeeded` from splitting a tool-call/result pair, validate current membership by seq, answer both edges from one per-cut balance sequence, and reject stale or missing seqs and orphan results. - **`dsh-session`** validates positional replacement, complete provenance, and content-only single-node `tool/result` rewrites through its one surface manager. Its invariant companion treats fresh appended tool results as executions that require an open step and pending call, while the compaction companion owns numeric-turn versus standalone-null bracket relations. diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md index 1fe9ece286..8f9cd1f6bc 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md @@ -119,7 +119,7 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab ## 后果 - **包**:`packages/compact/compact` 提供接口,`compact-basic` 提供后端,`compact-tool-result-prune` 提供可选的确定性重写,`command-compact` 提供面向用户的 `/compact`。`packages/llm/token-meter` 独立拥有回放感知的测量。 -- **自动 seam**:`agent/pre-step`(`@mode waterfall`)在请求派生前处理压力,`agent/request-error`(`@mode waterfall`)处理失败步骤关闭后的最终请求失败。pre-step 接收已领取批次与 `PreStepContext`,不携带压缩专属的提示词/前缀 payload。 +- **自动 seam**:`agent/pre-step`(`@mode waterfall`)在请求派生前处理压力,`agent/request-error`(`@mode waterfall`)处理失败步骤关闭后的最终请求失败。pre-step 的 payload 携带已领取批次、轮次、步骤与 signal(参见 [payload-object 事件决策](../architecture/2026-08-06-agent-event-payload-objects.md)),不携带压缩专属的提示词/前缀 payload。 - **`SessionEventMap`** 通过可合并扩展的声明合并获得 `compact/start` / `compact/summary` / `compact/end`;`SurfaceEventType` **未被**触及。这些是会话事件,不是 cordis `Events`,因此事件分类门禁无需新增条目。 - **`dsh-compact`** 拥有 `COMPACT_CHECKPOINT_SOURCE`、`isCompactCheckpointSource(source)`、`toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`。该标记用于跨后端实现识别替换摘要。带缓存的 surface 边缘检查会防止 `compactRegion` 和 `compactIfNeeded` 拆分工具调用/结果对,按 seq 校验当前成员关系,从每个切割点的一条平衡序列回答两侧边缘,并拒绝陈旧或缺失的 seq 与孤立结果。 - **`dsh-session`** 通过唯一的 surface 管理器校验位置替换、完整溯源信息和仅内容的单节点 `tool/result` 重写。其不变式配套插件将新追加的工具结果视为执行,要求存在已打开的步骤与待处理调用,而压缩配套组件拥有数字轮次归属与独立 `null` 归属标记对之间的关系。 diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml index 604255dee5..3be447fe2f 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-06-30-interception-seams.md -2026-06-30-interception-seams.md: 629a1aed509bd9bce9a2da89ce84b17a1db8e6b6 -2026-06-30-interception-seams.zh.md: d6958c9d1e7a8af8fa06d859d1905719a19cd43d +2026-06-30-interception-seams.md: c318e41cfb1d64230b6151f1febad85d75b1451d +2026-06-30-interception-seams.zh.md: 1b274fae4bc7fde326dbb0eeec54d57f73987803 diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md b/.agents/notes/implemented/feature/2026-06-30-interception-seams.md index 629a1aed50..c318e41cfb 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.md @@ -15,8 +15,8 @@ The surface needs distinct contracts for per-prompt policy (CC's `UserPromptSubm The canonical surface separates transformable policy, around-dispatch control, and observe-only notification. Policy waterfalls return small seam-specific **typed Decision unions**; wrappers return normalized results; notifications receive immutable snapshots and cannot affect the outcome. The set covers the hook points in scope (`session-start`, `prompt-submit`, `pre-tool`, `post-tool`, `stop`-via-continuation) while leaving non-hook execution policy independently composable. **Agent events** (`dsh-agent`): -- `agent/session-start(agent, source)` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`. -- `agent/pre-step(agent, messages, context, next) → PreStepDecision` — waterfall, fired before every proposed step after the loop has atomically removed its exclusive inbox batch. `PreStepContext` carries that request's `turn`, `step`, and cancellation `signal`; `messages` is empty for a tool continuation with no intervening input. `enter` returns the complete message batch, including any current-request context a listener contributes; `reject` opens no step and leaves the claimed messages removed. +- `agent/session-start({ agent, source })` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`. +- `agent/pre-step({ agent, messages, turn, step, signal }, next) → PreStepDecision` — waterfall, fired before every proposed step after the loop has atomically removed its exclusive inbox batch. The payload carries the request's `turn`, `step`, and cancellation `signal` (the retired `PreStepContext` fields live in the payload; see the [payload-object events decision](../architecture/2026-08-06-agent-event-payload-objects.md)); `messages` is empty for a tool continuation with no intervening input. `enter` returns the complete message batch, including any current-request context a listener contributes; `reject` opens no step and leaves the claimed messages removed. **`agent/turn-stopping`** is an awaited notification at the natural stop boundary. A listener that needs another step calls `agent.steer()` with explicitly sourced model-facing content; the loop then re-reads the outbox and either continues or closes the turn. diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md b/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md index d6958c9d1e..1b274fae4b 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md @@ -15,8 +15,8 @@ harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那 规范表面将可变换策略、环绕调度控制与仅观测通知分离。策略 waterfall(瀑布式事件)返回小型的、seam 专属的**类型化 Decision 联合类型**;包装层返回规范化结果;通知接收不可变快照,无法影响结果。覆盖的钩子点包括 `session-start`、`prompt-submit`、`pre-tool`、`post-tool`、通过 continuation 实现的 `stop`,同时将非钩子的执行策略留作独立可组合。 **Agent 事件**(`dsh-agent`): -- `agent/session-start(agent, source)` ——emit,在第 1 轮次之前触发一次,携带 `SessionStartSource`(`startup` 表示全新/fork 创建,`resume` 表示重新加载的持久化会话;`clear`/`compact` 保留)。纯通知,不能阻塞启动(这是有意的空白:桥接可以记录/注入,但不管控启动)。监听器通过 `agent.inject()` 注入上下文。 -- `agent/pre-step(agent, messages, context, next) → PreStepDecision` ——waterfall,在每个拟议步骤之前、循环原子移除其独占 inbox 批次后触发。`PreStepContext` 携带该请求的 `turn`、`step` 与取消 `signal`;没有中途输入的工具续步会收到空批次。`enter` 返回完整消息批次,其中包括监听器为当前请求贡献的上下文;`reject` 不打开步骤,并让已领取消息保持已删除。 +- `agent/session-start({ agent, source })` ——emit,在第 1 轮次之前触发一次,携带 `SessionStartSource`(`startup` 表示全新/fork 创建,`resume` 表示重新加载的持久化会话;`clear`/`compact` 保留)。纯通知,不能阻塞启动(这是有意的空白:桥接可以记录/注入,但不管控启动)。监听器通过 `agent.inject()` 注入上下文。 +- `agent/pre-step({ agent, messages, turn, step, signal }, next) → PreStepDecision` ——waterfall,在每个拟议步骤之前、循环原子移除其独占 inbox 批次后触发。payload 携带该请求的 `turn`、`step` 与取消 `signal`(已退役的 `PreStepContext` 字段位于 payload 中;参见 [payload-object 事件决策](../architecture/2026-08-06-agent-event-payload-objects.md));没有中途输入的工具续步会收到空批次。`enter` 返回完整消息批次,其中包括监听器为当前请求贡献的上下文;`reject` 不打开步骤,并让已领取消息保持已删除。 **`agent/turn-stopping`** 是自然停止边界上的一次 awaited 通知。需要再执行一步的监听器调用 `agent.steer()`,传入来源显式的 steering(中途引导)内容供模型使用;循环随后重新读取 outbox,继续执行或关闭轮次。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index b1d4bae895..0459323eea 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -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 docs/architecture.md -architecture.md: 9b84c1482cb379fd796e21db45f128ba49750c54 -architecture.zh.md: 84708fcae24623e50b0157782cf459c35a55844b +architecture.md: 40c20a1c9eeabe5ecbbc6edacde81c20071b8a04 +architecture.zh.md: 6fddaa883775cf8345aba01af52575c0f0e1aaa0 diff --git a/docs/architecture.md b/docs/architecture.md index 9b84c1482c..40c20a1c9e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -83,7 +83,7 @@ forever: -> 'turn/start' claim next-step input plus one next-turn message -> emit agent/inbox/claimed({ message, turn }) for each claimed message - -> agent/pre-step(messages, { turn, step, signal }) + -> agent/pre-step({ agent, messages, turn, step, signal }) reject, empty input, cancellation, or listener failure -> the claimed batch stays removed; close the no-step turn; stop the driver enter -> step loop: @@ -112,7 +112,7 @@ idle inject: Each step assembles ordered prompt sections, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona; the loop supplies `provider`, `model`, and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). -`inject()` queues non-waking `next-step` context; an idle driver leaves it pending until `followup()` or `steer()` wakes the driver. Post-tool `additionalContexts` use the same inbox. `agent/pre-step` receives the exclusive claimed batch and upcoming turn, step, and signal. Reject opens no step; enter supplies the complete batch appended after `step/start`. Empty tool continuations still traverse the waterfall, whose final value settles all rewrites. +`inject()` queues non-waking `next-step` context; an idle driver leaves it pending until `followup()` or `steer()` wakes the driver. Post-tool `additionalContexts` use the same inbox. The `agent/pre-step` payload carries the exclusive claimed batch and the upcoming turn, step, and signal. Reject opens no step; enter supplies the complete batch appended after `step/start`. Empty tool continuations still traverse the waterfall, whose final value settles all rewrites. Pruning precedes summaries; overflow retries require durable progress. `agent/request-error` may authorize a same-step retry of the frozen prompt; cancellation wins. Adapter `retryPolicy` bounds normal mode, while always mode retries after specialized recovery ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry foundation](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md), [provider policy](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md)). The generated [agent lifecycle](agent-lifecycle.md) owns exact event order, and the [agent-loop README](../packages/core/agent-loop/README.md) owns queue, steering, retry, and cancellation mechanics. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 84708fcae2..6fddaa8837 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -83,7 +83,7 @@ forever: -> 'turn/start' claim next-step input plus one next-turn message -> emit agent/inbox/claimed({ message, turn }) for each claimed message - -> agent/pre-step(messages, { turn, step, signal }) + -> agent/pre-step({ agent, messages, turn, step, signal }) reject, empty input, cancellation, or listener failure -> the claimed batch stays removed; close the no-step turn; stop the driver enter -> step loop: @@ -112,7 +112,7 @@ idle inject: 每个步骤都会组装有序的提示词片段、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定;循环提供 `provider`、`model` 和 `cwd`([提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。 -`inject()` 将不会唤醒驱动器的上下文排入 `next-step`;空闲驱动器会让它保持待处理,直至 `followup()` 或 `steer()` 唤醒。工具执行后的 `additionalContexts` 使用同一个 inbox。`agent/pre-step` 接收独占的已领取批次,以及即将使用的轮次、步骤和信号。拒绝则不进入步骤;进入则提供在 `step/start` 后追加的完整批次。空的工具续跑仍会经过 waterfall,其最终值一次性结算所有改写。 +`inject()` 将不会唤醒驱动器的上下文排入 `next-step`;空闲驱动器会让它保持待处理,直至 `followup()` 或 `steer()` 唤醒。工具执行后的 `additionalContexts` 使用同一个 inbox。`agent/pre-step` 的 payload 携带独占的已领取批次,以及即将使用的轮次、步骤和信号。拒绝则不进入步骤;进入则提供在 `step/start` 后追加的完整批次。空的工具续跑仍会经过 waterfall,其最终值一次性结算所有改写。 裁剪先于摘要;溢出重试必须取得持久进展。`agent/request-error` 可以授权使用冻结提示词进行同步骤重试;取消优先。适配器的 `retryPolicy` 使 normal mode 保持有界,always mode 则在专门恢复后重试([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试基础](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)、[提供方策略](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md))。精确事件顺序由生成的 [agent 生命周期](agent-lifecycle.md)定义;队列、steering、重试与取消机制由 [agent-loop README](../packages/core/agent-loop/README.md)定义。 diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 8f6a1e829f..a702d709f1 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -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 docs/core-data-structures/core.md -core.md: 499f20b430854dd9a3c614604c7275502d95c40a -core.zh.md: 4b3a1381f95d229f4b7fc3465abfce57288f5276 +core.md: 7bba3dcc6b3f73c46c485a6a6d10fcf84bc9347e +core.zh.md: 9604dbff540c83004a92abdef9f082e73351cb98 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 0cd5907f5f..82066a2c99 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -10,15 +10,15 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:182`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:154`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:163`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/types.ts:192`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/types.ts:200`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/types.ts:181`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/types.ts:192`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/types.ts:200`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/types.ts:181`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `agent/pre-step` | `waterfall` | [`packages/core/agent/src/types.ts:226`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:173`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`jsonrpc`](../packages/ui/jsonrpc) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:173`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`jsonrpc`](../packages/ui/jsonrpc) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:273`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy` | diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index f1cc07705d..cfac8262c2 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -7,6 +7,7 @@ import type { Agent, AgentCancelCause, + AgentEventDispatch, AgentOptions, AgentStatus, CancelOptions, @@ -14,7 +15,7 @@ import type { PreStepDecision, RequestErrorAction, } from '@deepseek-ai/dsh-agent' -import { Inbox, agentCarrier, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent' +import { Inbox, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm' import { BlockAssembler, @@ -24,7 +25,7 @@ import { errorChain, markAgentLoopRequest, } from '@deepseek-ai/dsh-llm' -import type { Scope, Scoped } from '@deepseek-ai/dsh-scope' +import type { Scope } from '@deepseek-ai/dsh-scope' import { createScope } from '@deepseek-ai/dsh-scope' import type { EpochHeader, RequestContext, Session, SessionId, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session' import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session' @@ -69,8 +70,8 @@ export class ReactLoopAgent implements Agent { readonly scope: Scope readonly ctx: Context - /** Fused scope carrier, built once in the constructor for every dispatch. */ - readonly carrier: Scoped + /** Fused dispatcher, built once in the constructor so hot-path dispatches never allocate. */ + private readonly dispatch: AgentEventDispatch /** Whether this loop instance has appended its initial/resume request anchor. */ private requestHeaderLogged = false @@ -82,11 +83,11 @@ export class ReactLoopAgent implements Agent { public readonly options: AgentOptions, public readonly session: Session, ) { - this.carrier = agentCarrier(this) + this.dispatch = agentEvents(loopCtx, this) this.inbox = new Inbox(session, { - inserted: (message) => { emitAgentEvent(loopCtx, this, 'agent/inbox/inserted', { message }) }, - discarded: (message) => { emitAgentEvent(loopCtx, this, 'agent/inbox/discarded', { message }) }, - claimed: (message, turn) => { emitAgentEvent(loopCtx, this, 'agent/inbox/claimed', { message, turn }) }, + inserted: (message) => { this.dispatch.emit('agent/inbox/inserted', { message }) }, + discarded: (message) => { this.dispatch.emit('agent/inbox/discarded', { message }) }, + claimed: (message, turn) => { this.dispatch.emit('agent/inbox/claimed', { message, turn }) }, }) const lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0 this.phase = { kind: 'idle', lastTurn } @@ -105,7 +106,7 @@ export class ReactLoopAgent implements Agent { this.phase = next const status = this.status if (status !== previousStatus) { - emitAgentEvent(this.loopCtx, this, 'agent/status', { status }) + this.dispatch.emit('agent/status', { status }) } } @@ -183,7 +184,7 @@ export class ReactLoopAgent implements Agent { private throwError(error: unknown): never { const turn = this.phase.kind === 'running' ? this.phase.turn : this.phase.lastTurn const step = this.phase.kind === 'running' ? this.phase.step : 0 - emitAgentEvent(this.loopCtx, this, 'agent/error', { turn, step, error }) + this.dispatch.emit('agent/error', { turn, step, error }) throw error } @@ -209,8 +210,8 @@ export class ReactLoopAgent implements Agent { signal.throwIfAborted() const sections = renderContextSections(assembly) const context = this.runtimeContext.project(joinContextSections(sections), sections) - const decision = await this.loopCtx.waterfall( - this.carrier, 'agent/pre-step', { agent: this, messages: claimed, ...position, signal }, + const decision = await this.dispatch.waterfall( + 'agent/pre-step', { messages: claimed, ...position, signal }, (): Promise => Promise.resolve({ kind: 'enter', messages: context === undefined ? claimed : [...claimed, context], @@ -271,7 +272,7 @@ export class ReactLoopAgent implements Agent { } signal.throwIfAborted() if (turnEnds && this.inbox.nextStep.length === 0) { - await this.loopCtx.serial(this.carrier, 'agent/turn-stopping', { agent: this, turn, signal }) + await this.dispatch.serial('agent/turn-stopping', { turn, signal }) signal.throwIfAborted() } if (turnEnds && this.inbox.nextStep.length === 0) break @@ -328,9 +329,8 @@ export class ReactLoopAgent implements Agent { signal.throwIfAborted() const finish = assembler.finish if (finish.kind === 'error' || finish.kind === 'aborted') { - const action = await this.loopCtx.waterfall( - this.carrier, 'agent/request-error', { - agent: this, + const action = await this.dispatch.waterfall( + 'agent/request-error', { turn, step, provider: request.provider, @@ -412,8 +412,8 @@ export class ReactLoopAgent implements Agent { ...maxTokens === undefined ? {} : { maxTokens }, }, )) - const proposedConfig = await this.loopCtx.waterfall( - this.carrier, 'agent/request', { agent: this, turn, step, signal }, + const proposedConfig = await this.dispatch.waterfall( + 'agent/request', { turn, step, signal }, () => Promise.resolve(seedConfig), ) signal.throwIfAborted() diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 4cfc5328e8..5c03669baf 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -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/core/agent/README.md -README.md: c3d6e6c24480894b6059417c1ab89db7aa0d7fa2 -README.zh.md: 16ee8f5e6c483555839b0c3ab174e2e2356b1359 +README.md: 2a69ab380eaad3929e27039582807037969eba64 +README.zh.md: 176f3f75cf0f6e3309b2f5d34afb4d562105608e diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index c3d6e6c244..2a69ab380e 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -50,7 +50,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves. -Most interception points are cooperative waterfalls. `agent/pre-step` receives the exclusive claimed `UserMessage[]` plus a `PreStepContext` containing the proposed `turn`, `step`, and cancellation `signal`; its batch may be empty when tools already require another request. Other turn-scoped asynchronous seams receive their explicit `AbortSignal` positionally. Listeners may cooperate with a signal but must not retain it as authority over another turn. `agent/request-error` is the failed-model-request recovery waterfall: it receives request coordinates, normalized failure facts, the serving registration's retry policy when available, and the signal. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery. `agent/turn-stopping` runs before an otherwise completed turn closes. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. +Most interception points are cooperative waterfalls. `agent/pre-step` receives a payload carrying the subject `agent`, the exclusive claimed `UserMessage[]`, and the proposed `turn`, `step`, and cancellation `signal`; its batch may be empty when tools already require another request. Agent-scoped turn seams carry their explicit `AbortSignal` in the payload; the remaining turn-scoped seams receive it through their request value. Listeners may cooperate with a signal but must not retain it as authority over another turn. `agent/request-error` is the failed-model-request recovery waterfall: it receives request coordinates, normalized failure facts, the serving registration's retry policy when available, and the signal. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery. `agent/turn-stopping` runs before an otherwise completed turn closes. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. `PreStepDecision` is either `{ kind: 'reject' }` or `{ kind: 'enter', messages }`. The enter branch is the complete identified, frozen batch for the proposed step. A listener that wraps downstream entry preserves that batch unless it intentionally replaces it; additions follow the waterfall's natural return order. Claiming already removed the offered messages from the inbox, so rejection does not retain them. Messages inserted after the claim remain pending for a later boundary. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 16ee8f5e6c..176f3f75cf 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -50,7 +50,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, 生命周期边有两个重要的本地注意事项。`agent/created` 在作用域 setup 之后、会话与 agent 注册表条目都存在之后运行。Setup 是受信任、仅用于组合的代码;紧随其后且不可 veto 的 `agent/session-start` 通知是第一个受支持的启动注入点。`agent/disposed` 始终表示确切 agent 已离开注册表。AgentLoop 在其驱动器完全停稳后发出该事件,而有序 teardown 此时可能仍在分离会话并撤销作用域;直接注册的自定义 agent 自行拥有任何更强的驱动器顺序契约。 -大多数拦截点都是协作式 waterfall(瀑布式事件)。`agent/pre-step` 接收独占的已领取 `UserMessage[]`,以及包含拟进入 `turn`、`step` 与取消 `signal` 的 `PreStepContext`;当工具已经要求继续请求时,该批次可以为空。其他轮次作用域异步 seam 仍按位置接收显式 `AbortSignal`。监听器可以配合信号,但不得将它保留为控制另一轮次的权限。`agent/request-error` 是失败模型请求的恢复 waterfall:它接收请求坐标、规范化失败事实、可用时提供服务的注册项重试策略以及信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`。`agent/turn-stopping` 在本可完成的轮次关闭前运行。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。 +大多数拦截点都是协作式 waterfall(瀑布式事件)。`agent/pre-step` 接收一个 payload,携带主体 `agent`、独占的已领取 `UserMessage[]` 以及拟进入的 `turn`、`step` 与取消 `signal`;当工具已经要求继续请求时,该批次可以为空。agent 作用域轮次 seam 在 payload 中携带显式 `AbortSignal`;其余轮次作用域 seam 通过其请求值接收它。监听器可以配合信号,但不得将它保留为控制另一轮次的权限。`agent/request-error` 是失败模型请求的恢复 waterfall:它接收请求坐标、规范化失败事实、可用时提供服务的注册项重试策略以及信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`。`agent/turn-stopping` 在本可完成的轮次关闭前运行。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。 `PreStepDecision` 要么是 `{ kind: 'reject' }`,要么是 `{ kind: 'enter', messages }`。enter 分支是拟进入步骤的完整、带标识且冻结的批次。包装下游 enter 的监听器会保留该批次,除非有意替换它;新增消息遵循 waterfall 的自然返回顺序。领取操作已经把候选消息从 inbox 删除,因此 reject 不会保留它们;领取后插入的消息仍等待后续边界。 diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index 925d46796c..cf07f24ecf 100644 --- a/packages/core/agent/src/dispatch.ts +++ b/packages/core/agent/src/dispatch.ts @@ -1,7 +1,8 @@ /** - * Agent-scoped dispatch and prompt assembly helpers. Ordinary events use the - * fused dispatcher so subject and scope key cannot diverge; registry lifecycle - * code instead captures one stable carrier for both edges. + * Agent-scoped dispatch and prompt assembly helpers. The fused dispatcher + * {@link agentEvents} couples the agent subject to its scope carrier, so the + * scope key and the payload's `agent` cannot diverge; repeat dispatchers (the + * loop driver) build it once in the agent's constructor and reuse it. * @module @deepseek-ai/dsh-agent/dispatch */ @@ -83,9 +84,10 @@ export interface AgentEventDispatch { /** * Build the fused scope carrier for one agent subject. * - * The carrier is a stateless routing object; callers that dispatch repeatedly - * for the same agent (the loop driver) build it once in the agent's - * constructor and reuse it, so hot-path dispatches never allocate. + * The carrier is a stateless routing object. {@link agentEvents} accepts an + * existing carrier, so callers that dispatch repeatedly for the same agent + * (the loop driver) build it once in the agent's constructor and reuse it, + * keeping hot-path dispatches allocation-free. * @param agent - the subject agent and scope key. * @returns the carrier passed as the event dispatcher `this` value. */ @@ -97,10 +99,12 @@ export function agentCarrier(agent: Agent): Scoped { * Build a dispatcher that couples the agent subject to its scope carrier. * @param ctx - the context to dispatch through (any context of the app). * @param agent - the subject agent; also the scope-carrier key. + * @param carrier - the scope carrier to dispatch through; defaults to + * {@link agentCarrier} for the agent. Pass a constructor-built carrier to + * avoid rebuilding it for every dispatch. * @returns the fused dispatcher. */ -export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { - const carrier = agentCarrier(agent) +export function agentEvents(ctx: Context, agent: Agent, carrier: Scoped = agentCarrier(agent)): AgentEventDispatch { // The ordinary dispatch methods forward through Cordis' variadic mixins. The // fused (carrier, name, payload, ...rest) tuple is provably a valid argument // list for the matching thisArg overload, but TypeScript cannot relate the @@ -108,8 +112,10 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { // tuple — hence one contained, shape-preserving cast per method. const fused = (payload: PayloadRest): PayloadOf => // The dispatcher owns the subject injection; callers pass PayloadRest, so - // the fused record is exactly the declared payload. - ({ agent, ...payload } as PayloadOf) + // the fused record is exactly the declared payload. The spread comes + // first, so a structurally acceptable payload that happens to carry an + // `agent` field can never override the injected subject. + ({ ...payload, agent } as PayloadOf) return { emit(name, payload) { // Cordis emit invokes callbacks through Array.map: one synchronous throw diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index cf8248a1c7..e80d575aeb 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -11,6 +11,7 @@ import type { Agent, AgentCancelCause, AgentFactory, + AgentStatus, CreateAgentOptions, ResumeAgentOptions, } from '@deepseek-ai/dsh-agent' @@ -305,6 +306,21 @@ describe('agentEvents()', () => { expect(heard).toEqual([{ agent, turn: 3, signal }]) }) + + it('injects the fused subject even when the payload carries a conflicting agent field', async () => { + const ctx = new Context() + const agent = stubAgent('fused-subject') + const other = stubAgent('payload-agent') + const heard: Agent[] = [] + ctx.on('agent/status', ({ agent: subject }) => void heard.push(subject)) + // A structurally acceptable payload may carry an extra `agent` field; the + // dispatcher's injected subject must win over it. + const payload: { status: AgentStatus; agent: Agent } = { status: 'running', agent: other } + + agentEvents(ctx, agent).emit('agent/status', payload) + + expect(heard).toEqual([agent]) + }) }) describe('explicit cancellation contract', () => { From ba2d1532685ad22dfa8e602192b938bf899f3477 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 6 Aug 2026 13:56:38 +0800 Subject: [PATCH 08/16] test(web): refresh two stale markdown aria goldens The CJK-strong and inline-code-link goldens predate the flanking-space footer separators and drifted on the master merge; re-record them with the accessible space, matching every other golden. --- apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md | 2 +- .../tests/snapshots/markdown-inline-code-links/ui.expected.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md index 68a4df5603..187ab25e8c 100644 --- a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md @@ -40,7 +40,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}}Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} - textbox "Message the agent" - button "Commands": - img diff --git a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md index 059849223c..19efa06238 100644 --- a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md @@ -31,7 +31,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}}Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} - textbox "Message the agent" - button "Commands": - img From cdf4a18b6846e6a64fa74f004caee6400d11bf6c Mon Sep 17 00:00:00 2001 From: GeeeekExplorer <2651904866@qq.com> Date: Thu, 6 Aug 2026 14:10:50 +0800 Subject: [PATCH 09/16] test(web): align stale markdown goldens with the stats-line clock spacing The two CJK/inline-code markdown goldens recorded the stats line without the space after the clock token ({{clock}}Ran for), while every other golden and the current rendering emit {{clock}} Ran for. The mismatch surfaced on the merge tree as the only diff in the web browser snapshot lane; align the two stragglers with the rest. --- apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md | 2 +- .../tests/snapshots/markdown-inline-code-links/ui.expected.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md index 68a4df5603..187ab25e8c 100644 --- a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md @@ -40,7 +40,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}}Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} - textbox "Message the agent" - button "Commands": - img diff --git a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md index 059849223c..19efa06238 100644 --- a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md @@ -31,7 +31,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}}Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} - textbox "Message the agent" - button "Commands": - img From c1364a2f253aad359f8c64b98a48e383956ddb21 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:48:09 +0800 Subject: [PATCH 10/16] doc(web): agent note for the shell dist chunk split and directory layout --- ...8-06-web-shell-dist-chunk-layout.i18n.yaml | 6 +++ .../2026-08-06-web-shell-dist-chunk-layout.md | 48 +++++++++++++++++++ ...26-08-06-web-shell-dist-chunk-layout.zh.md | 48 +++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 .agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.md create mode 100644 .agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.i18n.yaml new file mode 100644 index 0000000000..0dc618924a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.md +2026-08-06-web-shell-dist-chunk-layout.md: bce46591d65bae2daa61b1d513b4bdf37a9fad20 +2026-08-06-web-shell-dist-chunk-layout.zh.md: 595338ddec4ff5a9926dafcf0b1181241dc51788 diff --git a/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.md b/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.md new file mode 100644 index 0000000000..bce46591d6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.md @@ -0,0 +1,48 @@ +# Agent Note: Web shell dist chunk split and directory layout + +Status: implemented + +English | [中文](2026-08-06-web-shell-dist-chunk-layout.zh.md) + +## Problem + +The apps/web shell previously built into a single ~1.2 MB (minified) index chunk, roughly 80% of it vendor bytes — KaTeX, the boot grammars and the shiki engine, react-dom, the markdown pipeline — fused with all the workspace shell code (about one fifth). Any one-line shell change rehashed the whole chunk, forcing returning clients to redownload everything; `dist/assets/` was a flat single-level spread of 100-plus files (the main chunk, 23 lazy-loaded grammar chunks, 59 KaTeX font faces, and sourcemaps intermixed), impossible to navigate. + +## Decision + +`apps/web/vite.config.ts` splits the shell into two initial chunks via `manualChunks` and sorts the output into directories via naming functions; the entire configuration contains zero regexes — an exact-package-name Set, a filename list, an extension list. + +**Membership** (`VENDOR_PACKAGES`, by exact npm package name): + +- `vendor` = the **facade packages** of the three heavy rendering families: math (katex, rehype-katex), highlight (shiki), markdown (react-markdown, remark-gfm, remark-math, mdast-util-from-markdown, mdast-util-gfm, micromark-extension-gfm, micromark-extension-math, micromark-factory-space, micromark-util-character, micromark-util-symbol, micromark-util-types). The list only needs the packages that workspace code **imports directly**: private transitive dependencies (the unified/hast family, the oniguruma family, @shikijs/core, and dozens more) are referenced only by these facades, so rollup's chunk coloring pulls them into vendor automatically; dependencies shared with the index side fall back to index, diluting it by a few KB — not a correctness issue. +- `index` (the default chunk) = the react family, vendored cordis, all workspace code, and the unlisted small pieces (anser, clsx). +- `@shikijs/langs` is special-cased: the boot grammars (`BOOT_GRAMMAR_FILES`: typescript, shellscript, json — the three that highlight.ts statically imports, all self-contained data modules with zero internal imports) go into vendor; the remaining 23 lazy-loaded grammars get no assignment and each keeps its own on-demand chunk. +- `index.html` is wired up automatically by vite: index loads via `