diff --git a/backend/api/server.py b/backend/api/server.py
index 904d3e5..97d2ae2 100644
--- a/backend/api/server.py
+++ b/backend/api/server.py
@@ -71,13 +71,19 @@ from src.run_state_store import ACTIVE_RUN_STATUSES, RunStateStore
from src.session_store import (
DEFAULT_AGENT_SESSION_DIR,
StoredAgentSession,
+ agent_session_delta,
+ cancel_session_input_queue,
+ consume_session_guidance,
delete_agent_session,
deserialize_runtime_config,
+ enqueue_session_input,
list_agent_sessions,
+ list_session_input_queue,
load_agent_session,
save_agent_session,
serialize_model_config,
serialize_runtime_config,
+ update_session_input_queue_item,
)
from src.token_budget import calculate_token_budget
@@ -1061,6 +1067,22 @@ class RunCancelRequest(BaseModel):
run_id: str | None = None
+class SessionInputQueueCreate(BaseModel):
+ content: str = Field(min_length=1, max_length=20000)
+ kind: str = 'next_turn'
+ run_id: str | None = None
+ account_id: str | None = None
+ metadata: dict[str, Any] | None = None
+
+
+class SessionInputQueueUpdate(BaseModel):
+ content: str | None = Field(default=None, max_length=20000)
+ kind: str | None = None
+ status: str | None = None
+ run_id: str | None = None
+ account_id: str | None = None
+
+
class StateUpdate(BaseModel):
model: str | None = None
base_url: str | None = None
@@ -1737,6 +1759,126 @@ def create_app(state: AgentState) -> FastAPI:
raise HTTPException(status_code=404, detail='Session not found')
return _serialize_stored_session(stored)
+ @app.get('/api/sessions/{session_id}/state')
+ async def get_session_state_delta(
+ session_id: str,
+ account_id: str | None = None,
+ after_message_seq: int = 0,
+ after_event_seq: int = 0,
+ ) -> dict[str, Any]:
+ directory = state.account_paths(account_id)['sessions']
+ safe_id = _safe_session_id(session_id)
+ if safe_id is None:
+ raise HTTPException(status_code=400, detail='Invalid session id')
+ account_key = state._account_key(account_id)
+ message_delta = agent_session_delta(
+ safe_id,
+ directory=directory,
+ after_message_seq=after_message_seq,
+ )
+ event_delta = state.run_state_store.events_since(
+ account_key,
+ safe_id,
+ after_event_seq=after_event_seq,
+ )
+ run_status = await latest_run(safe_id, account_id=account_id)
+ queue_items = list_session_input_queue(safe_id, directory=directory)
+ return {
+ 'session_id': safe_id,
+ 'session': message_delta.get('session', {'session_id': safe_id}),
+ 'messages': message_delta.get('messages', []),
+ 'latest_message_seq': message_delta.get('latest_message_seq', 0),
+ 'activity_events': event_delta.get('events', []),
+ 'latest_event_seq': event_delta.get('latest_event_seq', 0),
+ 'run': run_status,
+ 'input_queue': queue_items,
+ }
+
+ @app.get('/api/sessions/{session_id}/input-queue')
+ async def get_session_input_queue(
+ session_id: str,
+ account_id: str | None = None,
+ ) -> dict[str, Any]:
+ directory = state.account_paths(account_id)['sessions']
+ safe_id = _safe_session_id(session_id)
+ if safe_id is None:
+ raise HTTPException(status_code=400, detail='Invalid session id')
+ return {
+ 'session_id': safe_id,
+ 'items': list_session_input_queue(safe_id, directory=directory),
+ }
+
+ @app.post('/api/sessions/{session_id}/input-queue')
+ async def create_session_input_queue_item(
+ session_id: str,
+ payload: SessionInputQueueCreate,
+ ) -> dict[str, Any]:
+ directory = state.account_paths(payload.account_id)['sessions']
+ safe_id = _safe_session_id(session_id)
+ if safe_id is None:
+ raise HTTPException(status_code=400, detail='Invalid session id')
+ content = payload.content.strip()
+ if not content:
+ raise HTTPException(status_code=400, detail='Empty input content')
+ run_id = payload.run_id
+ if payload.kind == 'guidance' and not run_id:
+ account_key = state._account_key(payload.account_id)
+ latest = state.run_state_store.snapshot_latest(account_key, safe_id)
+ if latest and latest.get('status') in ACTIVE_RUN_STATUSES:
+ run_id = str(latest.get('run_id') or '')
+ item = enqueue_session_input(
+ safe_id,
+ directory=directory,
+ run_id=run_id,
+ kind=payload.kind,
+ content=content,
+ metadata=payload.metadata,
+ )
+ return {'session_id': safe_id, 'item': item}
+
+ @app.patch('/api/sessions/{session_id}/input-queue/{item_id}')
+ async def update_session_input_queue(
+ session_id: str,
+ item_id: int,
+ payload: SessionInputQueueUpdate,
+ ) -> dict[str, Any]:
+ directory = state.account_paths(payload.account_id)['sessions']
+ safe_id = _safe_session_id(session_id)
+ if safe_id is None:
+ raise HTTPException(status_code=400, detail='Invalid session id')
+ item = update_session_input_queue_item(
+ safe_id,
+ item_id,
+ directory=directory,
+ content=payload.content.strip() if payload.content is not None else None,
+ kind=payload.kind,
+ status=payload.status,
+ run_id=payload.run_id,
+ )
+ if item is None:
+ raise HTTPException(status_code=404, detail='Queue item not found')
+ return {'session_id': safe_id, 'item': item}
+
+ @app.delete('/api/sessions/{session_id}/input-queue/{item_id}')
+ async def delete_session_input_queue(
+ session_id: str,
+ item_id: int,
+ account_id: str | None = None,
+ ) -> dict[str, Any]:
+ directory = state.account_paths(account_id)['sessions']
+ safe_id = _safe_session_id(session_id)
+ if safe_id is None:
+ raise HTTPException(status_code=400, detail='Invalid session id')
+ item = update_session_input_queue_item(
+ safe_id,
+ item_id,
+ directory=directory,
+ status='cancelled',
+ )
+ if item is None:
+ raise HTTPException(status_code=404, detail='Queue item not found')
+ return {'session_id': safe_id, 'item': item}
+
@app.delete('/api/sessions/{session_id}')
async def delete_session(session_id: str, account_id: str | None = None) -> dict[str, Any]:
directory = state.account_paths(account_id)['sessions']
@@ -2356,8 +2498,18 @@ def create_app(state: AgentState) -> FastAPI:
status='cancelled',
stage='用户已取消',
)
+ queue_cancelled = cancel_session_input_queue(
+ safe_id,
+ state.account_paths(payload.account_id)['sessions'],
+ run_id=payload.run_id,
+ )
cancelled_ids.update(stored_cancelled)
- cancelled = bool(cancelled_ids or cancelled_bg_task_ids or abandoned_runtime)
+ cancelled = bool(
+ cancelled_ids
+ or cancelled_bg_task_ids
+ or abandoned_runtime
+ or queue_cancelled
+ )
if cancelled:
_mark_session_interrupted(
state.account_paths(payload.account_id)['sessions'],
@@ -2370,6 +2522,7 @@ def create_app(state: AgentState) -> FastAPI:
'cancelled_run_ids': sorted(cancelled_ids),
'cancelled_bg_task_ids': sorted(cancelled_bg_task_ids),
'abandoned_runtime': abandoned_runtime,
+ 'cancelled_input_queue_items': queue_cancelled,
}
def _run_chat_payload(
@@ -2527,6 +2680,22 @@ def create_app(state: AgentState) -> FastAPI:
except Exception: # noqa: BLE001
return False
+ def _drain_runtime_guidance() -> tuple[dict[str, object], ...]:
+ items = consume_session_guidance(
+ requested_session_id,
+ run_record.run_id,
+ directory=session_directory,
+ )
+ return tuple(
+ {
+ 'id': int(item.get('id') or 0),
+ 'content': str(item.get('content') or ''),
+ }
+ for item in items
+ )
+
+ previous_guidance_provider = agent.runtime_guidance_provider
+ agent.runtime_guidance_provider = _drain_runtime_guidance
agent.tool_context = replace(
agent.tool_context,
cancel_event=run_record.cancel_event,
@@ -2602,6 +2771,7 @@ def create_app(state: AgentState) -> FastAPI:
)
raise
finally:
+ agent.runtime_guidance_provider = previous_guidance_provider
agent.tool_context = replace(
agent.tool_context,
cancel_event=None,
diff --git a/docs/technical-architecture/12-runtime-guidance-queue.md b/docs/technical-architecture/12-runtime-guidance-queue.md
new file mode 100644
index 0000000..c896e0b
--- /dev/null
+++ b/docs/technical-architecture/12-runtime-guidance-queue.md
@@ -0,0 +1,125 @@
+# 运行中输入队列与 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`,在每轮模型调用前消费 pending guidance:
+
+```text
+agent loop boundary
+ -> consume_session_guidance(session_id, run_id)
+ -> append hidden user message:
+
+ 用户在任务运行中补充了以下引导...
+
+ -> display=false
+ -> 继续模型调用
+```
+
+这个注入点必须在 tool_result 已经写回之后、下一次模型调用之前,不能插在 tool_use 和 tool_result 中间。
+
+## 前端交互
+
+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. 点击停止后,对应 run 的进程和 pending queue 都被取消。
+6. 触发 compact 后,UI 仍能从 `display_messages` 回放完整历史;模型只使用 compact 后的 `model_messages`。
diff --git a/docs/technical-architecture/README.md b/docs/technical-architecture/README.md
index 8e713bd..979ba12 100644
--- a/docs/technical-architecture/README.md
+++ b/docs/technical-architecture/README.md
@@ -15,6 +15,7 @@
8. [label-master Skill 实现](08-label-master.md)
9. [外部系统 Skill:ELK、SQL、模型迭代](09-external-skills.md)
10. [Agent 记忆机制调研与对比](10-memory-research.md)
+11. [运行中输入队列与 Runtime Guidance 注入](12-runtime-guidance-queue.md)
## 一句话定位
diff --git a/frontend/app/app/api/claw/sessions/[sessionId]/input-queue/[itemId]/route.ts b/frontend/app/app/api/claw/sessions/[sessionId]/input-queue/[itemId]/route.ts
new file mode 100644
index 0000000..6c36f03
--- /dev/null
+++ b/frontend/app/app/api/claw/sessions/[sessionId]/input-queue/[itemId]/route.ts
@@ -0,0 +1,60 @@
+import { getCurrentAccount } from "@/lib/claw-auth";
+
+const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
+
+export async function PATCH(
+ req: Request,
+ { params }: { params: Promise<{ sessionId: string; itemId: string }> },
+) {
+ const account = await getCurrentAccount();
+ if (!account)
+ return Response.json({ error: "请先登录账号" }, { status: 401 });
+
+ const { sessionId: rawSessionId, itemId: rawItemId } = await params;
+ const sessionId = rawSessionId.trim();
+ const itemId = rawItemId.trim();
+ const payload = await req.json().catch(() => ({}));
+ const url = new URL(
+ `${CLAW_API_URL}/api/sessions/${sessionId}/input-queue/${itemId}`,
+ );
+ const response = await fetch(url, {
+ method: "PATCH",
+ cache: "no-store",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ ...payload, account_id: account.id }),
+ });
+ const responsePayload = await response.text();
+ return new Response(responsePayload, {
+ status: response.status,
+ headers: {
+ "content-type":
+ response.headers.get("content-type") ?? "application/json",
+ },
+ });
+}
+
+export async function DELETE(
+ _req: Request,
+ { params }: { params: Promise<{ sessionId: string; itemId: string }> },
+) {
+ const account = await getCurrentAccount();
+ if (!account)
+ return Response.json({ error: "请先登录账号" }, { status: 401 });
+
+ const { sessionId: rawSessionId, itemId: rawItemId } = await params;
+ const sessionId = rawSessionId.trim();
+ const itemId = rawItemId.trim();
+ const url = new URL(
+ `${CLAW_API_URL}/api/sessions/${sessionId}/input-queue/${itemId}`,
+ );
+ url.searchParams.set("account_id", account.id);
+ const response = await fetch(url, { method: "DELETE", cache: "no-store" });
+ const responsePayload = await response.text();
+ return new Response(responsePayload, {
+ status: response.status,
+ headers: {
+ "content-type":
+ response.headers.get("content-type") ?? "application/json",
+ },
+ });
+}
diff --git a/frontend/app/app/api/claw/sessions/[sessionId]/input-queue/route.ts b/frontend/app/app/api/claw/sessions/[sessionId]/input-queue/route.ts
new file mode 100644
index 0000000..2ae301f
--- /dev/null
+++ b/frontend/app/app/api/claw/sessions/[sessionId]/input-queue/route.ts
@@ -0,0 +1,54 @@
+import { getCurrentAccount } from "@/lib/claw-auth";
+
+const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
+
+export async function GET(
+ _req: Request,
+ { params }: { params: Promise<{ sessionId: string }> },
+) {
+ const account = await getCurrentAccount();
+ if (!account)
+ return Response.json({ error: "请先登录账号" }, { status: 401 });
+
+ const { sessionId: rawSessionId } = await params;
+ const sessionId = rawSessionId.trim();
+ const url = new URL(`${CLAW_API_URL}/api/sessions/${sessionId}/input-queue`);
+ url.searchParams.set("account_id", account.id);
+ const response = await fetch(url, { cache: "no-store" });
+ const payload = await response.text();
+ return new Response(payload, {
+ status: response.status,
+ headers: {
+ "content-type":
+ response.headers.get("content-type") ?? "application/json",
+ },
+ });
+}
+
+export async function POST(
+ req: Request,
+ { params }: { params: Promise<{ sessionId: string }> },
+) {
+ const account = await getCurrentAccount();
+ if (!account)
+ return Response.json({ error: "请先登录账号" }, { status: 401 });
+
+ const { sessionId: rawSessionId } = await params;
+ const sessionId = rawSessionId.trim();
+ const payload = await req.json().catch(() => ({}));
+ const url = new URL(`${CLAW_API_URL}/api/sessions/${sessionId}/input-queue`);
+ const response = await fetch(url, {
+ method: "POST",
+ cache: "no-store",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ ...payload, account_id: account.id }),
+ });
+ const responsePayload = await response.text();
+ return new Response(responsePayload, {
+ status: response.status,
+ headers: {
+ "content-type":
+ response.headers.get("content-type") ?? "application/json",
+ },
+ });
+}
diff --git a/frontend/app/app/api/claw/sessions/[sessionId]/state/route.ts b/frontend/app/app/api/claw/sessions/[sessionId]/state/route.ts
new file mode 100644
index 0000000..fac6bff
--- /dev/null
+++ b/frontend/app/app/api/claw/sessions/[sessionId]/state/route.ts
@@ -0,0 +1,31 @@
+import { getCurrentAccount } from "@/lib/claw-auth";
+
+const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
+
+export async function GET(
+ req: Request,
+ { params }: { params: Promise<{ sessionId: string }> },
+) {
+ const account = await getCurrentAccount();
+ if (!account)
+ return Response.json({ error: "请先登录账号" }, { status: 401 });
+
+ const { sessionId: rawSessionId } = await params;
+ const sessionId = rawSessionId.trim();
+ const incoming = new URL(req.url);
+ const url = new URL(`${CLAW_API_URL}/api/sessions/${sessionId}/state`);
+ url.searchParams.set("account_id", account.id);
+ for (const key of ["after_message_seq", "after_event_seq"]) {
+ const value = incoming.searchParams.get(key);
+ if (value) url.searchParams.set(key, value);
+ }
+ const response = await fetch(url, { cache: "no-store" });
+ const payload = await response.text();
+ return new Response(payload, {
+ status: response.status,
+ headers: {
+ "content-type":
+ response.headers.get("content-type") ?? "application/json",
+ },
+ });
+}
diff --git a/frontend/app/app/doc/[slug]/page.tsx b/frontend/app/app/doc/[slug]/page.tsx
index c3f7b2c..5caeeba 100644
--- a/frontend/app/app/doc/[slug]/page.tsx
+++ b/frontend/app/app/doc/[slug]/page.tsx
@@ -61,6 +61,11 @@ const technicalDocs = {
file: "11-subagents.md",
image: null,
},
+ "12-runtime-guidance-queue": {
+ title: "运行中输入队列",
+ file: "12-runtime-guidance-queue.md",
+ image: null,
+ },
} as const;
type TechnicalDocSlug = keyof typeof technicalDocs;
diff --git a/frontend/app/app/doc/page.tsx b/frontend/app/app/doc/page.tsx
index 0a1161f..7f3d1a6 100644
--- a/frontend/app/app/doc/page.tsx
+++ b/frontend/app/app/doc/page.tsx
@@ -144,6 +144,27 @@ const sections: Section[] = [
decision:
"子 Agent 负责观察和验证,主 Agent 负责用户上下文、最终决策和产物写入。",
},
+ {
+ id: "runtime-guidance",
+ no: "12",
+ title: "运行中输入队列",
+ summary:
+ "运行中的用户输入先入队,只有用户显式引导时才在 Agent loop 安全边界注入当前 run。",
+ doc: "/doc/12-runtime-guidance-queue",
+ points: [
+ "display_messages、model_messages、run_events 和 input_queue 分离,避免刷新、compact 和多会话切换互相污染。",
+ "运行中 Enter 写入队列,不创建新 run,不取消当前 run。",
+ "引导消息以 hidden runtime guidance 注入模型,不作为普通用户历史展示。",
+ ],
+ code: [
+ "src/session_store.py",
+ "src/run_state_store.py",
+ "src/agent_runtime.py",
+ "frontend input queue chips",
+ ],
+ decision:
+ "UI、模型和持久化不能共享同一份 mutable messages;运行中交互必须事件化、可恢复、可取消。",
+ },
];
const skillSections: Section[] = [
@@ -235,7 +256,8 @@ const skillSections: Section[] = [
const exampleSessions = [
{
title: "product-data",
- description: "数据生成链路:目标抽取、计划确认、分批生成、canonical records 和训练/评测格式导出。",
+ description:
+ "数据生成链路:目标抽取、计划确认、分批生成、canonical records 和训练/评测格式导出。",
links: [
"http://10.189.47.6/session/__LOCALID_ZgwXdfP",
"http://10.189.47.6/session/__LOCALID_QOecDTu",
@@ -243,7 +265,8 @@ const exampleSessions = [
},
{
title: "标签大师",
- description: "标签知识判断链路:复杂度、多指令、自动任务、function/tag 输出形态和边界知识引用。",
+ description:
+ "标签知识判断链路:复杂度、多指令、自动任务、function/tag 输出形态和边界知识引用。",
links: [
"http://10.189.47.6/session/__LOCALID_ji5lCfb",
"http://10.189.47.6/session/__LOCALID_Z22r3pn",
@@ -254,12 +277,14 @@ const exampleSessions = [
},
{
title: "标签大师 + online-mining",
- description: "组合链路:先用线上日志定位样本,再结合标签知识做判断、整理和格式转换。",
+ description:
+ "组合链路:先用线上日志定位样本,再结合标签知识做判断、整理和格式转换。",
links: ["http://10.189.47.6/session/__LOCALID_OLKxKnf"],
},
{
title: "online-mining",
- description: "线上挖掘链路:查询策略、ELK 字段探索、候选样本抽样 review 和标准数据沉淀。",
+ description:
+ "线上挖掘链路:查询策略、ELK 字段探索、候选样本抽样 review 和标准数据沉淀。",
links: [
"http://10.189.47.6/session/__LOCALID_BnxMLqn",
"http://10.189.47.6/session/__LOCALID_xZRilR5",
@@ -357,7 +382,8 @@ export default function DocPage() {
ZK-Data-Agent 架构
- 基座是一个通用 Code Agent Loop;我们的工作不是重写这个闭环,而是在关键节点补上团队业务能力,让执行、业务上下文和流程沉淀进入同一套工作台。
+ 基座是一个通用 Code Agent
+ Loop;我们的工作不是重写这个闭环,而是在关键节点补上团队业务能力,让执行、业务上下文和流程沉淀进入同一套工作台。
@@ -379,19 +405,23 @@ export default function DocPage() {
基座提供执行闭环
- Code Agent 负责把任务放进多轮循环:组装上下文、模型推理、调用工具、观察结果,再继续规划或完成。它让模型从一次回答变成可以持续执行的运行时。
+ Code Agent
+ 负责把任务放进多轮循环:组装上下文、模型推理、调用工具、观察结果,再继续规划或完成。它让模型从一次回答变成可以持续执行的运行时。
增强层体现我们的工作
- ZK-Data-Agent 在上下文、工具、产物和团队协作节点接入 Skill 体系、Skill 热更新、用户记忆、Skill 记忆、portable scripts、会话工作区、活动流和管理后台。
+ ZK-Data-Agent 在上下文、工具、产物和团队协作节点接入 Skill
+ 体系、Skill 热更新、用户记忆、Skill 记忆、portable
+ scripts、会话工作区、活动流和管理后台。
效率来自工程化补齐
- 工具和工作区提升研发执行效率;Skill 与记忆提升业务嵌入深度;Git 化 Skill、热更新和团队治理提升流程可复用度。
+ 工具和工作区提升研发执行效率;Skill 与记忆提升业务嵌入深度;Git 化
+ Skill、热更新和团队治理提升流程可复用度。
@@ -411,7 +441,9 @@ export default function DocPage() {
示例 Session
- 下面这些是真实跑过的会话,适合讲解时直接打开。它们展示的不是单次问答,而是 Skill 如何在 Agent Loop 中完成计划、工具调用、人工确认、产物生成和格式转换。
+ 下面这些是真实跑过的会话,适合讲解时直接打开。它们展示的不是单次问答,而是
+ Skill 如何在 Agent Loop
+ 中完成计划、工具调用、人工确认、产物生成和格式转换。
diff --git a/frontend/app/components/assistant-ui/thread.tsx b/frontend/app/components/assistant-ui/thread.tsx
index fa64a7e..2798255 100644
--- a/frontend/app/components/assistant-ui/thread.tsx
+++ b/frontend/app/components/assistant-ui/thread.tsx
@@ -91,12 +91,14 @@ import {
writeActiveSessionId,
writePendingWorkspaceSessionId,
} from "@/lib/claw-active-session";
+import { fetchSessionReplaySnapshot } from "@/lib/claw-session-cache";
import { useClawSessionReplay } from "@/lib/claw-session-replay";
import { readSessionIdFromUrl } from "@/lib/claw-session-url";
import { dispatchSkillToggled } from "@/lib/use-training-mode";
import { cn } from "@/lib/utils";
type ComposerInsertEvent = CustomEvent<{ text: string }>;
+const INPUT_QUEUE_CHANGED_EVENT = "claw-input-queue-changed";
type SkillSummary = {
name: string;
@@ -129,11 +131,23 @@ type ClawState = {
active_session_id?: string | null;
};
-type ClawStoredSession = {
- session_id?: string;
+type ClawStoredSession = Parameters[0] & {
model?: string;
};
+type SessionInputQueueItem = {
+ id: number;
+ session_id: string;
+ run_id?: string;
+ kind: "next_turn" | "guidance";
+ status: "pending" | "consumed" | "cancelled";
+ content: string;
+ created_at?: number;
+ updated_at?: number;
+ consumed_at?: number | null;
+ metadata?: Record;
+};
+
type ModelOption = {
id: string;
provider?: string;
@@ -220,32 +234,27 @@ function useRefreshReplayedRun() {
if (!replayedRun.active || !replayedRun.sessionId) return;
const activeSessionId = replayedRun.sessionId;
let cancelled = false;
+ let lastSignature: string | null = null;
async function refreshIfFinished() {
- const runStatus = await fetchLatestRunStatus(activeSessionId);
- if (cancelled) return;
- if (runStatus?.status === "queued" || runStatus?.status === "running") {
- const response = await fetch(`/api/claw/sessions/${activeSessionId}`, {
- cache: "no-store",
- });
- if (!response.ok || cancelled) return;
- const payload = await response.json();
- replaySession(
- activeSessionId,
- toReplayRepository(payload, activeSessionId, runStatus),
- );
- return;
- }
- const response = await fetch(`/api/claw/sessions/${activeSessionId}`, {
- cache: "no-store",
- });
- if (!response.ok || cancelled) return;
- const payload = await response.json();
+ const snapshot = await fetchSessionReplaySnapshot<
+ ClawStoredSession,
+ ClawRunStatus
+ >(activeSessionId);
+ if (cancelled || !snapshot) return;
+ if (snapshot.signature === lastSignature) return;
+ lastSignature = snapshot.signature;
replaySession(
activeSessionId,
- toReplayRepository(payload, activeSessionId, runStatus),
+ toReplayRepository(
+ snapshot.session,
+ activeSessionId,
+ snapshot.runStatus,
+ ),
);
- window.dispatchEvent(new Event("claw-sessions-changed"));
+ if (!isActiveRunStatus(snapshot.runStatus)) {
+ window.dispatchEvent(new Event("claw-sessions-changed"));
+ }
}
refreshIfFinished();
@@ -279,7 +288,11 @@ function useRefreshCurrentRun() {
async function refreshIfSettled() {
if (!sessionId) return;
- const runStatus = await fetchLatestRunStatus(sessionId);
+ const snapshot = await fetchSessionReplaySnapshot<
+ ClawStoredSession,
+ ClawRunStatus
+ >(sessionId);
+ const runStatus = snapshot?.runStatus ?? null;
if (cancelled) return;
if (runStatus?.status === "queued" || runStatus?.status === "running") {
const runKey = runStatus.run_id ?? "active";
@@ -291,14 +304,10 @@ function useRefreshCurrentRun() {
// useRefreshReplayedRun will take over polling once replayedRun.active
// becomes true.
if (!runtimeRunning && previousRunKey !== runKey) {
- const response = await fetch(`/api/claw/sessions/${sessionId}`, {
- cache: "no-store",
- });
- if (!response.ok || cancelled) return;
- const payload = await response.json();
+ if (!snapshot || cancelled) return;
replaySession(
sessionId,
- toReplayRepository(payload, sessionId, runStatus),
+ toReplayRepository(snapshot.session, sessionId, runStatus),
);
}
return;
@@ -312,14 +321,10 @@ function useRefreshCurrentRun() {
runStatus?.elapsed_ms ?? "",
].join(":");
if (appliedTerminalRefMap.current.get(sessionId) === terminalKey) return;
- const response = await fetch(`/api/claw/sessions/${sessionId}`, {
- cache: "no-store",
- });
- if (!response.ok || cancelled) return;
- const payload = await response.json();
+ if (!snapshot || cancelled) return;
replaySession(
sessionId,
- toReplayRepository(payload, sessionId, runStatus),
+ toReplayRepository(snapshot.session, sessionId, runStatus),
);
activeRunRefMap.current.delete(sessionId);
appliedTerminalRefMap.current.set(sessionId, terminalKey);
@@ -893,15 +898,10 @@ const ThreadSuggestionItem: FC = () => {
};
const Composer: FC = () => {
- const replayedRun = useReplayedRunState();
const workspaceSessionId = useCurrentThreadSessionId({
includePending: true,
includeActive: true,
});
- const replayedRunIsCurrent =
- replayedRun.active &&
- Boolean(workspaceSessionId) &&
- replayedRun.sessionId === workspaceSessionId;
return (
@@ -911,13 +911,13 @@ const Composer: FC = () => {
>
+
@@ -926,6 +926,92 @@ const Composer: FC = () => {
);
};
+const ComposerPendingInputQueue: FC<{ sessionId: string | null }> = ({
+ sessionId,
+}) => {
+ const backendRunStatus = useBackendActiveRunStatus(sessionId);
+ const items = useSessionInputQueue(sessionId);
+ if (!sessionId || items.length === 0) return null;
+
+ return (
+
+ {items.map((item) => {
+ const isGuidance = item.kind === "guidance";
+ const canGuide = Boolean(backendRunStatus?.run_id);
+ return (
+
+
+
+ {isGuidance ? "待引导" : "待发送"}
+
+ {item.content}
+
+
+
+
+
+ );
+ })}
+
+ );
+};
+
const ComposerWorkspaceStatus: FC<{ sessionId: string | null }> = ({
sessionId,
}) => {
@@ -1071,6 +1157,100 @@ function isActiveRunStatus(status?: ClawRunStatus | null) {
return status?.status === "queued" || status?.status === "running";
}
+function dispatchInputQueueChanged() {
+ window.dispatchEvent(new Event(INPUT_QUEUE_CHANGED_EVENT));
+}
+
+function useSessionInputQueue(sessionId: string | null) {
+ const [items, setItems] = useState([]);
+
+ useEffect(() => {
+ if (!sessionId) {
+ setItems([]);
+ return;
+ }
+ let cancelled = false;
+ const refresh = async () => {
+ const nextItems = await fetchSessionInputQueue(sessionId);
+ if (!cancelled) setItems(nextItems);
+ };
+ const handleQueueChanged = () => {
+ void refresh();
+ };
+ void refresh();
+ const interval = window.setInterval(refresh, 1000);
+ window.addEventListener(INPUT_QUEUE_CHANGED_EVENT, handleQueueChanged);
+ return () => {
+ cancelled = true;
+ window.clearInterval(interval);
+ window.removeEventListener(INPUT_QUEUE_CHANGED_EVENT, handleQueueChanged);
+ };
+ }, [sessionId]);
+
+ return items;
+}
+
+async function fetchSessionInputQueue(sessionId: string) {
+ try {
+ const response = await fetch(
+ `/api/claw/sessions/${encodeURIComponent(sessionId)}/input-queue`,
+ { cache: "no-store" },
+ );
+ if (!response.ok) return [];
+ const payload = (await response.json()) as {
+ items?: SessionInputQueueItem[];
+ };
+ return Array.isArray(payload.items) ? payload.items : [];
+ } catch {
+ return [];
+ }
+}
+
+async function createSessionInputQueueItem(
+ sessionId: string,
+ payload: {
+ content: string;
+ kind?: "next_turn" | "guidance";
+ run_id?: string;
+ },
+) {
+ const response = await fetch(
+ `/api/claw/sessions/${encodeURIComponent(sessionId)}/input-queue`,
+ {
+ method: "POST",
+ cache: "no-store",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(payload),
+ },
+ );
+ if (!response.ok) {
+ throw new Error(`Failed to queue input: ${response.status}`);
+ }
+ return (await response.json()) as { item?: SessionInputQueueItem };
+}
+
+async function updateSessionInputQueueItem(
+ sessionId: string,
+ itemId: number,
+ payload: Partial<
+ Pick
+ >,
+) {
+ const response = await fetch(
+ `/api/claw/sessions/${encodeURIComponent(sessionId)}/input-queue/${itemId}`,
+ {
+ method: "PATCH",
+ cache: "no-store",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(payload),
+ },
+ );
+ if (!response.ok) {
+ throw new Error(`Failed to update queued input: ${response.status}`);
+ }
+ return (await response.json()) as { item?: SessionInputQueueItem };
+}
+
const ReplayedRunCancelButton: FC<{
sessionId: string | null;
runId: string | null;
@@ -1090,15 +1270,18 @@ const ReplayedRunCancelButton: FC<{
setCancelling(true);
try {
await cancelLatestRun(sessionId, runId);
- const runStatus = await fetchLatestRunStatus(sessionId);
- const response = await fetch(`/api/claw/sessions/${sessionId}`, {
- cache: "no-store",
- });
- if (response.ok) {
- const payload = await response.json();
+ const snapshot = await fetchSessionReplaySnapshot<
+ ClawStoredSession,
+ ClawRunStatus
+ >(sessionId);
+ if (snapshot) {
replaySession(
sessionId,
- toReplayRepository(payload, sessionId, runStatus),
+ toReplayRepository(
+ snapshot.session,
+ sessionId,
+ snapshot.runStatus,
+ ),
);
}
window.dispatchEvent(new Event("claw-sessions-changed"));
@@ -2099,10 +2282,7 @@ const ActivityChainSummary: FC<{
liveStartedAtRef.current = authoritativeStartedAt;
} else if (liveStartedAtRef.current === null && storedElapsedMs !== null) {
liveStartedAtRef.current = Date.now() - storedElapsedMs;
- } else if (
- liveStartedAtRef.current !== null &&
- storedElapsedMs !== null
- ) {
+ } else if (liveStartedAtRef.current !== null && storedElapsedMs !== null) {
liveStartedAtRef.current = Math.min(
liveStartedAtRef.current,
Date.now() - storedElapsedMs,
@@ -2422,14 +2602,25 @@ const ImeComposerInput: FC> = ({
const storeText = useAuiState((s) =>
s.composer.isEditing ? s.composer.text : "",
);
+ const isEditingComposer = useAuiState((s) => s.composer.isEditing);
+ const runtimeRunning = useAuiState((s) => s.thread.isRunning);
const runtimeDisabled = useAuiState(
(s) => s.thread.isDisabled || s.composer.dictation?.inputDisabled,
);
+ const activeSessionId = useCurrentThreadSessionId({
+ includePending: true,
+ includeActive: true,
+ });
+ const backendRunStatus = useBackendActiveRunStatus(activeSessionId);
const [localText, setLocalText] = useState(storeText);
const textareaRef = useRef(null);
const isComposingRef = useRef(false);
const justSubmittedRef = useRef(false);
- const isDisabled = runtimeDisabled || disabled;
+ const canQueueWhileRunning =
+ !isEditingComposer &&
+ Boolean(activeSessionId) &&
+ (runtimeRunning || isActiveRunStatus(backendRunStatus));
+ const isDisabled = (runtimeDisabled && !canQueueWhileRunning) || disabled;
// Pull store→local on external store changes (session switch, paste-from-attachment,
// submit-clear, etc). The reverse direction (local→store) is pushed eagerly by
@@ -2475,6 +2666,12 @@ const ImeComposerInput: FC> = ({
const text = detail?.text ?? "";
justSubmittedRef.current = false;
setLocalText(text);
+ requestAnimationFrame(() => {
+ const textarea = textareaRef.current;
+ if (!textarea) return;
+ textarea.focus({ preventScroll: true });
+ textarea.setSelectionRange(text.length, text.length);
+ });
};
window.addEventListener("claw-composer-restore-draft", handler);
return () =>
@@ -2523,6 +2720,27 @@ const ImeComposerInput: FC> = ({
}
const threadState = aui.thread().getState();
+ const content = localText.trim();
+ if (
+ (threadState.isRunning || isActiveRunStatus(backendRunStatus)) &&
+ activeSessionId
+ ) {
+ event.preventDefault();
+ if (!content) return;
+ justSubmittedRef.current = true;
+ setLocalText("");
+ syncComposerText("");
+ void createSessionInputQueueItem(activeSessionId, {
+ content,
+ kind: "next_turn",
+ })
+ .catch(() => {
+ justSubmittedRef.current = false;
+ setLocalText(content);
+ })
+ .finally(dispatchInputQueueChanged);
+ return;
+ }
if (threadState.isRunning && !threadState.capabilities.queue) return;
event.preventDefault();
diff --git a/frontend/app/lib/claw-session-cache.ts b/frontend/app/lib/claw-session-cache.ts
index e431d79..f63754f 100644
--- a/frontend/app/lib/claw-session-cache.ts
+++ b/frontend/app/lib/claw-session-cache.ts
@@ -33,6 +33,12 @@ export async function fetchSessionReplaySnapshot<
sessionId: string,
options: { signal?: AbortSignal } = {},
): Promise | null> {
+ const stateSnapshot = await fetchSessionStateSnapshot(
+ sessionId,
+ options.signal,
+ );
+ if (stateSnapshot) return stateSnapshot;
+
const [sessionResponse, runStatus] = await Promise.all([
fetch(`/api/claw/sessions/${encodeURIComponent(sessionId)}`, {
cache: "no-store",
@@ -45,6 +51,43 @@ export async function fetchSessionReplaySnapshot<
return writeCachedSessionReplay(sessionId, session, runStatus);
}
+type SessionStateResponse = {
+ session?: Record;
+ messages?: Array<{ seq?: number; message?: unknown }>;
+ run?: TRun | null;
+};
+
+async function fetchSessionStateSnapshot(
+ sessionId: string,
+ signal?: AbortSignal,
+): Promise | null> {
+ try {
+ const response = await fetch(
+ `/api/claw/sessions/${encodeURIComponent(sessionId)}/state?after_message_seq=0&after_event_seq=0`,
+ { cache: "no-store", signal },
+ );
+ if (!response.ok) return null;
+ const payload = (await response.json()) as SessionStateResponse;
+ if (!Array.isArray(payload.messages)) return null;
+ const orderedMessages = payload.messages
+ .slice()
+ .sort((left, right) => (left.seq ?? 0) - (right.seq ?? 0))
+ .map((entry) => entry.message)
+ .filter((message) => Boolean(message));
+ const session = {
+ ...(payload.session ?? {}),
+ session_id: sessionId,
+ messages: orderedMessages,
+ } as TSession;
+ return writeCachedSessionReplay(sessionId, session, payload.run ?? null);
+ } catch (error) {
+ if (error instanceof DOMException && error.name === "AbortError") {
+ throw error;
+ }
+ return null;
+ }
+}
+
function writeCachedSessionReplay(
sessionId: string,
session: TSession,
@@ -84,15 +127,18 @@ async function fetchLatestRunStatusForCache(
}
}
-function sessionReplaySignature(session: unknown, runStatus: unknown) {
+function sessionReplaySignature(session: unknown, runStatusValue: unknown) {
const sessionObject = isObject(session) ? session : {};
- const runObject = isObject(runStatus) ? runStatus : {};
+ const runObject = isObject(runStatusValue) ? runStatusValue : {};
const messages = Array.isArray(sessionObject.messages)
? sessionObject.messages
: [];
const lastMessage = messages.at(-1);
const lastObject = isObject(lastMessage) ? lastMessage : {};
const metadata = isObject(lastObject.metadata) ? lastObject.metadata : {};
+ const runStatus =
+ typeof runObject.status === "string" ? runObject.status : "";
+ const runIsActive = runStatus === "queued" || runStatus === "running";
return [
sessionObject.session_id,
sessionObject.turns,
@@ -103,10 +149,11 @@ function sessionReplaySignature(session: unknown, runStatus: unknown) {
typeof lastObject.content === "string" ? lastObject.content.length : "",
metadata.elapsed_ms,
runObject.run_id,
- runObject.status,
- runObject.updated_at,
+ runStatus,
+ runObject.current_stage,
+ runIsActive ? "" : runObject.updated_at,
runObject.finished_at,
- runObject.elapsed_ms,
+ runIsActive ? "" : runObject.elapsed_ms,
].join("|");
}
diff --git a/frontend/app/public/doc-assets/technical-docs/12-runtime-guidance-queue.md b/frontend/app/public/doc-assets/technical-docs/12-runtime-guidance-queue.md
new file mode 100644
index 0000000..c896e0b
--- /dev/null
+++ b/frontend/app/public/doc-assets/technical-docs/12-runtime-guidance-queue.md
@@ -0,0 +1,125 @@
+# 运行中输入队列与 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`,在每轮模型调用前消费 pending guidance:
+
+```text
+agent loop boundary
+ -> consume_session_guidance(session_id, run_id)
+ -> append hidden user message:
+
+ 用户在任务运行中补充了以下引导...
+
+ -> display=false
+ -> 继续模型调用
+```
+
+这个注入点必须在 tool_result 已经写回之后、下一次模型调用之前,不能插在 tool_use 和 tool_result 中间。
+
+## 前端交互
+
+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. 点击停止后,对应 run 的进程和 pending queue 都被取消。
+6. 触发 compact 后,UI 仍能从 `display_messages` 回放完整历史;模型只使用 compact 后的 `model_messages`。
diff --git a/src/agent_runtime.py b/src/agent_runtime.py
index 2e79467..81a5bfe 100644
--- a/src/agent_runtime.py
+++ b/src/agent_runtime.py
@@ -298,6 +298,7 @@ class LocalCodingAgent:
team_runtime: TeamRuntime | None = None
workflow_runtime: WorkflowRuntime | None = None
worktree_runtime: WorktreeRuntime | None = None
+ runtime_guidance_provider: Callable[[], tuple[dict[str, object], ...]] | None = None
last_session: AgentSessionState | None = field(default=None, init=False, repr=False)
last_run_result: AgentRunResult | None = field(default=None, init=False, repr=False)
cumulative_usage: UsageStats = field(default_factory=UsageStats, init=False, repr=False)
@@ -734,6 +735,11 @@ class LocalCodingAgent:
return result
for turn_index in range(1, self.runtime_config.max_turns + 1):
+ self._inject_runtime_guidance(
+ session,
+ stream_events,
+ turn_index=turn_index,
+ )
self._microcompact_session_if_needed(
session,
stream_events,
@@ -4274,6 +4280,69 @@ class LocalCodingAgent:
transcript=session.transcript(),
)
+ def _inject_runtime_guidance(
+ self,
+ session: AgentSessionState,
+ stream_events: list[dict[str, object]],
+ *,
+ turn_index: int,
+ ) -> None:
+ provider = self.runtime_guidance_provider
+ if provider is None:
+ return
+ try:
+ items = tuple(
+ item
+ for item in provider()
+ if isinstance(item, dict)
+ and str(item.get('content') or '').strip()
+ )
+ except Exception as exc: # noqa: BLE001
+ stream_events.append(
+ {
+ 'type': 'runtime_guidance_error',
+ 'turn_index': turn_index,
+ 'error': str(exc)[:500],
+ }
+ )
+ return
+ if not items:
+ return
+ lines = [
+ '',
+ (
+ '用户在本轮任务运行过程中追加了以下引导。请在不丢弃当前进展的前提下'
+ '吸收这些要求;如果它们与当前目标冲突,先说明冲突并选择最合理的继续方式。'
+ ),
+ ]
+ item_ids: list[int] = []
+ for index, item in enumerate(items, start=1):
+ item_id = item.get('id')
+ if isinstance(item_id, int) and not isinstance(item_id, bool):
+ item_ids.append(item_id)
+ content = str(item.get('content') or '').strip()
+ lines.append(f'{index}. {content}')
+ lines.append('')
+ session.append_user(
+ '\n'.join(lines),
+ metadata={
+ 'kind': 'runtime_guidance',
+ 'turn_index': turn_index,
+ 'queue_item_ids': item_ids,
+ },
+ message_id=f'runtime_guidance_{turn_index}_{uuid4().hex[:8]}',
+ display=False,
+ )
+ stream_events.append(
+ {
+ 'type': 'runtime_guidance_injected',
+ 'turn_index': turn_index,
+ 'input_ids': item_ids,
+ 'count': len(items),
+ 'message': f'已将 {len(items)} 条用户引导注入当前任务',
+ }
+ )
+
def render_system_prompt(self) -> str:
prompt_context = self.build_prompt_context()
parts = self.build_system_prompt_parts(prompt_context)
diff --git a/src/run_state_store.py b/src/run_state_store.py
index 860040a..5bc3635 100644
--- a/src/run_state_store.py
+++ b/src/run_state_store.py
@@ -230,6 +230,69 @@ class RunStateStore:
return None
return self._snapshot_from_row(conn, row)
+ def events_since(
+ self,
+ account_key: str,
+ session_id: str,
+ *,
+ after_event_seq: int = 0,
+ limit: int = 500,
+ ) -> dict[str, Any]:
+ with self._connect() as conn:
+ rows = conn.execute(
+ """
+ select e.id, e.recorded_at, e.event_json, s.run_id, s.status
+ from run_events e
+ join run_states s on s.run_id = e.run_id
+ where s.account_key = ?
+ and s.session_id = ?
+ and e.id > ?
+ order by e.id asc
+ limit ?
+ """,
+ (
+ account_key,
+ session_id,
+ max(0, int(after_event_seq)),
+ max(1, int(limit)),
+ ),
+ ).fetchall()
+ latest_row = conn.execute(
+ """
+ select max(e.id) as latest_event_seq
+ from run_events e
+ join run_states s on s.run_id = e.run_id
+ where s.account_key = ?
+ and s.session_id = ?
+ """,
+ (account_key, session_id),
+ ).fetchone()
+ events: list[dict[str, Any]] = []
+ for row in rows:
+ try:
+ event = json.loads(row['event_json'])
+ except (TypeError, json.JSONDecodeError):
+ continue
+ if not isinstance(event, dict):
+ continue
+ events.append(
+ {
+ 'event_seq': int(row['id']),
+ 'run_id': row['run_id'],
+ 'run_status': row['status'],
+ 'recorded_at': float(row['recorded_at'] or 0.0),
+ 'event': event,
+ }
+ )
+ latest_event_seq = 0
+ if latest_row is not None and latest_row['latest_event_seq'] is not None:
+ latest_event_seq = int(latest_row['latest_event_seq'])
+ return {
+ 'session_id': session_id,
+ 'latest_event_seq': latest_event_seq,
+ 'events': events,
+ }
+
def mark_interrupted_if_active(
self,
run_id: str,
diff --git a/src/session_store.py b/src/session_store.py
index 1c6242d..d553cba 100644
--- a/src/session_store.py
+++ b/src/session_store.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
+import hashlib
import sqlite3
import time
from dataclasses import asdict, dataclass
@@ -84,6 +85,12 @@ def save_agent_session(session: StoredAgentSession, directory: Path | None = Non
path = session_dir / 'session.json'
payload = asdict(session)
_write_agent_session_db_payload(target_dir, session.session_id, payload, path)
+ _sync_agent_display_messages(
+ target_dir,
+ session.session_id,
+ tuple(message for message in session.display_messages if isinstance(message, dict))
+ or tuple(message for message in session.messages if isinstance(message, dict)),
+ )
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding='utf-8')
return path
@@ -131,6 +138,266 @@ def list_agent_sessions(
return sorted(sessions.values(), key=lambda item: item[1], reverse=True)
+def agent_session_delta(
+ session_id: str,
+ directory: Path | None = None,
+ *,
+ after_message_seq: int = 0,
+ limit: int = 500,
+) -> dict[str, Any]:
+ target_dir = directory or DEFAULT_AGENT_SESSION_DIR
+ try:
+ stored = load_agent_session(session_id, directory=target_dir)
+ except (FileNotFoundError, json.JSONDecodeError, KeyError, TypeError, ValueError):
+ stored = None
+ if stored is not None:
+ _sync_agent_display_messages(
+ target_dir,
+ session_id,
+ stored.display_messages or stored.messages,
+ )
+ with _connect_agent_session_db(target_dir) as conn:
+ latest_row = conn.execute(
+ """
+ select max(seq) as latest_seq
+ from agent_display_messages
+ where session_id = ?
+ """,
+ (session_id,),
+ ).fetchone()
+ rows = conn.execute(
+ """
+ select seq, message_json, updated_at
+ from agent_display_messages
+ where session_id = ? and seq > ?
+ order by seq asc
+ limit ?
+ """,
+ (session_id, max(0, int(after_message_seq)), max(1, int(limit))),
+ ).fetchall()
+ messages: list[dict[str, Any]] = []
+ for row in rows:
+ try:
+ message = json.loads(row['message_json'])
+ except (TypeError, json.JSONDecodeError):
+ continue
+ if isinstance(message, dict):
+ messages.append(
+ {
+ 'seq': int(row['seq']),
+ 'updated_at': float(row['updated_at'] or 0.0),
+ 'message': message,
+ }
+ )
+ latest_seq = 0
+ if latest_row is not None and latest_row['latest_seq'] is not None:
+ latest_seq = int(latest_row['latest_seq'])
+ return {
+ 'session_id': session_id,
+ 'latest_message_seq': latest_seq,
+ 'messages': messages,
+ 'session': (
+ {
+ 'session_id': stored.session_id,
+ 'turns': stored.turns,
+ 'tool_calls': stored.tool_calls,
+ 'usage': stored.usage,
+ 'total_cost_usd': stored.total_cost_usd,
+ 'model': stored.model_config.get('model'),
+ 'is_training': stored.is_training,
+ 'session_metadata': stored.session_metadata or {},
+ }
+ if stored is not None
+ else {'session_id': session_id}
+ ),
+ }
+
+
+def enqueue_session_input(
+ session_id: str,
+ *,
+ content: str,
+ directory: Path | None = None,
+ run_id: str | None = None,
+ kind: str = 'next_turn',
+ status: str = 'pending',
+ metadata: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+ target_dir = directory or DEFAULT_AGENT_SESSION_DIR
+ now = time.time()
+ normalized_kind = kind if kind in {'next_turn', 'guidance'} else 'next_turn'
+ with _connect_agent_session_db(target_dir) as conn:
+ cursor = conn.execute(
+ """
+ insert into agent_input_queue (
+ session_id, run_id, kind, status, content,
+ created_at, updated_at, metadata_json
+ )
+ values (?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ session_id,
+ run_id or '',
+ normalized_kind,
+ status,
+ content,
+ now,
+ now,
+ json.dumps(metadata or {}, ensure_ascii=False, sort_keys=True),
+ ),
+ )
+ item_id = int(cursor.lastrowid)
+ return {
+ 'id': item_id,
+ 'session_id': session_id,
+ 'run_id': run_id or '',
+ 'kind': normalized_kind,
+ 'status': status,
+ 'content': content,
+ 'created_at': now,
+ 'updated_at': now,
+ 'metadata': metadata or {},
+ }
+
+
+def list_session_input_queue(
+ session_id: str,
+ directory: Path | None = None,
+ *,
+ statuses: tuple[str, ...] = ('pending',),
+ limit: int = 100,
+) -> list[dict[str, Any]]:
+ target_dir = directory or DEFAULT_AGENT_SESSION_DIR
+ placeholders = ','.join('?' for _ in statuses)
+ with _connect_agent_session_db(target_dir) as conn:
+ rows = conn.execute(
+ f"""
+ select *
+ from agent_input_queue
+ where session_id = ?
+ and status in ({placeholders})
+ order by id asc
+ limit ?
+ """,
+ (session_id, *statuses, max(1, int(limit))),
+ ).fetchall()
+ return [_queue_row_to_dict(row) for row in rows]
+
+
+def update_session_input_queue_item(
+ session_id: str,
+ item_id: int,
+ directory: Path | None = None,
+ *,
+ content: str | None = None,
+ kind: str | None = None,
+ status: str | None = None,
+ run_id: str | None = None,
+) -> dict[str, Any] | None:
+ target_dir = directory or DEFAULT_AGENT_SESSION_DIR
+ assignments: list[str] = ['updated_at = ?']
+ values: list[Any] = [time.time()]
+ if content is not None:
+ assignments.append('content = ?')
+ values.append(content)
+ if kind is not None:
+ assignments.append('kind = ?')
+ values.append(kind if kind in {'next_turn', 'guidance'} else 'next_turn')
+ if status is not None:
+ assignments.append('status = ?')
+ values.append(status)
+ if status in {'consumed', 'cancelled'}:
+ assignments.append('consumed_at = coalesce(consumed_at, ?)')
+ values.append(time.time())
+ if run_id is not None:
+ assignments.append('run_id = ?')
+ values.append(run_id)
+ values.extend([session_id, int(item_id)])
+ with _connect_agent_session_db(target_dir) as conn:
+ conn.execute(
+ f"""
+ update agent_input_queue
+ set {', '.join(assignments)}
+ where session_id = ? and id = ?
+ """,
+ values,
+ )
+ row = conn.execute(
+ """
+ select *
+ from agent_input_queue
+ where session_id = ? and id = ?
+ """,
+ (session_id, int(item_id)),
+ ).fetchone()
+ return _queue_row_to_dict(row) if row is not None else None
+
+
+def cancel_session_input_queue(
+ session_id: str,
+ directory: Path | None = None,
+ *,
+ run_id: str | None = None,
+) -> int:
+ target_dir = directory or DEFAULT_AGENT_SESSION_DIR
+ now = time.time()
+ where = 'session_id = ? and status = ?'
+ values: list[Any] = [session_id, 'pending']
+ if run_id:
+ where += ' and (run_id = ? or run_id = ?)'
+ values.extend([run_id, ''])
+ with _connect_agent_session_db(target_dir) as conn:
+ cursor = conn.execute(
+ f"""
+ update agent_input_queue
+ set status = 'cancelled',
+ updated_at = ?,
+ consumed_at = coalesce(consumed_at, ?)
+ where {where}
+ """,
+ (now, now, *values),
+ )
+ return int(cursor.rowcount or 0)
+
+
+def consume_session_guidance(
+ session_id: str,
+ run_id: str,
+ directory: Path | None = None,
+ *,
+ limit: int = 10,
+) -> list[dict[str, Any]]:
+ target_dir = directory or DEFAULT_AGENT_SESSION_DIR
+ now = time.time()
+ with _connect_agent_session_db(target_dir) as conn:
+ rows = conn.execute(
+ """
+ select *
+ from agent_input_queue
+ where session_id = ?
+ and status = 'pending'
+ and kind = 'guidance'
+ and (run_id = ? or run_id = '')
+ order by id asc
+ limit ?
+ """,
+ (session_id, run_id, max(1, int(limit))),
+ ).fetchall()
+ ids = [int(row['id']) for row in rows]
+ if ids:
+ conn.execute(
+ f"""
+ update agent_input_queue
+ set status = 'consumed',
+ updated_at = ?,
+ consumed_at = ?
+ where id in ({','.join('?' for _ in ids)})
+ """,
+ (now, now, *ids),
+ )
+ return [_queue_row_to_dict(row) for row in rows]
+
+
def delete_agent_session(session_id: str, directory: Path | None = None) -> bool:
target_dir = directory or DEFAULT_AGENT_SESSION_DIR
deleted = False
@@ -140,6 +407,14 @@ def delete_agent_session(session_id: str, directory: Path | None = None) -> bool
(session_id,),
)
deleted = cursor.rowcount > 0
+ conn.execute(
+ 'delete from agent_display_messages where session_id = ?',
+ (session_id,),
+ )
+ conn.execute(
+ 'delete from agent_input_queue where session_id = ?',
+ (session_id,),
+ )
nested = target_dir / session_id
if nested.exists():
import shutil
@@ -288,6 +563,195 @@ def _write_agent_session_revision(
)
+def _sync_agent_display_messages(
+ directory: Path,
+ session_id: str,
+ display_messages: tuple[JSONDict, ...],
+) -> None:
+ if not display_messages:
+ return
+ now = time.time()
+ with _connect_agent_session_db(directory) as conn:
+ existing_rows = conn.execute(
+ """
+ select seq, message_key, message_json
+ from agent_display_messages
+ where session_id = ?
+ """,
+ (session_id,),
+ ).fetchall()
+ existing: dict[int, sqlite3.Row] = {
+ int(row['seq']): row for row in existing_rows
+ }
+ existing_by_key: dict[str, sqlite3.Row] = {
+ str(row['message_key'] or ''): row
+ for row in existing_rows
+ if str(row['message_key'] or '')
+ }
+ next_seq = max(existing.keys(), default=0) + 1
+ for index, message in enumerate(display_messages, start=1):
+ if not isinstance(message, dict):
+ continue
+ message_json = json.dumps(message, ensure_ascii=False, sort_keys=True)
+ message_key = _display_message_key(message)
+ row = existing.get(index)
+ keyed_row = existing_by_key.get(message_key)
+ if keyed_row is not None and keyed_row['message_json'] == message_json:
+ continue
+ if keyed_row is not None:
+ try:
+ keyed_old_message = json.loads(keyed_row['message_json'])
+ except (TypeError, json.JSONDecodeError):
+ keyed_old_message = {}
+ if not isinstance(keyed_old_message, dict):
+ keyed_old_message = {}
+ if _is_replaceable_display_message(keyed_old_message):
+ conn.execute(
+ """
+ update agent_display_messages
+ set role = ?,
+ updated_at = ?,
+ message_json = ?
+ where session_id = ? and seq = ?
+ """,
+ (
+ str(message.get('role') or ''),
+ now,
+ message_json,
+ session_id,
+ int(keyed_row['seq']),
+ ),
+ )
+ continue
+ if row is None:
+ conn.execute(
+ """
+ insert into agent_display_messages (
+ session_id, seq, message_key, role, updated_at, message_json
+ )
+ values (?, ?, ?, ?, ?, ?)
+ """,
+ (
+ session_id,
+ index,
+ message_key,
+ str(message.get('role') or ''),
+ now,
+ message_json,
+ ),
+ )
+ continue
+ if row['message_json'] == message_json:
+ continue
+ try:
+ old_message = json.loads(row['message_json'])
+ except (TypeError, json.JSONDecodeError):
+ old_message = {}
+ if not isinstance(old_message, dict):
+ old_message = {}
+ old_key = str(row['message_key'] or '')
+ should_update = (
+ old_key == message_key
+ or _is_replaceable_display_message(old_message)
+ or _same_role_and_content(old_message, message)
+ )
+ if not should_update:
+ append_seq = next_seq
+ next_seq += 1
+ conn.execute(
+ """
+ insert into agent_display_messages (
+ session_id, seq, message_key, role, updated_at, message_json
+ )
+ values (?, ?, ?, ?, ?, ?)
+ """,
+ (
+ session_id,
+ append_seq,
+ message_key,
+ str(message.get('role') or ''),
+ now,
+ message_json,
+ ),
+ )
+ existing_by_key[message_key] = {
+ 'seq': append_seq,
+ 'message_key': message_key,
+ 'message_json': message_json,
+ } # type: ignore[assignment]
+ continue
+ conn.execute(
+ """
+ update agent_display_messages
+ set message_key = ?,
+ role = ?,
+ updated_at = ?,
+ message_json = ?
+ where session_id = ? and seq = ?
+ """,
+ (
+ message_key,
+ str(message.get('role') or ''),
+ now,
+ message_json,
+ session_id,
+ index,
+ ),
+ )
+
+
+def _display_message_key(message: JSONDict) -> str:
+ message_id = message.get('message_id')
+ if isinstance(message_id, str) and message_id:
+ return f'id:{message_id}'
+ tool_call_id = message.get('tool_call_id')
+ if isinstance(tool_call_id, str) and tool_call_id:
+ return f'tool:{tool_call_id}'
+ role = str(message.get('role') or '')
+ content = '' if message.get('content') is None else str(message.get('content') or '')
+ digest = hashlib.sha1(f'{role}\0{content}'.encode('utf-8')).hexdigest()
+ return f'hash:{digest}'
+
+
+def _is_replaceable_display_message(message: JSONDict) -> bool:
+ metadata = message.get('metadata')
+ if not isinstance(metadata, dict):
+ return False
+ if metadata.get('placeholder') is True:
+ return True
+ return metadata.get('kind') in {'run_status'}
+
+
+def _same_role_and_content(left: JSONDict, right: JSONDict) -> bool:
+ return (
+ str(left.get('role') or '') == str(right.get('role') or '')
+ and str(left.get('content') or '') == str(right.get('content') or '')
+ )
+
+
+def _queue_row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
+ try:
+ metadata = json.loads(row['metadata_json'] or '{}')
+ except (TypeError, json.JSONDecodeError):
+ metadata = {}
+ return {
+ 'id': int(row['id']),
+ 'session_id': str(row['session_id'] or ''),
+ 'run_id': str(row['run_id'] or ''),
+ 'kind': str(row['kind'] or ''),
+ 'status': str(row['status'] or ''),
+ 'content': str(row['content'] or ''),
+ 'created_at': float(row['created_at'] or 0.0),
+ 'updated_at': float(row['updated_at'] or 0.0),
+ 'consumed_at': (
+ float(row['consumed_at'])
+ if row['consumed_at'] is not None
+ else None
+ ),
+ 'metadata': metadata if isinstance(metadata, dict) else {},
+ }
+
+
def _read_agent_session_db_payload(
directory: Path,
session_id: str,
@@ -365,6 +829,53 @@ def _connect_agent_session_db(directory: Path) -> sqlite3.Connection:
on agent_session_revisions(session_id, revision_id)
"""
)
+ conn.execute(
+ """
+ create table if not exists agent_display_messages (
+ session_id text not null,
+ seq integer not null,
+ message_key text not null,
+ role text default '',
+ updated_at real not null,
+ message_json text not null,
+ primary key(session_id, seq)
+ )
+ """
+ )
+ conn.execute(
+ """
+ create index if not exists idx_agent_display_messages_session
+ on agent_display_messages(session_id, seq)
+ """
+ )
+ conn.execute(
+ """
+ create table if not exists agent_input_queue (
+ id integer primary key autoincrement,
+ session_id text not null,
+ run_id text default '',
+ kind text not null,
+ status text not null,
+ content text not null,
+ created_at real not null,
+ updated_at real not null,
+ consumed_at real,
+ metadata_json text default '{}'
+ )
+ """
+ )
+ conn.execute(
+ """
+ create index if not exists idx_agent_input_queue_session
+ on agent_input_queue(session_id, status, id)
+ """
+ )
+ conn.execute(
+ """
+ create index if not exists idx_agent_input_queue_run
+ on agent_input_queue(session_id, run_id, status, id)
+ """
+ )
return conn