实现后台Agent真流式回复
接入身份绑定的后台最终回复流并保持唯一finalization提交 完善Project Supervisor与Swarm CLI增量展示、恢复和竞态处理 禁止Runtime Provider重定向重放并加固生命周期与公共审计脱敏 增加真实Provider隔离验收、定向回归和技术文档
This commit is contained in:
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
@@ -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 delta;planning、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 DB;expectedCommand 和 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 段 finalization,fallback 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-15,V1.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 committed;canonical 正文 883 字,conversation 恰好 1 条 user 和 1 条 assistant,final-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 使用 v2,pending 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`,普通用户界面不暴露文件面板。
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user