# 运行中输入队列与 Runtime Guidance 注入

## 背景

用户在一个会话运行中继续输入，是 Agent 产品的基本能力。这个输入不能直接当成普通 user message 写入当前模型历史，否则会产生三个问题：

1. **串台**：前端切换 session 或 URL 状态滞后时，新输入可能被写进旧 session。
2. **取消误伤**：新建任务或继续输入会触发新的 run，从而取消当前正在运行的 run。
3. **上下文污染**：运行中的输入如果直接进入 `model_messages`，会破坏当前 tool_use/tool_result 顺序，甚至触发 Bedrock/Anthropic 的 tool_result 校验错误。

正确做法是把运行中输入先作为 UI 和 runtime 的外部事件持久化，等 Agent loop 进入安全边界时再决定如何注入。

## 主流方案对比

| 方案 | 关键机制 | 对本项目的启发 |
|------|----------|----------------|
| [OpenAI Codex long-horizon tasks](https://developers.openai.com/blog/run-long-horizon-tasks-with-codex) | 长任务依赖计划、验证、修复和可持续的外部状态，而不是单轮大 prompt | 会话运行态要可恢复；用户中途修正不能重置整轮任务 |
| [Claude Code hooks](https://code.claude.com/docs/en/hooks-guide) | `UserPromptSubmit`、`PreToolUse`、`PostToolUse`、`Stop` 等生命周期点允许注入上下文或阻断动作 | runtime guidance 应只在明确生命周期边界注入，不直接改写当前消息流 |
| [Building AI Coding Agents for the Terminal](https://arxiv.org/html/2603.05344v1) | Agent harness 把输入层、工具层、上下文层和执行层拆开；输入可通过线程安全队列进入执行循环 | 运行中输入应先入队，再由 Agent loop 主线程消费 |
| [Event-driven agentic loops](https://boundaryml.com/podcast/2025-11-05-event-driven-agents) | 用户输入、LLM chunk、tool call、interrupt 都是事件；UI、LLM、持久化各自投影 | `display_messages`、`model_messages`、`run_events` 必须分离，避免一个状态源服务所有场景 |

## 目标设计

```text
用户输入
  -> 如果当前 session idle: 正常发送，创建 run
  -> 如果当前 session running: 写入 agent_input_queue
       -> UI 展示 pending chip
       -> 用户可编辑、删除、引导
       -> 引导: kind=guidance，绑定当前 run_id
       -> Agent loop 在安全插入点消费 guidance
       -> guidance 以 display=false 的 user message 注入 model_messages
```

## 数据分层

| 数据 | 作用 | 是否允许 compact 覆盖 |
|------|------|-----------------------|
| `model_messages` | 给模型推理用，可压缩、可摘要、可隐藏注入 | 允许 |
| `display_messages` / `agent_display_messages` | 给 UI 回放用，append-only，不因为 compact 丢历史 | 不允许 |
| `run_states` | 当前 run 的状态、耗时、取消能力 | 不允许用前端内存替代 |
| `run_events` | 右侧活动区事件流 | 不允许只存在 SSE 内存里 |
| `agent_input_queue` | 运行中输入、guidance、待处理后续输入 | 不允许直接写进 display/model messages |

## 后端实现约定

### 状态读取

前端优先读取：

```text
GET /api/sessions/{session_id}/state
```

返回：

- `messages`: DB 中 `agent_display_messages` 的增量或全量。
- `activity_events`: DB 中 `run_events` 的增量或全量。
- `run`: `run_states` 最新状态。
- `input_queue`: 当前 pending 输入队列。

旧接口 `GET /api/sessions/{session_id}` 只作为兼容兜底，不应该再作为实时 UI 的主状态源。

### 输入队列

```text
POST   /api/sessions/{session_id}/input-queue
GET    /api/sessions/{session_id}/input-queue
PATCH  /api/sessions/{session_id}/input-queue/{item_id}
DELETE /api/sessions/{session_id}/input-queue/{item_id}
```

字段约定：

| 字段 | 说明 |
|------|------|
| `kind=next_turn` | 运行中输入的默认状态，只展示在 composer 上方，不进入模型 |
| `kind=guidance` | 用户显式点击“引导”后进入当前 run |
| `run_id` | guidance 应绑定当前 active run；未绑定时由后端尝试绑定 latest active run |
| `status=pending` | UI 可见，等待处理 |
| `status=consumed` | 已被 runtime 注入 |
| `status=cancelled` | 用户编辑/删除/取消 run 后不再处理 |

### Runtime 注入

`LocalCodingAgent` 提供 `runtime_guidance_provider`，在 Agent loop 的安全边界消费 pending guidance：

```text
agent loop safe boundary
  -> consume_session_guidance(session_id, run_id)
  -> append hidden user message:
     <runtime-guidance>
     用户在任务运行中补充了以下引导...
     </runtime-guidance>
  -> display=false
  -> 继续模型调用
```

可用插入点：

| 插入点 | 时机 | 处理策略 |
|--------|------|----------|
| `before_model` | 每次模型调用前 | 常规消费，适合上一轮工具完成后的补充 |
| `before_tools` | 模型已经给出工具计划，但工具还没开始执行 | 运行时先为上一批未执行工具补 synthetic `tool_result`，标记为 `runtime_guidance_replan`，再注入 guidance，让模型重新判断是否继续原计划、调整参数或换计划 |
| `during_tool_interrupted` | 工具已经开始执行，且用户引导明显要求停止、改目标、换参数、纠错 | 运行时给当前工具传入单工具 interrupt event，并通过 process registry 终止当前工具进程；当前工具结果落盘后注入 guidance，让模型重规划 |
| `after_tool` | 工具执行期间或刚完成后收到补充型 guidance，或工具没有中间输出无法及时中断 | 保留当前工具结果，停止继续执行同批旧工具计划，注入 guidance 让模型判断继续、补充或重跑 |
| `before_finish` | 模型准备给最终回复前 | 注入 guidance，让模型判断是修正最终输出、补充信息，还是转为后续任务 |

约束：

- 不把 guidance 插在 `tool_use` 和 `tool_result` 中间。
- `before_tools` 不直接删除 assistant 的工具计划，而是补一组“未执行、被 runtime 跳过”的 tool result，保证 Anthropic/Bedrock 的消息顺序合法。
- `during_tool_interrupted` 不复用整轮 run cancel event，而是构造“整轮取消 OR 当前工具中断”的组合 cancel event 传给当前工具，避免把用户引导误判成整轮取消。
- 立即中断依赖工具合作：bash / python / Jupyter / 远端执行等接入 cancel_event 或 process registry 的工具可以被终止；纯同步且没有中间输出的工具只能在返回后进入 `after_tool` 重规划。

### 引导策略判断

前端不暴露复杂按钮，用户仍然只点击“引导”。系统内部按安全点自动处理：

| 用户引导类型 | 默认策略 |
|--------------|----------|
| 工具未开始前的纠偏、改目标、改参数 | `before_tools` 重规划 |
| 工具完成后的补充要求 | `before_model` 注入下一次模型调用 |
| 即将结束前的格式、总结、补充输出要求 | `before_finish` 注入并继续一轮 |
| 已经运行中的长工具纠偏 | 明显停止/改目标/纠错类引导触发 `during_tool_interrupted`；补充输出类引导进入 `after_tool` |

模型负责在收到 `<runtime-guidance>` 后判断如何吸收：继续原计划、调整计划、说明冲突或转为后续任务；运行时只负责选择合法插入点和维护消息协议。

## 前端交互

1. 当前 session idle：输入框 Enter 仍然正常发送。
2. 当前 session running：输入框不禁用；Enter 写入 queue，清空输入框。
3. pending 输入显示为 composer 上方 chip：
   - **编辑**：取消 queue item，把文本恢复到输入框。
   - **引导**：改成 `kind=guidance` 并绑定 active `run_id`。
   - **删除**：取消 queue item。
4. 停止 run 时，后端同时取消该 session 下 pending queue item，避免下一轮误消费。

## 不做的事

- 不在运行中输入时自动创建新 run。
- 不把 pending 输入直接写入 `display_messages`。
- 不把 guidance 展示成普通用户消息；它是运行中的控制信号，不是对话历史。
- 不依赖前端内存判断最终状态；刷新后必须能从 DB 完整恢复。

## 验收点

1. 同一个账号同时打开两个 session，分别运行任务，输入不会串台。
2. A session 运行中切到 B session 输入，B 的输入只进入 B 的 queue。
3. A session 运行中输入后刷新，pending chip 仍存在。
4. 点击“引导”后，最近的安全插入点能消费 guidance，并在活动区记录注入事件。
5. 如果 guidance 在工具执行前到达，旧工具计划不执行，并产生 `runtime_guidance_replan_before_tools` 活动事件。
6. 点击停止后，对应 run 的进程和 pending queue 都被取消。
7. 触发 compact 后，UI 仍能从 `display_messages` 回放完整历史；模型只使用 compact 后的 `model_messages`。
