实现后台Agent真流式回复

接入身份绑定的后台最终回复流并保持唯一finalization提交
完善Project Supervisor与Swarm CLI增量展示、恢复和竞态处理
禁止Runtime Provider重定向重放并加固生命周期与公共审计脱敏
增加真实Provider隔离验收、定向回归和技术文档
This commit is contained in:
AIGameCreator App
2026-07-15 15:58:02 +08:00
parent fda565f40b
commit 3f5424a0b1
12 changed files with 5280 additions and 273 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -4,6 +4,23 @@ pub(crate) fn build_game_creator_llm_client_from_llm_config(
llm: &GameCreatorLlmConfig,
config_path: &str,
) -> Result<LlmClient, String> {
let config = build_game_creator_platform_llm_config(llm, config_path)?;
LlmClient::new(config).map_err(|error| format!("LLM client 初始化失败:{error}"))
}
pub(crate) fn build_game_creator_llm_client_without_redirects_from_llm_config(
llm: &GameCreatorLlmConfig,
config_path: &str,
) -> Result<LlmClient, String> {
let config = build_game_creator_platform_llm_config(llm, config_path)?;
LlmClient::new_without_redirects(config)
.map_err(|error| format!("LLM client 初始化失败:{error}"))
}
fn build_game_creator_platform_llm_config(
llm: &GameCreatorLlmConfig,
config_path: &str,
) -> Result<LlmConfig, String> {
let api_key =
trim_config_string(&llm.api_key).ok_or_else(|| llm_api_key_config_error(config_path))?;
let base_url =
@@ -11,7 +28,7 @@ pub(crate) fn build_game_creator_llm_client_from_llm_config(
let model =
trim_config_string(&llm.model).ok_or_else(|| llm_model_config_error(config_path))?;
validate_game_creator_llm_timing_config(llm, config_path)?;
let config = LlmConfig::new(
LlmConfig::new(
LlmProvider::OpenAiCompatible,
base_url,
api_key,
@@ -20,9 +37,7 @@ pub(crate) fn build_game_creator_llm_client_from_llm_config(
llm.max_retries,
llm.retry_backoff_ms,
)
.map_err(|error| format!("LLM 配置无效:{error}"))?;
LlmClient::new(config).map_err(|error| format!("LLM client 初始化失败:{error}"))
.map_err(|error| format!("LLM 配置无效:{error}"))
}
pub(crate) fn build_game_creator_llm_client_from_config() -> Result<LlmClient, String> {
@@ -421,6 +421,26 @@ struct AgentRuntimeEvent {
updated_at: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct AgentRuntimeResponseStream {
schema_version: String,
agent_id: String,
task_id: String,
session_id: String,
run_id: String,
request_kind: String,
request_slot: String,
applied_steer_cursor: u64,
response_revision: u64,
sequence: u64,
status: String,
accumulated_text: String,
finish_reason: Option<String>,
started_at: u64,
updated_at: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeTaskRecord {
@@ -514,6 +534,7 @@ struct AgentRuntimeResult {
task_queue: AgentRuntimeTaskQueueSummary,
recent_events: Vec<AgentRuntimeEvent>,
recent_tasks: Vec<AgentRuntimeTaskRecord>,
response_stream: Option<AgentRuntimeResponseStream>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+228 -7
View File
@@ -304,6 +304,8 @@ interface AgentRuntimeState {
taskQueue?: AgentRuntimeTaskQueueSummary;
allowedTools: string[];
toolPolicy?: AgentRuntimeToolPolicySnapshot;
appliedSteerCursor?: number;
queuedSteerCount?: number;
lastResponse: string | null;
error: string | null;
updatedAt: number;
@@ -406,6 +408,32 @@ interface AgentRuntimeResult {
taskQueue?: AgentRuntimeTaskQueueSummary;
recentEvents?: AgentRuntimeEventRecord[];
recentTasks?: AgentRuntimeTaskRecord[];
responseStream?: AgentRuntimeResponseStream | null;
}
type AgentRuntimeResponseStreamStatus =
| 'streaming'
| 'ready'
| 'committed'
| 'discarded'
| 'failed';
interface AgentRuntimeResponseStream {
schemaVersion: string;
agentId: string;
taskId: string;
sessionId: string;
runId: string;
requestKind: string;
requestSlot: string;
appliedSteerCursor: number;
responseRevision: number;
sequence: number;
status: AgentRuntimeResponseStreamStatus;
accumulatedText: string;
finishReason: string | null;
startedAt: number;
updatedAt: number;
}
interface AgentGoalRecord {
@@ -1022,6 +1050,118 @@ function agentRuntimeStateFromResult(
);
}
function normalizeProjectSupervisorResponseStream(
stream: AgentRuntimeResponseStream | null | undefined,
runtime: AgentRuntimeState,
) {
if (!stream) {
return null;
}
const integerFields = [
stream.appliedSteerCursor,
stream.responseRevision,
stream.sequence,
stream.startedAt,
stream.updatedAt,
];
if (
stream.schemaVersion !== 'game-creator-runtime-response-stream.v1' ||
stream.agentId !== PROJECT_SUPERVISOR_AGENT_ID ||
stream.agentId !== runtime.agentId ||
stream.taskId !== runtime.taskId ||
stream.sessionId !== runtime.sessionId ||
stream.runId !== runtime.runId ||
stream.requestKind !== 'final-reply' ||
!stream.requestSlot.trim() ||
integerFields.some(
(value) =>
typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0,
) ||
stream.startedAt <= 0 ||
stream.updatedAt < stream.startedAt ||
typeof stream.accumulatedText !== 'string' ||
Array.from(stream.accumulatedText).length > 32_000 ||
/<\/?think>/i.test(stream.accumulatedText)
) {
return null;
}
if (
typeof runtime.appliedSteerCursor === 'number' &&
stream.appliedSteerCursor !== runtime.appliedSteerCursor
) {
return null;
}
if ((runtime.queuedSteerCount ?? 0) > 0) {
return null;
}
if (
typeof runtime.loopIteration === 'number' &&
Number.isSafeInteger(runtime.loopIteration) &&
stream.requestSlot !==
`final-reply-loop-${runtime.loopIteration}-revision-${stream.responseRevision}`
) {
return null;
}
if (
(stream.status === 'streaming' || stream.status === 'ready') &&
(runtime.status !== 'running' ||
!['response', 'finalizing'].includes(runtime.phase))
) {
return null;
}
if (stream.status === 'ready' && !stream.accumulatedText.trim()) {
return null;
}
return stream;
}
function sameProjectSupervisorResponseStream(
left: AgentRuntimeResponseStream,
right: AgentRuntimeResponseStream,
) {
return (
left.agentId === right.agentId &&
left.taskId === right.taskId &&
left.sessionId === right.sessionId &&
left.runId === right.runId &&
left.requestKind === right.requestKind &&
left.requestSlot === right.requestSlot &&
left.appliedSteerCursor === right.appliedSteerCursor &&
left.responseRevision === right.responseRevision
);
}
function mergeProjectSupervisorResponseStream(
current: AgentRuntimeResponseStream | null,
incoming: AgentRuntimeResponseStream | null | undefined,
runtime: AgentRuntimeState,
) {
const currentForRuntime = normalizeProjectSupervisorResponseStream(
current,
runtime,
);
if (!incoming) {
return null;
}
const next = normalizeProjectSupervisorResponseStream(incoming, runtime);
if (!next) {
return currentForRuntime;
}
if (next.status !== 'streaming' && next.status !== 'ready') {
return null;
}
if (
!currentForRuntime ||
!sameProjectSupervisorResponseStream(currentForRuntime, next)
) {
return next;
}
if (next.sequence <= currentForRuntime.sequence) {
return currentForRuntime;
}
return next;
}
function agentRuntimeWaitingOnFromPhase(phase: string) {
switch (phase) {
case 'planning':
@@ -2259,6 +2399,9 @@ function projectSupervisorRuntimeStatusLabel(
) {
return '执行';
}
if (runtime.phase === 'response' || runtime.phase === 'finalizing') {
return '回复中';
}
if (runtime.status === 'cancelled' || runtime.phase === 'cancelled') {
return '已取消';
}
@@ -15409,6 +15552,8 @@ export function App() {
>(null);
const [projectSupervisorRuntime, setProjectSupervisorRuntime] =
useState<AgentRuntimeState | null>(null);
const [projectSupervisorResponseStream, setProjectSupervisorResponseStream] =
useState<AgentRuntimeResponseStream | null>(null);
const [projectSupervisorRuntimeError, setProjectSupervisorRuntimeError] =
useState('');
const chatInputRef = useRef<HTMLInputElement | null>(null);
@@ -15534,6 +15679,9 @@ export function App() {
projectSupervisorSessionIdRef.current = projectSupervisorSessionId;
const projectSupervisorRuntimeRef = useRef<AgentRuntimeState | null>(null);
projectSupervisorRuntimeRef.current = projectSupervisorRuntime;
const projectSupervisorResponseStreamRef =
useRef<AgentRuntimeResponseStream | null>(null);
projectSupervisorResponseStreamRef.current = projectSupervisorResponseStream;
const projectSupervisorRuntimeSyncingRef = useRef(new Set<string>());
const projectSupervisorRefreshConversationRef = useRef<
| ((
@@ -15565,14 +15713,29 @@ export function App() {
setProjectSupervisorRuntime(nextRuntime);
}
function updateProjectSupervisorResponseStream(
incoming: AgentRuntimeResponseStream | null | undefined,
runtime: AgentRuntimeState,
) {
const nextStream = mergeProjectSupervisorResponseStream(
projectSupervisorResponseStreamRef.current,
incoming,
runtime,
);
projectSupervisorResponseStreamRef.current = nextStream;
setProjectSupervisorResponseStream(nextStream);
}
function resetProjectSupervisorState() {
projectSupervisorHistoryLoadVersionRef.current += 1;
projectSupervisorRuntimeResumeProjectPathRef.current = null;
projectSupervisorSessionIdRef.current = null;
projectSupervisorRuntimeRef.current = null;
projectSupervisorResponseStreamRef.current = null;
projectSupervisorRuntimeSyncingRef.current.clear();
setProjectSupervisorSessionId(null);
setProjectSupervisorRuntime(null);
setProjectSupervisorResponseStream(null);
setProjectSupervisorRuntimeError('');
}
@@ -15702,7 +15865,8 @@ export function App() {
(expectedSessionId !== null &&
nextRuntime.sessionId !== expectedSessionId) ||
(currentRuntime !== null &&
!sameAgentRuntimeRun(nextRuntime, currentRuntime))
(!sameAgentRuntimeRun(nextRuntime, currentRuntime) ||
nextRuntime.updatedAt < currentRuntime.updatedAt))
) {
return;
}
@@ -15711,6 +15875,10 @@ export function App() {
setProjectSupervisorSessionId(nextRuntime.sessionId);
}
updateProjectSupervisorRuntime(nextRuntime);
updateProjectSupervisorResponseStream(
payload.runtime.responseStream,
nextRuntime,
);
setProjectSupervisorRuntimeError('');
if (invoke) {
syncTerminalProjectSupervisorConversation(
@@ -15782,6 +15950,9 @@ export function App() {
return;
}
inFlight = true;
const runtimeBeforePoll = projectSupervisorRuntimeRef.current;
const responseStreamBeforePoll =
projectSupervisorResponseStreamRef.current;
try {
const result = await invoke<AgentRuntimeResult>(
'read_game_creator_agent_runtime',
@@ -15791,20 +15962,32 @@ export function App() {
sessionId,
},
);
const nextRuntime = agentRuntimeStateFromResult(
result,
projectSupervisorRuntimeRef.current,
);
const currentRuntime = projectSupervisorRuntimeRef.current;
if (
disposed ||
currentRuntime !== runtimeBeforePoll ||
projectSupervisorResponseStreamRef.current !==
responseStreamBeforePoll ||
localProjectPathRef.current !== nextProjectPath ||
projectSupervisorSessionIdRef.current !== sessionId ||
projectSupervisorSessionIdRef.current !== sessionId
) {
return;
}
const nextRuntime = agentRuntimeStateFromResult(result, currentRuntime);
if (
nextRuntime.sessionId !== sessionId ||
nextRuntime.runId !== trackedRunId
nextRuntime.runId !== trackedRunId ||
(currentRuntime !== null &&
sameAgentRuntimeRun(nextRuntime, currentRuntime) &&
nextRuntime.updatedAt < currentRuntime.updatedAt)
) {
return;
}
updateProjectSupervisorRuntime(nextRuntime);
updateProjectSupervisorResponseStream(
result.responseStream,
nextRuntime,
);
setProjectSupervisorRuntimeError('');
syncTerminalProjectSupervisorConversation(
invoke,
@@ -16653,6 +16836,19 @@ export function App() {
projectConversation.messages,
supervisorConversation.messages,
);
const transientResponse = projectSupervisorResponseStreamRef.current;
if (
transientResponse &&
supervisorConversation.messages.some(
(message) =>
message.role === 'assistant' &&
message.content === transientResponse.accumulatedText &&
message.updatedAt >= transientResponse.startedAt,
)
) {
projectSupervisorResponseStreamRef.current = null;
setProjectSupervisorResponseStream(null);
}
savedConversationProjectPathRef.current = nextProjectPath;
savedConversationCountRef.current = conversationMessages.length;
latestMessagesRef.current = conversationMessages;
@@ -16712,6 +16908,7 @@ export function App() {
);
let supervisorConversation: LocalConversationResult | null = null;
let runtime: AgentRuntimeState | null = null;
let runtimeResponseStream: AgentRuntimeResponseStream | null = null;
let runtimeError = '';
if (sessionId) {
supervisorConversation = await invoke<LocalConversationResult>(
@@ -16732,6 +16929,7 @@ export function App() {
},
);
runtime = agentRuntimeStateFromResult(runtimeResult);
runtimeResponseStream = runtimeResult.responseStream ?? null;
} catch (error) {
runtimeError = error instanceof Error ? error.message : String(error);
}
@@ -16745,6 +16943,12 @@ export function App() {
projectSupervisorSessionIdRef.current = sessionId;
setProjectSupervisorSessionId(sessionId);
updateProjectSupervisorRuntime(runtime);
if (runtime) {
updateProjectSupervisorResponseStream(runtimeResponseStream, runtime);
} else {
projectSupervisorResponseStreamRef.current = null;
setProjectSupervisorResponseStream(null);
}
setProjectSupervisorRuntimeError(runtimeError || resumeError);
const conversationMessages = mergeProjectSupervisorConversation(
projectConversation.messages,
@@ -20301,6 +20505,10 @@ export function App() {
throw new Error('项目总控 Agent Runtime Session 身份不匹配');
}
updateProjectSupervisorRuntime(runtime);
updateProjectSupervisorResponseStream(
runtimeResult.responseStream,
runtime,
);
setProjectSupervisorRuntimeError('');
const refreshConversation =
projectSupervisorRefreshConversationRef.current;
@@ -20398,6 +20606,7 @@ export function App() {
return;
}
updateProjectSupervisorRuntime(nextRuntime);
updateProjectSupervisorResponseStream(result.responseStream, nextRuntime);
setCommandLog((current) => [
...current,
`agent.runtime.${decision} project-supervisor`,
@@ -24627,6 +24836,8 @@ export function App() {
0,
messages.length - visibleMessages.length,
);
const projectSupervisorTransientReply =
projectSupervisorResponseStream?.accumulatedText.trim() ?? '';
const projectSupervisorStatus = projectSupervisorRuntimeStatusLabel(
projectSupervisorRuntime,
projectSupervisorRuntimeError,
@@ -25261,6 +25472,16 @@ export function App() {
) : null}
</Fragment>
))}
{projectSupervisorTransientReply ? (
<p
className="message message--assistant"
aria-label="项目总控 Agent 实时回复"
aria-live="polite"
data-runtime-owned="true"
>
{projectSupervisorTransientReply}
</p>
) : null}
</div>
{projectSupervisorStatus ? (
<section
File diff suppressed because it is too large Load Diff
@@ -4599,6 +4599,15 @@
- 边界:短入口只复用现有 Swarm CLI、External Runner、Supervisor active Session、conversation、黑板、记忆和 durable 委派协议,不新增 Agent、HTTP 服务、数据库或旁路 Provider 调用。
- 验收:CLI 单测覆盖省略 ID 默认总控和显式 ID 兼容;真实入口 smoke 用一次性项目启动 `agc:chat`,终端显示 `project-supervisor`、创建空总控 Session,并在未发起 LLM 请求时通过 `/quit` 正常退出和清理。
## 2026-07-15 后台 Agent 最终回复使用真实增量流
- 决策:对标 Codex streamed agent events 时,现有 Runtime state/event 继续承担工具和阶段进度,只有 `phase=response` 的最终用户可见回复输出 Provider SSE deltaplanning、function arguments、thinking 和 observation 不进入流,也不允许客户端拆字伪装。
- 持久边界:`.agent/runtime/response-streams/<agentHash>/<runHash>.json` 是绑定 Agent/task/Session/run/request slot/steer cursor/revision 的私有、可丢失展示缓存。路径 hash 取稳定身份 SHA-256 十六进制前 32 位;conversation assistant、Provider lifecycle、finalization journal 和 Runtime task/state 仍是完成事实源,公共审计只存流状态、sequence、字符数和哈希。
- 控制边界:流式配置只改变同一 lifecycle 唯一物理请求的传输方式,不增加 fallback 重放。steer、Goal 控制、取消、失败、revision 漂移和 reconciliation 会让旧流失效;最终候选仍经过原 verification/plan/Goal/finalization 门禁并恰好一次写入 assistant。
- 客户端:普通 Project Supervisor 用 runtimeOwned 临时 assistant 渲染匹配流,刷新从 Runtime 轮询恢复;CLI 按 accumulated text 增量输出并避免 settle 后重复整段。真实验收必须证明至少两个公开 delta 先于终态、最终全文一致、单物理请求和公共面零正文泄漏。
- 审计收口:`project.verify` 执行后只允许把精确的 `.agent/logs/command.log` 相对路径写入 Agent DBexpectedCommand 和 output 在公共审计落盘前必须替换项目根路径,其中 output 保留有界尾部供诊断。路径不在该精确位置时,执行结果进入 reconciliation,不能把宿主绝对路径写入公共面。
- 验收:2026-07-15 真实 `gpt-5.5` `response-stream` suite PASS。39 个不同非空快照先于终态,sequence `1 -> 418 -> 425 committed`,最终 883 字;唯一 assistant、唯一 final-reply `started -> completed` lifecycle、4 段 finalizationfallback replay、重复 message/receipt 均为 0。上游物理请求数未直接观测,报告明确使用 lifecycle slot 与 canonical response identity 证明模式。公共正文、API Key、thinking、诱饵、项目路径和 transcript/report 路径泄漏均为 0,隔离 Runner/AppData/项目完成精确清理。
## 2026-07-13 普通微信支付 V3 退款使用统一观察事务闭环
- 背景:普通微信支付 V3 的退款申请响应、退款结果回调、主动查单和商户平台手工退款发现可能重复、乱序或只出现其中一种;原充值订单只有单一终态,无法表达多次部分退款、权益回收欠款和会员人工处理。
@@ -802,11 +802,41 @@ V1.18 对标 Codex CLI `/goal` 的长任务语义:目标文本既是首轮任
- 最终 Goal、Runtime 和最新 task 必须在原 Agent/Session/run 上 completed,结构化计划全部完成,Goal completion evidence 必须精确匹配当前 Goal/plan/verification/run/session;同一 finalizationId 必须按严格七槽物理顺序形成完整记录,finalization sidecar 最终不残留,目标 Session 只允许一个 assistant。Goal sidecar、task JSONL、Runtime state、v4 context 和 conversation 属于本地私有执行事实,可包含完成目标所需正文;event、Agent DB、receipt、activity、output 与最终报告不得保存 task、Goal/steer、委派任务、verify 命令或 error 正文,只允许身份/状态、SHA-256、字符/字节/条目计数和经 URL、项目根、其它绝对路径及凭据清洗的有界摘要。公共 task 统一不保留正文;委派只保留 `taskSha256 / taskChars`verify 只保留脚本安全标识、`expectedCommandSha256 / expectedCommandChars`、timeout 和结果计数,error 只保留 kind/fingerprint/chars 或脱敏摘要,禁止任何正文、preview、head 或 tail。验收必须扫描完整 Goal/编辑/委派/verify/error canary、已加载密钥和一次性项目绝对路径在全部公共持久面泄漏为 0,并确认动作、消息、receipt 和 Provider lifecycle 均无重复。
- 截至 2026-07-15V1.18 真实 Provider 门禁尚未通过。最新保留现场在首轮 planning、`planRevision=0`、零 pending action/observation 时由对端关闭长 TLS 连接;同一发布配置、模型和 Rust native-tls 客户端的最小单 Agent 请求在 25.2 秒成功,证明基础鉴权与短请求通道可用,但不能外推为工具 planning 或 Goal 长链路 PASS。Provider 长请求恢复后仍需完整执行上一条一次性项目验收。
## V1.19 后台 Agent 真流式最终回复
V1.19 对标 Codex 富客户端的增量 turn 事件:工具开始、完成和等待继续沿用现有 Runtime state/event;只有已经进入 `phase=response` 的用户可见最终回复允许输出 Provider 文本 delta。后台工具 planning、function arguments、`thinkingSummary`、原始 observation 和修复上下文不得进入流。禁止前端拆字、定时补字或先生成完整正文再伪装流式。
### 流身份与私有快照
- 新增 `game-creator-runtime-response-stream.v1` 私有快照,路径固定为 `.agent/runtime/response-streams/<agentHash>/<runHash>.json`,两个 hash 都取稳定身份 SHA-256 十六进制前 32 位。记录绑定 `agentId / taskId / sessionId / runId / requestKind=final-reply / requestSlot / appliedSteerCursor / responseRevision`,并保存单调 `sequence``status=streaming|ready|committed|discarded|failed``accumulatedText`、可选 `finishReason` 和时间。正文最多 32000 字符;路径、标识、schema、状态、sequence 和正文限制任一不合法时只关闭该展示流,不能把不可信内容显示给用户或据此恢复 Runtime。
- 流快照是可丢失的本地展示缓存,不是 assistant、Provider lifecycle、任务完成或 finalization 的事实源。写入采用同路径原子替换并允许节流;Tauri 关闭、CLI 断线或单次快照写失败不能让已经可靠完成的 Provider 请求失败。`AgentRuntimeResult.responseStream` 只在快照与当前 Runtime 的 Agent/Session/run、`phase=response|finalizing|completed`、steer cursor 和请求身份一致时返回。
- 开始新的 final-reply request slot 时先写空 `streaming` 快照。SSE delta 只在经过增量 `<think>...</think>` 过滤后追加;标记可跨 chunk,未闭合 thinking 永不外显。sequence 只随公开 accumulated text 或 finish reason 的真实变化增加。Provider 完整返回后用最终 `strip_llm_thinking_blocks` 结果校准为 `ready`,确保草稿与最终候选一致。
### Provider、控制与 finalization 边界
- 后台 Agent 继续严格服从 `agentLlm.<agent>.stream`:为 `true` 时即使 planning 已带候选 `response`,也必须进入独立 final-reply lifecycle,并由该 lifecycle 的唯一物理请求使用 `LlmClient::stream_run`;planning 候选只在这次请求失败时作为 fallback。为 `false` 时可直接采用完整 planning response;只有 planning 未带 response 而确需独立 final-reply lifecycle 时才使用一次 `run`,并在完整结果后写一次 `ready`。流式协议失败不得在同一 Provider lifecycle 内静默补发普通请求;V1.18 的单物理请求、request slot、orphan barrier、pause/steer interrupt 和显式恢复新 lifecycle 约束保持不变。
- same-run steer、Goal edit/pause、取消、stale revision、Provider 失败或 reconciliation 都必须让旧快照进入 `discarded|failed`,或因 steer cursor/phase 不匹配而立即不可见。旧草稿不能成为 observation、fallback response、conversation message、Goal completion evidence 或下一轮模型上下文。
- 完整候选仍必须通过 verification、plan、Goal、process/join/delegate 和项目 revision 门禁。`finish_game_creator_agent_background_runtime_turn_at` 仍是唯一 finalization 入口;只有 assistant 已按稳定 messageId 恰好一次写入并完成 Runtime 投影后,快照才可标记 `committed`。失败消息和 `plan.response` fallback 必须覆盖为其实际候选,不能保留不同 Provider 草稿。
- 公共 event、Agent DB、receipt、activity/output 和报告不得复制 delta 或 accumulated text,只记录流身份、状态、sequence、字符数和 SHA-256。conversation、finalization、Runtime task/state 和 response-stream 都是本地私有事实面,可保存 canonical 最终正文;其中 response-stream 只是可丢失候选缓存,且不得保存 API Key、请求头、URL、模型 thinking、工具计划或原始 Provider error。
### 客户端与 CLI
- 普通 Project Supervisor 聊天把匹配的 `streaming|ready` 快照渲染成一条 `runtimeOwned` 临时 assistant 消息;刷新、窗口重开和 Tauri event 丢失时由现有 750ms Runtime 轮询恢复。`committed` 后以 conversation 中的规范 assistant 替换草稿,不把临时消息写回 legacy project conversation 或 Agent Session。
- `agc:chat` / `agc:swarm` 按 accumulated text 前缀增量打印 UTF-8 suffix;新 request slot、非前缀校准或 reconnect 要明确重置。已经完整流出的父回复在 settle 时只补完成换行/状态,不再整段重复打印。`/status` 只显示流状态、sequence 和字符数,不显示隐藏 planning 或 thinking。
### 验收口径
- 确定性测试覆盖 Chat / Responses SSE 至少两个真实 delta、chunk 边界 thinking 过滤、sequence 单调、32K 上限、损坏/错身份快照不显示、Tauri 轮询恢复、CLI suffix/reconnect/非前缀重置、steer/取消/失败旧流失效、非流配置单次 ready、finalization 后唯一 assistant 与 committed 精确一致,以及公共持久面零正文。
- 真实 Provider 使用一次性项目和独立 AppData,把目标 Agent 的 `stream=true`,证明首次公开 delta 发生在 Provider/finalization 终态之前、至少两个非空增量可观察、最终 conversation assistant 与 ready/committed 全文一致、同一 lifecycle 不发生应用层重试,并扫描密钥、thinking canary、项目绝对路径和 delta 正文在 event、Agent DB、receipt、activity/output 与报告等公共面泄漏为 0。上游物理请求数无法直接观测时,必须明确记录证明模式,不能把 lifecycle 计数冒充网络请求计数。
- 2026-07-15 真实 `gpt-5.5` `response-stream` suite 已 PASS:隔离 AppData 只以 hardlink 读取正式配置并使用无密钥 `stream=true` overlay,正式配置 CLI 调用为 0、源 Runner endpoint 未变化。39 个不同非空 streaming 快照先于终态,sequence 从 1 单调推进到 418,最终以 425 committedcanonical 正文 883 字,conversation 恰好 1 条 user 和 1 条 assistantfinal-reply lifecycle 恰好 1 组 `started -> completed`fallback replay、重复 message/receipt 均为 0。该次上游物理请求计数未直接观测,证明模式为 lifecycle slot 与 canonical response identity 交叉核对。公共正文、API Key、thinking、诱饵、项目绝对路径及 transcript/report 路径泄漏均为 0;隔离 Runner 由 Linux pidfd 精确停止,AppData 和一次性项目按 sentinel 清理。
## 验收命令
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml structured_plan_ -- --nocapture`
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml agent_goal_ -- --nocapture`
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml goal_context_bundle_v4_migrates_v3_and_v2_then_rejects_plan_mismatch -- --nocapture`
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml response_stream_ -- --nocapture`
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml swarm_cli::tests -- --nocapture`
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml typed_goal_pause_and_cancel_require_durable_intent_and_keep_exact_run -- --nocapture`
- `npm run ai-game-creator-shell:typecheck`
- `npm run test -- apps/ai-game-creator-shell/tests`
@@ -816,6 +846,7 @@ V1.18 对标 Codex CLI `/goal` 的长任务语义:目标文本既是首轮任
- `npm run ai-game-creator-shell:agent-run:smoke`
- `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir <AppData> --suite llm-runtime`
- `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir <AppData> --suite goal-runtime`
- `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir <AppData> --suite response-stream`
- `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir <AppData> --suite full`
- `npm run check:encoding`
- `git diff --check`
@@ -557,4 +557,6 @@ game-project/
- 共享契约提供 `GAME_CREATION_APP_LIMITED_RUN_COMMANDS`;当前真实命令为 `game.static_smoke`,用于检查 `game/index.html` 的可玩原型门槛并写入 `.agent/logs/command.log`
- 后台 Agent 的项目 revision 以 `.agent/runtime/project-revision.json` 为唯一事实源,per-run 验证门禁以 `.agent/runtime/verification/<agentId>/<runId>.json` 为事实源。每次 `file.write``file.patch``file.delete``project.restore` 都必须在实际修改前保守推进 revision,并永久记住当前 run 的 `requiresVerification=true`;失败或崩溃不回退。只有成功且绑定当前 revision 的 `project.verify``command.run_limited / game.static_smoke` 才能放行空 actions;未修改项目的只读任务不强制验证,但最终回复仍必须绑定请求开始时的 `responseRevision`。per-run context bundle 使用 v2pending action 使用 v3 并绑定创建时的全局 revision;旧版恢复失败关闭。最终 assistant 和 completed 必须在项目写锁内重读 revision / gate 后依次落盘,文件回读、observation 或锁外旧快照都不能替代验证凭证。验收必须分别模拟待执行动作、修改 run 与只读 run 的跨 Agent revision 漂移,证明旧动作不执行、旧回复不落盘、不产生 completed 或 failed、per-Agent 锁不提前释放、原 run/session 在收到 blocker 后保持可恢复;stale continuation 经重启仍从原 `nextLoopIndex` 续跑,revision 数值或成功验证输出中的动态时间戳不能绕过 context stall。
- `.agent/manifest.json` 会记录当前 `preview` 状态和 `commandRuns` 受限命令运行结果,作为本地产物索引的最小真相源。
- 2026-07-15 补充:后台 Runtime 的最终用户回复接入真 Provider SSE。planning/function arguments/thinking/observation 继续只留在私有执行链;`AgentRuntimeResult` 读取与 CLI 通过 `.agent/runtime/response-streams/<agentHash>/<runHash>.json` 的有界私有快照恢复公开 accumulated text。快照绑定 Agent/task/Session/run/request slot/steer cursor/revision,只是可丢失展示缓存,不替代 conversation、Provider lifecycle 或 finalization。普通 Project Supervisor 以 runtimeOwned 草稿展示,最终仍由唯一 assistant 落盘替换;steer、取消、失败和身份漂移必须隐藏旧草稿,公共审计只保留哈希与计数。
- 2026-07-15 真实 `gpt-5.5` `response-stream` 专项已 PASS:39 个不同非空快照在终态前可见,sequence 为 `1 -> 418 -> 425 committed`,最终 883 字与唯一 conversation assistant 精确一致;final-reply lifecycle 唯一、fallback replay 和重复消息/回执为 0。公共正文、API Key、thinking、诱饵和项目绝对路径泄漏均为 0;`project.verify` Agent DB 审计固定保存 `.agent/logs/command.log` 相对路径,并在写入前脱敏 expectedCommand/output 中的项目根路径。
- 开发模式可通过本地项目文件面板执行 `file.list/read/write/delete`,普通用户界面不暴露文件面板。
+71 -1
View File
@@ -9,7 +9,7 @@ use std::{
};
use log::{debug, warn};
use reqwest::{Client, StatusCode};
use reqwest::{Client, StatusCode, redirect::Policy};
use serde::{Deserialize, Serialize};
use tokio::time::sleep;
@@ -1030,6 +1030,21 @@ impl LlmClient {
})
}
pub fn new_without_redirects(config: LlmConfig) -> Result<Self, LlmError> {
let http_client = Client::builder()
.http1_only()
.redirect(Policy::none())
.build()
.map_err(|error| {
LlmError::InvalidConfig(format!("构建 reqwest client 失败:{error}"))
})?;
Ok(Self {
config,
http_client,
})
}
pub fn config(&self) -> &LlmConfig {
&self.config
}
@@ -2655,6 +2670,61 @@ mod tests {
assert_eq!(request_json["official_fallback"], serde_json::json!(true));
}
#[tokio::test]
async fn client_without_redirects_does_not_replay_post_on_307() {
let redirect_listener = TcpListener::bind("127.0.0.1:0").expect("redirect listener");
let redirect_address = redirect_listener.local_addr().expect("redirect address");
let target_listener = TcpListener::bind("127.0.0.1:0").expect("target listener");
let target_address = target_listener.local_addr().expect("target address");
target_listener
.set_nonblocking(true)
.expect("target listener nonblocking");
let server_handle = thread::spawn(move || {
let (mut stream, _) = redirect_listener.accept().expect("redirect request");
let request = read_request(&mut stream);
write!(
stream,
"HTTP/1.1 307 Temporary Redirect\r\nLocation: http://{target_address}/responses\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
.expect("write redirect response");
request
});
let config = LlmConfig::new(
LlmProvider::OpenAiCompatible,
format!("http://{redirect_address}"),
"test-key".to_string(),
"gpt-5".to_string(),
DEFAULT_REQUEST_TIMEOUT_MS,
0,
1,
)
.expect("redirect test config");
let client =
LlmClient::new_without_redirects(config).expect("redirect-disabled client builds");
let error = client
.run(LlmRunRequest::single_turn("系统", "用户").with_openai_responses())
.await
.expect_err("307 must remain an upstream response");
assert!(matches!(
error,
LlmError::Upstream {
status_code: 307,
..
}
));
let source_request = server_handle.join().expect("redirect server joins");
assert_eq!(
source_request.matches("POST /responses HTTP/1.1").count(),
1
);
assert!(matches!(
target_listener.accept(),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock
));
}
#[test]
fn sse_parser_handles_split_chunks_and_done_marker() {
let mut parser = OpenAiCompatibleSseParser::new(LlmApiKind::OpenAiChat);