refactor agent pre-step inbox lifecycle

This commit is contained in:
_Kerman
2026-07-31 19:21:16 +08:00
parent c2ff9ddec8
commit fcc2b5e282
267 changed files with 2052 additions and 1546 deletions

View File

@@ -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/context/tmux-context/README.md
README.md: a166a46d20f472cb5d8f045e2456ce3e6de7a2f2
README.zh.md: 0575d549e352239e7d954870eaf40beea1169cc6
README.md: 9e1afac16ecff0a026d9c20cba2e40ee5fad6792
README.zh.md: d1ebdc140b0b71ed344ec918993d60e2510780e8

View File

@@ -17,7 +17,7 @@ Opt-in durable context naming the tmux session, window, and pane this agent proc
## How it reads tmux
The plugin prepends an `agent/step` listener that runs only on the first step of each turn. When due, it runs one read-only command through the `ctx.bash` executor seam:
The plugin prepends an `agent/pre-step` listener that runs only on the first step of each turn. When due, it runs one read-only command through the `ctx.bash` executor seam:
```sh
[ -n "$TMUX_PANE" ] || exit 1
@@ -33,7 +33,7 @@ State is pulled on every eligible turn — a moved, renamed, or re-laid-out pane
## Timing semantics
When an injection is due, the plugin appends one injected `user/message` through `agent.inject()` before `step/start`, with source `{ kind: 'plugin', plugin: 'tmux-context' }`. Change suppression and interval scheduling scan the raw durable session events for the latest injection of this source, so the schedule survives compaction and resumed processes without process-local cache state; sessions schedule independently. The reading records a request-preparation attempt, not a committed step; because the listener runs first, its append may remain when a later pre-step listener cancels or fails the attempt (the log is append-only and the plugin performs no rollback).
The plugin prepends an `agent/pre-step` listener. When an injection is due and the downstream decision enters the proposed step, it prepends one sourced `UserMessage` to the returned batch. AgentLoop records that context after `step/start` with source `{ kind: 'plugin', plugin: 'tmux-context' }`. Change suppression and interval scheduling scan the raw durable session events for the latest injection of this source, so the schedule survives compaction and resumed processes without process-local cache state; sessions schedule independently. A downstream pre-step listener that rejects or fails prevents the reading from being recorded.
## Model Experience

View File

@@ -17,7 +17,7 @@
## 如何读取 tmux
插件前置注册一个 `agent/step` 监听器,仅在每轮的第一个 step 运行。当需要注入时,它通过 `ctx.bash` 执行器 seam 运行一条只读命令:
插件前置注册一个 `agent/pre-step` 监听器,仅在每轮的第一个 step 运行。当需要注入时,它通过 `ctx.bash` 执行器 seam 运行一条只读命令:
```sh
[ -n "$TMUX_PANE" ] || exit 1
@@ -33,7 +33,7 @@ exec tmux display-message -t "$TMUX_PANE" -p '<format>'
## 时序语义
当需要注入时,插件在 `step/start` 之前通过 `agent.inject()` 追加一条注入`user/message`来源为 `{ kind: 'plugin', plugin: 'tmux-context' }`。变化抑制与间隔调度会扫描原始持久会话事件中该来源的最近一次注入,因此调度可跨压缩与恢复的进程存续,无需进程内缓存状态;各会话独立调度。该读数记录的是一次请求准备尝试,而非已提交的 step由于监听器最先运行当后续 pre-step 监听器取消或失败时,它的追加可能仍会保留(日志只追加,插件不做回滚)
该插件会前置一个 `agent/pre-step` 监听器。需要注入且下游决策进入拟议步骤时,它会在返回批次前添加一条带来源`UserMessage`。AgentLoop 会在 `step/start` 之后记录该上下文,其来源为 `{ kind: 'plugin', plugin: 'tmux-context' }`。变化抑制与间隔调度会扫描原始持久会话事件中该来源的最近一次注入,因此调度可跨压缩与恢复的进程存续,无需进程内缓存状态;各会话独立调度。下游 pre-step 监听器 reject 或失败时,该读数不会被记录
## 模型体验

View File

@@ -3,7 +3,7 @@
* append durable, source-attributed context naming the tmux session, window,
* and pane this agent process runs in, plus the window's pane-tree layout.
*
* The plugin pulls state once per turn, on the first step (`step === 1`), by
* The plugin pulls state once per turn, for the first request (`step === 1`), by
* running one `tmux display-message` through the `ctx.bash` executor seam. It
* confirms this process genuinely runs inside the pane `$TMUX_PANE` names by
* matching the pane's `#{pane_tty}` against this process's controlling terminal,
@@ -20,14 +20,14 @@
import type { Context, LoggerService } from 'cordis'
import z from 'schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import type { BashExecutor, BashRunResult } from '@deepseek-ai/dsh-bash'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'tmux-context'
/** The agent registry that owns the `agent/step` lifecycle seam. */
/** The agent registry that owns pre-step processing. */
export const inject = ['agents']
/** Per-turn tmux-location scheduling. Invalid values fail plugin load. */
@@ -206,7 +206,7 @@ function validateRefreshInterval(refreshIntervalMs: number | undefined): void {
}
/**
* Register a prepended `agent/step` listener for the lifetime of `ctx`.
* Register a prepended pre-step listener for the lifetime of `ctx`.
* @param ctx - plugin context; the listener is disposed with it.
* @param config - durable refresh scheduling configuration.
* @throws when the refresh interval is invalid.
@@ -215,27 +215,34 @@ export function apply(ctx: Context, config: Config): void {
const refreshIntervalMs = config.refreshIntervalMs
validateRefreshInterval(refreshIntervalMs)
ctx.on('agent/step', async (
ctx.on('agent/pre-step', async (
agent: Agent,
turn: number,
step: number,
signal: AbortSignal,
): Promise<void> => {
if (signal.aborted || step !== 1) return
_messages,
{ turn, step, signal },
next,
): Promise<PreStepDecision> => {
const decision = await next()
if (decision.kind === 'reject' || signal.aborted || step !== 1) return decision
const bash = ctx.get('bash')
if (bash === undefined) return
if (bash === undefined) return decision
const previous = latestInjectedState(agent)
if (refreshIntervalMs !== undefined && refreshIntervalMs > 0 && previous !== undefined) {
const now = Date.now()
if (now >= previous.time && now - previous.time < refreshIntervalMs) return
if (now >= previous.time && now - previous.time < refreshIntervalMs) return decision
}
const location = await queryTmuxLocation(bash, ctx.logger, process.pid, signal)
if (location === undefined) return
if (location === undefined) return decision
const state = renderState(location)
if (previous !== undefined && previous.state === state) return
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: renderReading(location, turn) }],
source: { kind: 'plugin', plugin: name },
}), { surfaceOp: 'append' })
if (previous !== undefined && previous.state === state) return decision
return {
kind: 'enter',
messages: [
createUserMessage({
content: [{ type: 'text', text: renderReading(location, turn) }],
source: { kind: 'plugin', plugin: name },
}),
...decision.messages,
],
}
}, { prepend: true })
}

View File

@@ -96,7 +96,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
id: SessionId(id),
options: {},
session,
inbox: new Inbox(session),
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'running',
ctx: new Context(),
send: () => {},
@@ -135,7 +135,17 @@ async function fire(
step: number,
signal: AbortSignal = SIGNAL,
): Promise<void> {
await agentEvents(ctx, agent).serial('agent/step', turn, step, signal)
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
[],
{ turn, step, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
)
if (decision.kind === 'enter') {
for (const message of decision.messages) {
agent.session.append('user/message', message, { surfaceOp: 'append' })
}
}
}
afterEach(() => {
@@ -365,19 +375,11 @@ describe('tmux-context no-op paths', () => {
expect(warn).toHaveBeenCalledWith(expect.stringContaining('spawn refused'))
})
it('skips an already-aborted step and runs before ordinary agent/step listeners', async () => {
it('skips an already-aborted prompt submission', async () => {
const { ctx } = await mount({}, true)
const session = new Session(SessionId('ordering'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
let ordinarySawContext = false
ctx.on('agent/step', (subject) => {
ordinarySawContext = subject.session.events.some(
event => event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'tmux-context',
)
})
const abort = new AbortController()
abort.abort()
@@ -385,7 +387,6 @@ describe('tmux-context no-op paths', () => {
expect(contextTexts(session)).toHaveLength(0)
await fire(ctx, agent, 1, 1)
expect(ordinarySawContext).toBe(true)
expect(contextTexts(session)).toHaveLength(1)
})
})