增强单Agent上下文续跑与聊天可靠性
将六轮预算改为可压缩续跑窗口并持久化绑定 run 的上下文包 补齐上下文恢复、凭据清洗、项目验证脚本和跨窗口回归 修复流式尾包兼容、上游错误重复请求和聊天内部滚动等待状态 同步单 Agent Runtime 技术方案和长期决策记录
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1304,6 +1304,16 @@ pub(crate) const PROJECT_VERIFICATION_OUTPUT_MAX_BYTES: usize = 24 * 1024;
|
||||
const PROJECT_VERIFICATION_PACKAGE_MAX_BYTES: u64 = 512 * 1024;
|
||||
const PROJECT_VERIFICATION_MIN_TIMEOUT_SECONDS: u64 = 1;
|
||||
const PROJECT_VERIFICATION_MAX_TIMEOUT_SECONDS: u64 = 300;
|
||||
const PROJECT_VERIFICATION_SCRIPT_MAX_CHARS: usize = 160;
|
||||
const PROJECT_VERIFICATION_NAMED_SCRIPT_PREFIXES: [&str; 7] = [
|
||||
"check:",
|
||||
"test:",
|
||||
"lint:",
|
||||
"typecheck:",
|
||||
"build:",
|
||||
"verify:",
|
||||
"validate:",
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct ProjectVerificationSpec {
|
||||
@@ -1393,8 +1403,68 @@ pub(crate) fn project_verification_npm_program() -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn project_verification_script_shell() -> Result<PathBuf, String> {
|
||||
#[cfg(unix)]
|
||||
let path = PathBuf::from("/bin/sh");
|
||||
#[cfg(windows)]
|
||||
let path = std::env::var_os("ComSpec")
|
||||
.map(PathBuf::from)
|
||||
.filter(|path| path.is_absolute())
|
||||
.ok_or_else(|| "project.verify 无法解析系统 ComSpec".to_string())?;
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let path: PathBuf =
|
||||
return Err("project.verify 当前平台没有受支持的固定 script shell".to_string());
|
||||
|
||||
let metadata = fs::metadata(&path).map_err(|error| {
|
||||
format!(
|
||||
"project.verify 无法读取固定 script shell {}: {error}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
if !metadata.is_file() {
|
||||
return Err(format!(
|
||||
"project.verify 固定 script shell 不是普通文件:{}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn project_verification_script_allowed(script: &str) -> bool {
|
||||
matches!(script, "check" | "typecheck" | "test" | "lint" | "build")
|
||||
|| PROJECT_VERIFICATION_NAMED_SCRIPT_PREFIXES
|
||||
.iter()
|
||||
.any(|prefix| {
|
||||
script
|
||||
.strip_prefix(prefix)
|
||||
.is_some_and(project_verification_named_script_suffix_allowed)
|
||||
})
|
||||
}
|
||||
|
||||
fn project_verification_named_script_suffix_allowed(suffix: &str) -> bool {
|
||||
suffix.split(':').all(|segment| {
|
||||
let mut characters = segment.chars();
|
||||
characters
|
||||
.next()
|
||||
.is_some_and(|character| character.is_ascii_alphanumeric())
|
||||
&& characters.all(|character| {
|
||||
character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_project_verification_has_no_project_npmrc(root: &Path) -> Result<(), String> {
|
||||
let path = root.join(".npmrc");
|
||||
match fs::symlink_metadata(&path) {
|
||||
Ok(_) => Err(
|
||||
"project.verify 不允许项目级 .npmrc 改写 npm 执行语义;请移除后重新确认".to_string(),
|
||||
),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(format!(
|
||||
"project.verify 检查项目级 .npmrc 失败:{}: {error}",
|
||||
path.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn project_verification_package_manager_at(
|
||||
@@ -1437,9 +1507,18 @@ pub(crate) fn resolve_project_verification_spec_at(
|
||||
timeout_seconds: u64,
|
||||
) -> Result<ProjectVerificationSpec, String> {
|
||||
validate_project_root(root)?;
|
||||
ensure_project_verification_has_no_project_npmrc(root)?;
|
||||
let script = script.trim();
|
||||
if script.chars().count() > PROJECT_VERIFICATION_SCRIPT_MAX_CHARS {
|
||||
return Err(format!(
|
||||
"project.verify script 总长度不能超过 {PROJECT_VERIFICATION_SCRIPT_MAX_CHARS} 个字符"
|
||||
));
|
||||
}
|
||||
if !project_verification_script_allowed(script) {
|
||||
return Err("project.verify 只允许 check、typecheck、test、lint、build 脚本".to_string());
|
||||
return Err(
|
||||
"project.verify 只允许验证类脚本:check、typecheck、test、lint、build,或以 check:、test:、lint:、typecheck:、build:、verify:、validate: 开头的安全非空命名脚本"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if expected_command.trim().is_empty() {
|
||||
return Err("project.verify 缺少 expectedCommand".to_string());
|
||||
@@ -1539,35 +1618,7 @@ pub(crate) fn sanitize_project_verification_output(value: &str) -> String {
|
||||
.filter(|character| !character.is_control() || matches!(character, '\n' | '\t'))
|
||||
.collect::<String>();
|
||||
let sanitized = sanitize_prompt_context(&printable);
|
||||
let mut output = String::with_capacity(sanitized.len());
|
||||
let bytes = sanitized.as_bytes();
|
||||
let mut index = 0;
|
||||
while index < bytes.len() {
|
||||
let prefix_len = if bytes[index..].starts_with(b"tnr_sk_") {
|
||||
Some(7)
|
||||
} else if bytes[index..].starts_with(b"sk-") {
|
||||
Some(3)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some(prefix_len) = prefix_len else {
|
||||
let character = sanitized[index..]
|
||||
.chars()
|
||||
.next()
|
||||
.expect("index stays on a char boundary");
|
||||
output.push(character);
|
||||
index += character.len_utf8();
|
||||
continue;
|
||||
};
|
||||
let mut end = index + prefix_len;
|
||||
while end < bytes.len()
|
||||
&& (bytes[end].is_ascii_alphanumeric() || matches!(bytes[end], b'-' | b'_' | b'.'))
|
||||
{
|
||||
end += 1;
|
||||
}
|
||||
output.push_str("[redacted-secret]");
|
||||
index = end;
|
||||
}
|
||||
let output = redact_secret_tokens(&sanitized);
|
||||
truncate_project_verification_output(&output)
|
||||
}
|
||||
|
||||
@@ -1659,6 +1710,7 @@ async fn run_project_verification_process(
|
||||
root: &Path,
|
||||
spec: &ProjectVerificationSpec,
|
||||
) -> Result<ProjectVerificationProcessResult, String> {
|
||||
ensure_project_verification_has_no_project_npmrc(root)?;
|
||||
let isolated_home = resolve_local_project_path(root, ".agent/runtime/verify-home")?;
|
||||
let isolated_tmp = resolve_local_project_path(root, ".agent/runtime/verify-tmp")?;
|
||||
let isolated_cache = resolve_local_project_path(root, ".agent/runtime/npm-cache")?;
|
||||
@@ -1666,6 +1718,7 @@ async fn run_project_verification_process(
|
||||
.and_then(|()| fs::create_dir_all(&isolated_tmp))
|
||||
.and_then(|()| fs::create_dir_all(&isolated_cache))
|
||||
.map_err(|error| format!("创建 project.verify 隔离目录失败:{error}"))?;
|
||||
let script_shell = project_verification_script_shell()?;
|
||||
|
||||
let mut command = tokio::process::Command::new(&spec.program);
|
||||
command
|
||||
@@ -1683,6 +1736,7 @@ async fn run_project_verification_process(
|
||||
.env("npm_config_audit", "false")
|
||||
.env("npm_config_fund", "false")
|
||||
.env("npm_config_ignore_scripts", "true")
|
||||
.env("npm_config_script_shell", &script_shell)
|
||||
.env("npm_config_update_notifier", "false")
|
||||
.env("npm_config_cache", &isolated_cache)
|
||||
.env(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -93,6 +93,7 @@ const AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT = 20;
|
||||
const AGENT_RUN_HISTORY_VISIBLE_STEP = 20;
|
||||
const CONVERSATION_INITIAL_VISIBLE_COUNT = 20;
|
||||
const CONVERSATION_VISIBLE_STEP = 20;
|
||||
const AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD = 48;
|
||||
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
|
||||
const DEFAULT_CLIENT_AUTH_API_BASE_URL = 'http://127.0.0.1:8082';
|
||||
const launcherNotifications: Array<{
|
||||
@@ -3037,6 +3038,7 @@ export function WorkspaceLauncher({
|
||||
const [agentChatStatus, setAgentChatStatus] = useState('请选择项目和 Agent');
|
||||
const [agentChatBusy, setAgentChatBusy] = useState(false);
|
||||
const agentChatMessagesRef = useRef<HTMLDivElement | null>(null);
|
||||
const agentChatShouldFollowLatestRef = useRef(true);
|
||||
const [agentChatBackgroundBusy, setAgentChatBackgroundBusy] = useState(false);
|
||||
const [agentChatPendingRuntimeRun, setAgentChatPendingRuntimeRunState] =
|
||||
useState<AgentChatPendingRuntimeRun | null>(null);
|
||||
@@ -3152,7 +3154,7 @@ export function WorkspaceLauncher({
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const messageList = agentChatMessagesRef.current;
|
||||
if (messageList) {
|
||||
if (messageList && agentChatShouldFollowLatestRef.current) {
|
||||
messageList.scrollTop = messageList.scrollHeight;
|
||||
}
|
||||
}, [
|
||||
@@ -3162,6 +3164,14 @@ export function WorkspaceLauncher({
|
||||
agentChatStatus,
|
||||
]);
|
||||
|
||||
function handleAgentChatMessagesScroll(event: UIEvent<HTMLDivElement>) {
|
||||
const messageList = event.currentTarget;
|
||||
const distanceFromBottom =
|
||||
messageList.scrollHeight - messageList.scrollTop - messageList.clientHeight;
|
||||
agentChatShouldFollowLatestRef.current =
|
||||
distanceFromBottom <= AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke || recentWorkspaces.length === 0) {
|
||||
@@ -3887,6 +3897,7 @@ export function WorkspaceLauncher({
|
||||
}
|
||||
|
||||
function resetAgentChatSessionView() {
|
||||
agentChatShouldFollowLatestRef.current = true;
|
||||
setAgentChatReplyPhase('idle');
|
||||
setAgentChatPendingRuntimeRun(null);
|
||||
setAgentChatSessions([]);
|
||||
@@ -4085,6 +4096,7 @@ export function WorkspaceLauncher({
|
||||
if (!projectPathForChat || !agent) {
|
||||
return;
|
||||
}
|
||||
agentChatShouldFollowLatestRef.current = true;
|
||||
const pendingRun = agentChatPendingRuntimeRunRef.current;
|
||||
if (
|
||||
pendingRun &&
|
||||
@@ -4450,6 +4462,7 @@ export function WorkspaceLauncher({
|
||||
}
|
||||
const saveVersion = agentChatLoadVersionRef.current + 1;
|
||||
agentChatLoadVersionRef.current = saveVersion;
|
||||
agentChatShouldFollowLatestRef.current = true;
|
||||
setAgentChatBusy(true);
|
||||
setAgentChatReplyPhase('saving-user');
|
||||
setAgentChatInput('');
|
||||
@@ -4613,19 +4626,8 @@ export function WorkspaceLauncher({
|
||||
setAgentChatStatus(
|
||||
`Agent 回复已结束(${pendingStreamFinishReason}),正在保存已接收内容`,
|
||||
);
|
||||
} else if (pendingStreamDraftText) {
|
||||
throw error;
|
||||
} else {
|
||||
setAgentChatStatus('流式连接失败,正在切换普通回复模式');
|
||||
reply = await invoke<GameCreatorChatAgentReply>(
|
||||
'chat_with_game_creator_role_agent',
|
||||
{
|
||||
projectPath: projectPathForChat,
|
||||
agentId: agent.id,
|
||||
prompt: content,
|
||||
...agentChatSessionInvokeArgs(sessionIdForChat),
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -4794,6 +4796,7 @@ export function WorkspaceLauncher({
|
||||
}
|
||||
const saveVersion = agentChatLoadVersionRef.current + 1;
|
||||
agentChatLoadVersionRef.current = saveVersion;
|
||||
agentChatShouldFollowLatestRef.current = true;
|
||||
const requestedRunId = createAgentChatRunId('launcher-agent-task');
|
||||
let pendingRunId = requestedRunId;
|
||||
setAgentChatBackgroundBusy(true);
|
||||
@@ -6056,6 +6059,7 @@ export function WorkspaceLauncher({
|
||||
role="log"
|
||||
aria-label="Agent 聊天记录"
|
||||
aria-busy={currentAgentChatWaiting}
|
||||
onScroll={handleAgentChatMessagesScroll}
|
||||
tabIndex={0}
|
||||
>
|
||||
{agentChatMessages.length > 0 ? (
|
||||
|
||||
@@ -1122,12 +1122,14 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to a normal reply when streaming fails before the first delta', async () => {
|
||||
it('persists and shows an upstream stream error without issuing a normal retry', async () => {
|
||||
const persistedMessages: Array<{
|
||||
role: 'user' | 'assistant' | 'tool';
|
||||
content: string;
|
||||
agentId: string | null;
|
||||
}> = [];
|
||||
const upstreamError = '上游余额不足';
|
||||
const persistedError = `已保存用户消息;Agent 回复失败:${upstreamError}`;
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_local_conversation') {
|
||||
@@ -1138,10 +1140,10 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
};
|
||||
}
|
||||
if (command === 'chat_with_game_creator_role_agent_stream') {
|
||||
throw new Error('stream disconnected before first delta');
|
||||
throw new Error(upstreamError);
|
||||
}
|
||||
if (command === 'chat_with_game_creator_role_agent') {
|
||||
return { replyText: '普通回复补位成功。' };
|
||||
throw new Error('upstream errors must not issue a normal retry');
|
||||
}
|
||||
if (command === 'append_local_conversation_message') {
|
||||
persistedMessages.push(
|
||||
@@ -1177,20 +1179,31 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
expect(await screen.findByText(/已读取 0 条/)).not.toBeNull();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Agent 聊天内容'), {
|
||||
target: { value: '流式失败时继续回答' },
|
||||
target: { value: '不要重复请求上游' },
|
||||
});
|
||||
selectDeveloperAgentChatMode('chat');
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
|
||||
expect(await screen.findByText('普通回复补位成功。')).not.toBeNull();
|
||||
expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_role_agent', {
|
||||
projectPath: '/tmp/authorized-game',
|
||||
agentId: 'design-director',
|
||||
prompt: '流式失败时继续回答',
|
||||
});
|
||||
expect(
|
||||
await within(screen.getByLabelText('Agent 聊天记录')).findByText(
|
||||
persistedError,
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'chat_with_game_creator_role_agent',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'chat_with_game_creator_role_agent_stream',
|
||||
expect.objectContaining({
|
||||
projectPath: '/tmp/authorized-game',
|
||||
agentId: 'design-director',
|
||||
prompt: '不要重复请求上游',
|
||||
}),
|
||||
);
|
||||
expect(persistedMessages.at(-1)).toEqual({
|
||||
role: 'assistant',
|
||||
content: '普通回复补位成功。',
|
||||
content: persistedError,
|
||||
agentId: null,
|
||||
});
|
||||
});
|
||||
@@ -2019,6 +2032,201 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('follows new agent chat messages near the bottom without stealing an intentional scroll position', async () => {
|
||||
const persistedMessages: Array<{
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
agentId: string | null;
|
||||
}> = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '历史消息',
|
||||
agentId: null,
|
||||
},
|
||||
];
|
||||
let streamHandler:
|
||||
| ((event: { payload: Record<string, unknown> }) => void)
|
||||
| null = null;
|
||||
let streamRunId = '';
|
||||
let releaseStream: (() => void) | null = null;
|
||||
const listen = vi.fn(
|
||||
async (
|
||||
eventName: string,
|
||||
handler: (event: { payload: Record<string, unknown> }) => void,
|
||||
) => {
|
||||
if (eventName === 'game-creator-role-agent-chat-stream') {
|
||||
streamHandler = handler;
|
||||
return () => {
|
||||
if (streamHandler === handler) {
|
||||
streamHandler = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
return () => {};
|
||||
},
|
||||
);
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_local_conversation') {
|
||||
return {
|
||||
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
|
||||
agentId: args?.agentId,
|
||||
messages: persistedMessages.map((message, index) => ({
|
||||
schemaVersion: '1',
|
||||
...message,
|
||||
updatedAt: index + 1,
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (command === 'chat_with_game_creator_role_agent_stream') {
|
||||
streamRunId = String(args?.runId ?? '');
|
||||
streamHandler?.({
|
||||
payload: {
|
||||
projectPath: args?.projectPath,
|
||||
agentId: args?.agentId,
|
||||
runId: streamRunId,
|
||||
status: 'started',
|
||||
deltaText: '',
|
||||
accumulatedText: '',
|
||||
finishReason: null,
|
||||
runtimeSummary: '请求 Agent LLM',
|
||||
},
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseStream = resolve;
|
||||
});
|
||||
streamHandler?.({
|
||||
payload: {
|
||||
projectPath: args?.projectPath,
|
||||
agentId: args?.agentId,
|
||||
runId: streamRunId,
|
||||
status: 'completed',
|
||||
deltaText: '',
|
||||
accumulatedText: '第一段第二段',
|
||||
finishReason: 'stop',
|
||||
runtimeSummary: '等待下一轮输入',
|
||||
},
|
||||
});
|
||||
return { replyText: '第一段第二段' };
|
||||
}
|
||||
if (command === 'append_local_conversation_message') {
|
||||
persistedMessages.push(
|
||||
args?.message as {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
agentId: string | null;
|
||||
},
|
||||
);
|
||||
return {
|
||||
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
|
||||
agentId: args?.agentId,
|
||||
messages: persistedMessages.map((message, index) => ({
|
||||
schemaVersion: '1',
|
||||
...message,
|
||||
updatedAt: index + 1,
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke }, event: { listen } };
|
||||
renderLauncherAgentChatAt('/?agent-chat');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), {
|
||||
target: { value: '/tmp/authorized-game' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '读取历史' }));
|
||||
expect(await screen.findByText('历史消息')).not.toBeNull();
|
||||
|
||||
const messageList = screen.getByLabelText(
|
||||
'Agent 聊天记录',
|
||||
) as HTMLDivElement;
|
||||
let messageScrollHeight = 600;
|
||||
Object.defineProperty(messageList, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => messageScrollHeight,
|
||||
});
|
||||
Object.defineProperty(messageList, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => 100,
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Agent 聊天内容'), {
|
||||
target: { value: '测试消息列表滚动' },
|
||||
});
|
||||
selectDeveloperAgentChatMode('chat');
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
await waitFor(() => expect(releaseStream).not.toBeNull());
|
||||
|
||||
messageList.scrollTop = 460;
|
||||
fireEvent.scroll(messageList);
|
||||
messageScrollHeight = 720;
|
||||
await act(async () => {
|
||||
streamHandler?.({
|
||||
payload: {
|
||||
projectPath: '/tmp/authorized-game',
|
||||
agentId: 'design-director',
|
||||
runId: streamRunId,
|
||||
status: 'delta',
|
||||
deltaText: '第一段',
|
||||
accumulatedText: '第一段',
|
||||
finishReason: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(await screen.findByText('第一段')).not.toBeNull();
|
||||
await waitFor(() => expect(messageList.scrollTop).toBe(720));
|
||||
|
||||
messageList.scrollTop = 120;
|
||||
fireEvent.scroll(messageList);
|
||||
messageScrollHeight = 780;
|
||||
await act(async () => {
|
||||
streamHandler?.({
|
||||
payload: {
|
||||
projectPath: '/tmp/authorized-game',
|
||||
agentId: 'design-director',
|
||||
runId: streamRunId,
|
||||
status: 'started',
|
||||
deltaText: '',
|
||||
accumulatedText: '第一段',
|
||||
finishReason: null,
|
||||
runtimeSummary: '上游正在重试',
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(
|
||||
await within(messageList).findByText(
|
||||
'已连接 Agent LLM,上游正在重试',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(messageList.scrollTop).toBe(120);
|
||||
|
||||
messageScrollHeight = 840;
|
||||
await act(async () => {
|
||||
streamHandler?.({
|
||||
payload: {
|
||||
projectPath: '/tmp/authorized-game',
|
||||
agentId: 'design-director',
|
||||
runId: streamRunId,
|
||||
status: 'delta',
|
||||
deltaText: '第二段',
|
||||
accumulatedText: '第一段第二段',
|
||||
finishReason: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(await screen.findByText('第一段第二段')).not.toBeNull();
|
||||
expect(messageList.scrollTop).toBe(120);
|
||||
|
||||
messageScrollHeight = 900;
|
||||
await act(async () => {
|
||||
releaseStream?.();
|
||||
});
|
||||
expect(await screen.findByText(/已保存 3 条/)).not.toBeNull();
|
||||
expect(messageList.scrollTop).toBe(120);
|
||||
});
|
||||
|
||||
it('runs developer agent work by default and syncs the terminal reply', async () => {
|
||||
const persistedMessages: Array<{
|
||||
role: 'user' | 'assistant';
|
||||
|
||||
@@ -18,15 +18,17 @@
|
||||
|
||||
## 2026-07-09 AI 游戏创作 App Runtime V1 增加单 Agent 后台任务
|
||||
|
||||
- 2026-07-12 安全边界:`project.verify` 的 script 最多 160 个字符,固定使用系统 script shell,并在解析和执行前拒绝项目级 `.npmrc`。Runtime context bundle 必须绑定 `projectId / agentId / taskId / sessionId / runId / source / task`,结尾换行计入 64 KiB 上限;恢复时还要校验 `nextLoopIndex`、context window、当前窗口已完成轮数、观察指纹、计划和 observation 数量。bundle 写入必须拒绝父目录符号链接,读取必须基于同一文件句柄限制到 64 KiB,并清洗项目路径及常见平台凭据;已观察动作只有在 observation 写入 context checkpoint 后才能删除 ledger,下一轮 planning 和跨重启恢复不得再被旧 ledger 抢占。
|
||||
- 背景:开发用单 Agent 聊天已经能真实调用各 Agent 的 LLM 路由并持久化对话,但 Agent 仍主要表现为同步问答,用户无法明确投递一个任务让某个 Agent 独立运行,也无法同时启动多个 Agent 的工作。
|
||||
- 决策:在现有 `.agent/runtime` 和 `.agent/conversations` 基础上新增单 Agent 后台任务入口。Tauri 命令 `start_game_creator_agent_runtime_task` 立即写入该 Agent 的 runtime state/event/task history,追加用户任务到 `.agent/conversations/agents/<agentId>.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loop:Agent 按轮输出 `thinkingSummary / plan / actions / response`,Runtime 按白名单和项目权限策略执行工具并记录 `action / observation` 事件,再把已有 observation 放回下一轮 prompt,让 Agent 修正计划、继续行动或用空 actions + response 收束;当前后台任务最多执行 3 轮 loop,仍未收束时再按最后计划和全部观察生成最终回复。完成或失败后把 assistant 回复或错误追加回对话,并写入 `.agent/agent.db` 审计记录。工具箱包含只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`project.diff`、`file.list`、`file.read`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`file.write`、`command.run_limited`、`blackboard.write`、`agent.message` 和 `agent.delegate`;`memory.write` 可追加或覆盖本 Agent 私有记忆、项目长期/短期记忆或黑板,`file.write` 只能写项目内相对路径,`command.run_limited` 只接受 `game.static_smoke` 并复用本地静态自检安全边界,`blackboard.write` 追加共享黑板,`agent.message` 写目标 Agent 对话,`agent.delegate` 把任务投递到目标 Agent 的独立后台队列;策略拒绝时不执行工具并把 `blocked` observation 回给 Agent;策略要求确认时不执行工具,而是持久化精确待确认动作并暂停该 Agent 队列,待开发者确认或拒绝后在同一 run 续跑。每个 Agent 的任务历史落在 `.agent/runtime/tasks/<agentId>.jsonl`,读 runtime 时按 `runId` 去重返回最近任务,任务视角状态使用 `pending / running / completed / failed`,Runtime state 增加 `nextStep`,UI 在 Runtime 面板和主 Agent 状态卡展示当前任务、动作、下一步与最近任务。不同 Agent 使用独立 `.agent/runtime/locks/<agentId>.lock`,允许并行运行;同一 Agent 已有运行任务时,新任务会先进入该 Agent 的 pending 队列,当前 drain 持锁完成后串行继续下一条 pending。该能力仍不是独立 OS 进程或跨重启离线常驻 worker。
|
||||
- 决策:在现有 `.agent/runtime` 和 `.agent/conversations` 基础上新增单 Agent 后台任务入口。Tauri 命令 `start_game_creator_agent_runtime_task` 立即写入该 Agent 的 runtime state/event/task history,追加用户任务到 `.agent/conversations/agents/<agentId>.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loop:Agent 按轮输出 `thinkingSummary / plan / actions / response`,Runtime 按白名单和项目权限策略执行工具并记录 `action / observation` 事件,再把已有 observation 放回下一轮 prompt,让 Agent 修正计划、继续行动或用空 actions + response 收束;单 Agent Runtime 每 6 轮形成一个上下文压缩窗口,窗口有新的独立 observation 时压缩上下文并在同一 run 继续,最近 6 轮没有独立进展或相邻窗口重复时以 `failed / budget-exhausted` 和 `loop-budget-exhausted` 终止,不生成总结伪装完成。完成或失败后把 assistant 回复或错误追加回对话,并写入 `.agent/agent.db` 审计记录。工具箱包含只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`project.diff`、`file.list`、`file.read`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`file.write`、`command.run_limited`、`blackboard.write`、`agent.message` 和 `agent.delegate`;`memory.write` 可追加或覆盖本 Agent 私有记忆、项目长期/短期记忆或黑板,`file.write` 只能写项目内相对路径,`command.run_limited` 只接受 `game.static_smoke` 并复用本地静态自检安全边界,`blackboard.write` 追加共享黑板,`agent.message` 写目标 Agent 对话,`agent.delegate` 把任务投递到目标 Agent 的独立后台队列;策略拒绝时不执行工具并把 `blocked` observation 回给 Agent;策略要求确认时不执行工具,而是持久化精确待确认动作并暂停该 Agent 队列,待开发者确认或拒绝后在同一 run 续跑。每个 Agent 的任务历史落在 `.agent/runtime/tasks/<agentId>.jsonl`,读 runtime 时按 `runId` 去重返回最近任务,任务视角状态使用 `pending / running / completed / failed`,Runtime state 增加 `nextStep`,UI 在 Runtime 面板和主 Agent 状态卡展示当前任务、动作、下一步与最近任务。不同 Agent 使用独立 `.agent/runtime/locks/<agentId>.lock`,允许并行运行;同一 Agent 已有运行任务时,新任务会先进入该 Agent 的 pending 队列,当前 drain 持锁完成后串行继续下一条 pending。该能力仍不是独立 OS 进程或跨重启离线常驻 worker。
|
||||
- 2026-07-10 补充:后台 Runtime 每次追加 `.agent/runtime/events/<agentId>.jsonl` 后会通过 Tauri `game-creator-agent-runtime-update` 事件广播当前 `AgentRuntimeResult`;开发单 Agent 聊天页、项目内 Agent 对话弹窗和主窗口 Agent 状态列表都只把该事件作为实时 UI 通知并复用前端 runtime 归一化合并,事实源仍是 `.agent/runtime/agents`、`events` 和 `tasks` 文件。
|
||||
- 2026-07-11 补充:开发单 Agent 聊天页保留整页纵向滚动,聊天消息区固定响应式高度并在内部滚动;Runtime 恢复确认区使用独立布局行,避免与 Runtime 详情或聊天内容重叠。Runtime 面板详情可折叠且折叠时不渲染详情 DOM,但状态标题与任务控制按钮继续保留;等待 LLM 时在消息区持续显示动态状态和进行中提示,连续流式 delta 合并到动画帧更新并跳过重复 Runtime state。OpenAI Chat SSE 会跳过空 `choices` 心跳 / 元数据事件,收集 usage-only 尾包、保留 finish reason 与上游 error message,收到 `[DONE]` 后立即结束;正文与 finish reason 已接收后出现尾包异常时保存已完成正文,不把整轮改写成失败。持久事件订阅失败时显示非致命错误,聊天事件监听不可用或首个文本片段前流式失败时降级普通回复并继续落盘。
|
||||
- 2026-07-11 补充:为缩小单 Agent 与 Codex CLI 在代码任务上的差距,Runtime 工具箱新增 `project.search` 和 `file.patch`,并扩展 `file.read` 的按行分页。`project.search` 在项目内执行有界字面量检索,默认忽略大小写,返回相对路径、行号和匹配行,跳过 `.agent`、敏感配置、依赖和构建目录;权限继承 `file.read`。`file.read` 接受 `startLine / maxLines`,返回带行号的最多 240 行、8,000 字符上下文,允许 Agent 继续分页而不是只看到文件开头约 900 字符。`file.patch` 只做 `oldText -> newText` 精确替换,必须声明预期匹配数,匹配数不符时不写入;它继承 `file.write` 权限,复用项目写锁和 Runtime 动作账本,并追加不含代码正文的 `agent.runtime.file.patch` 审计记录。三者组成“搜索定位 -> 分段读取 -> 局部修改 -> 再次读取验证”的最小代码工作闭环,不开放任意 shell。
|
||||
- 2026-07-11 补充:单 Agent 代码闭环新增开发专用 `project.verify`,用于在修改后执行项目根 `package.json` 已定义的 `check / typecheck / test / lint / build` 之一;当前只支持 npm,不接受自由命令、参数或工作目录。Agent 必须先读取 `package.json`,再把脚本名、完整原始脚本文本 `expectedCommand` 和 1-300 秒超时一起提交;Runtime 在真正执行前重新解析 JSON 并做精确一致性校验,脚本漂移时拒绝执行。该工具使用独立、默认 `confirm` 的 `project.verify` 权限,不再与 `command.run_limited` / `game.static_smoke` 共用授权;确认指纹覆盖脚本正文和超时。执行时不经过 App 自行拼接的 `bash -c`,而由 npm 执行已确认的项目脚本,并附加 `--ignore-scripts` 阻止 `pre/post` 生命周期旁路;继承环境被清理到 PATH 与必要平台变量,HOME/TMP/npm cache 隔离,stdin 关闭,输出保留有界头尾。Unix 下验证根进程正常结束或超时都会清理同进程组残留后代;项目写锁会按持有 PID 回收崩溃遗留锁,并拒绝 `.agent` 符号链接逃逸。进入进程执行后的成功、非零退出、启动失败和超时会写 `.agent/logs/command.log`、manifest command run 与 `agent.runtime.project.verify` 审计;输入预检拒绝则只进入 Runtime observation / error 事件。输出先过滤敏感内容再进入 observation。只要最新 `project.verify` 未通过,或通过后又发生 `file.write / file.patch / project.restore`,Runtime 就拒绝模型用空 actions 假完成,继续要求修复和重新验证;耗尽 loop 仍未通过时保持失败。该能力会执行用户项目自身脚本,环境隔离不等同于 OS 沙箱,不能把不可信项目脚本视为安全代码;它不是自由 shell 代理,也不进入普通用户命令入口。
|
||||
- 2026-07-11 补充,2026-07-12 更新:单 Agent 代码闭环新增开发专用 `project.verify`,用于在修改后执行项目根 `package.json` 已定义的固定脚本 `check / typecheck / test / lint / build`,或以 `check: / test: / lint: / typecheck: / build: / verify: / validate:` 开头、后缀由安全非空段组成的命名脚本;当前只支持 npm,不接受自由命令、参数或工作目录。Agent 必须先读取普通文件 `package.json`,再把真实存在的脚本名、完整原始脚本文本 `expectedCommand` 和 1-300 秒超时一起提交;Runtime 在真正执行前重新解析 JSON,要求脚本仍存在且正文精确一致,脚本漂移时拒绝执行。非 npm `packageManager` 或 pnpm / yarn / bun 锁文件必须失败关闭;`pre* / post*` 生命周期脚本名不在允许范围,npm 执行再附加 `--ignore-scripts`,阻止所选脚本关联的 pre/post lifecycle。该工具使用独立、默认 `confirm` 的 `project.verify` 权限,不再与 `command.run_limited` / `game.static_smoke` 共用授权;确认指纹覆盖脚本正文和超时。执行时不经过 App 自行拼接的 `bash -c`;继承环境被清理到 PATH 与必要平台变量,HOME/TMP/npm cache 隔离,stdin 关闭,输出保留有界头尾。Unix 下验证根进程正常结束或超时都会清理同进程组残留后代;项目写锁会按持有 PID 回收崩溃遗留锁,并拒绝 `.agent` 符号链接逃逸。进入进程执行后的成功、非零退出、启动失败和超时会写 `.agent/logs/command.log`、manifest command run 与 `agent.runtime.project.verify` 审计;输入预检拒绝则只进入 Runtime observation / error 事件。输出先过滤敏感内容再进入 observation。只要最新 `project.verify` 未通过,或通过后又发生 `file.write / file.patch / project.restore`,Runtime 就拒绝模型用空 actions 假完成,继续要求修复和重新验证;多窗口重复无进展而以 `loop-budget-exhausted` 终止时,仍未形成新通过结果则保持失败。该能力会执行用户项目自身脚本,环境隔离不等同于 OS 沙箱,不能把不可信项目脚本视为安全代码;它不是自由 shell 代理,也不进入普通用户命令入口。
|
||||
- 2026-07-11 补充:开发侧新增 headless 单 Agent Runtime 入口 `npm run ai-game-creator-shell:agent-task -- [--init] <projectPath> <agentId> <task>`。该入口不实现第二套 Agent,只复用 Tauri App 的持久任务队列、per-agent 锁、LLM 路由、权限策略、工具 action / observation loop、对话和审计文件,并轮询到 `completed / failed / waiting-for-confirmation` 后用稳定键值行退出;`--init` 只在显式传入且 manifest 不存在时初始化项目。遇到待确认动作时 CLI 返回非零并打印 actionId、tool 和脱敏摘要,后续仍由开发窗口完成确认,不提供静默 `--yes` 绕过。
|
||||
- 2026-07-11 补充:后台 Agent 的工具规划和最终回复请求对 `LlmError::EmptyResponse` 最多自动重试 3 次(含首次共 4 次请求),与既有 Generator 对上游 HTTP 成功但空 content 的恢复策略一致;对 `Timeout / Connectivity / Transport` 及上游 `408 / 429 / 5xx` 最多额外自动重试 2 次并做线性退避,配置、请求、流能力、反序列化错误及其他 `4xx` 仍立即失败。重试发生在工具计划被解析和执行前,或最终回复尚未落盘时,不会重复执行已经落盘的工具动作。
|
||||
- 2026-07-11 调整:后台单 Agent 的 planning loop 上限从 3 轮提升到 6 轮,每轮工具动作上限仍为 3;真实代码任务已证明“搜索定位、分段读取、等待写入确认、写后复读”可能在第 3 轮才进入待确认状态,原上限会让确认后的同 run 没有继续验证余量。`maxLoopIterations` 随新上限写入 Runtime,跨重启待确认动作按已完成轮次继续使用剩余轮次;6 轮后 actions 仍未收束时继续进入 `failed / budget-exhausted`,不会伪装完成。该调整只作用于后台单 Agent Runtime,不改变游戏草案 Generator/Evaluator 的 3 轮上限;本文件和旧实施摘要中“后台 3 轮后整理最终回复”的历史描述由本条取代。
|
||||
- 2026-07-11 调整,2026-07-12 更新:后台单 Agent 的 planning loop 每 6 轮形成一个上下文压缩窗口,每轮工具动作上限仍为 3;6 轮不再是整个 run 的固定上限。`loopIteration` 在同一 run 内连续递增,`maxLoopIterations` 指向当前窗口结束轮次,跨重启待确认动作按 context bundle 的 `nextLoopIndex` 继续。每个窗口结束时压缩已有 observation;窗口产生新的独立观察时继续同一 run,最近 6 轮没有独立进展或相邻窗口指纹重复时才进入 `failed / budget-exhausted`,并记录 `loop-budget-exhausted`,不会伪装完成。该调整只作用于后台单 Agent Runtime,不改变游戏草案 Generator/Evaluator 的 3 轮上限;旧实施摘要中“后台 3 轮后整理最终回复”或“整个 run 最多 6 轮”的描述由本条取代。
|
||||
- 2026-07-12 补充:后台 Agent 每个 run 的可恢复 planning 上下文使用 `.agent/runtime/context-bundles/<agentId>/<runId>.json`。Runtime 通过临时文件替换原子写入,绑定 Agent、Task、Session、Run 和任务正文,保存 `nextLoopIndex`、当前窗口、计划、fallback response、压缩后的 observation 与上一窗口指纹;单文件最多 64 KiB、最多 12 条 observation。写入前统一截断并过滤敏感内容和项目绝对路径,安全校验失败时拒绝落盘;读取时要求普通文件,并校验 schema、Agent、Session、Run、任务正文和 observation 数量,身份不一致时拒绝续跑。该路径属于 Runtime 私有控制面,与根级 `.agent/context.bundle.json` 的旧 run-control 辅助文件不是同一契约,通用文件工具不得暴露。
|
||||
- 2026-07-11 调整:开发者投递的后台任务从队列记录、Runtime `currentTask/currentGoal` 到待确认动作私有账本统一保留最多 4,000 字符,不再在入队时截成 180 字符。180 字符只用于 UI、事件和审计预览;LLM planning、失败重试、确认续跑和重启恢复必须使用完整任务字段,避免位于长需求末尾的验收条件、禁止项或输出格式在真正执行前丢失。
|
||||
- 2026-07-11 调整:后台结构化 planning 使用独立的 4,000 输出 token 上限,最终回复使用 2,400;两者显式请求 low reasoning effort 和 low text verbosity。`platform-llm` 会把 reasoning effort 同时映射到 OpenAI Responses 的 `reasoning.effort` 与 Chat Completions 的 `reasoning_effort`,未设置时不新增字段。真实 gpt-5.5 Chat 响应曾连续消耗约 1,000-1,400 completion tokens 却不返回 message content,低推理强度、较大的可见输出余量和 EmptyResponse 重试共同构成恢复策略。
|
||||
- 2026-07-11 补充:后台单 Agent 的工具计划响应只接受可反序列化为计划 schema 的 JSON object。解析器提取模型输出中的首个完整对象并允许对象后带普通说明;未找到完整 JSON 对象,或提取对象无法反序列化为工具计划时,Runtime 最多追加 2 次自动格式修复请求。每次修复只携带限长、脱敏后的上一次无效输出,并写入 `agent.runtime.tool_plan.repair` 审计。两次修复后仍无有效对象则按工具规划失败处理;工具规划阶段的普通文本不得转换为默认的空 actions + response,也不得据此把任务标记为完成。
|
||||
@@ -4089,7 +4091,7 @@
|
||||
- 2026-07-10 调整:Agent Runtime 新增 `agent.schedule_ready` 调度入口,默认 `confirm` 权限。命令会扫描 `.agent/manifest.json` 中依赖已完成且仍为 `pending` 的 ready task,先把任务标成 `running`,再用 taskId 作为 Agent id 投递到既有后台队列,source 记为 `agent-ready-task-scheduler`,并写 `agent.runtime.ready_task.scheduled` 审计;后续执行仍走原 per-agent 锁、任务 JSONL、LLM loop、工具策略和事件流,不新增独立 worker。默认确认策略下该命令不会静默调度。
|
||||
- 2026-07-10 调整:Agent Runtime state 新增 `recentToolCalls`,后台 loop 每次执行白名单工具后记录最近 20 条结构化动作,包含 tool、status、actionFingerprint、inputSummary、reason、summary、detail 和 updatedAt。状态面板展示最近动作与安全目标摘要时使用该字段,不解析 observation 文本;写入前继续过滤敏感上下文,不保存原始密钥、待写正文或任意未过滤输入。
|
||||
- 2026-07-10 调整:Agent Runtime state 新增 `currentGoal` 和 `waitingOn`。`currentGoal` 固定表达本轮任务目标,`waitingOn` 表达当前等待 LLM、工具观察、开发者输入或失败处理;后台任务生命周期、`agent.run_status` observation、下一轮 planning prompt、开发单 Agent 对话页、项目内 Agent 对话弹窗和主窗口 Agent 状态列表都必须展示同一份目标 / 等待状态。
|
||||
- 2026-07-10 调整:Agent Runtime state 新增 `loopIteration / maxLoopIterations / toolActionBudget`。后台 Agent loop 每轮规划前刷新当前轮次、最大轮次和每轮工具动作预算;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示该进度,字段只做运行观测,不改变 loop 上限或权限 gate。
|
||||
- 2026-07-10 调整,2026-07-12 更新:Agent Runtime state 新增 `loopIteration / maxLoopIterations / toolActionBudget`。后台 Agent loop 每轮规划前刷新当前轮次、当前 6 轮上下文压缩窗口的结束轮次和每轮工具动作预算;`maxLoopIterations` 随窗口推进显示 6、12 等结束轮次。开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示该进度;字段只做运行观测,不构成单个 run 的固定轮数上限,也不改变权限 gate。
|
||||
- 2026-07-10 调整:Agent Runtime state 新增 `planSteps / activePlanStepIndex`。Runtime 从 Agent 输出的 `plan` 派生结构化计划步骤,并在 action / observation / response / error 生命周期中更新 `pending / active / completed / failed` 和 detail;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示步骤进度,不再只依赖不可定位的 plan 字符串。
|
||||
- 2026-07-10 调整:开发单 Agent 对话页和项目内 Agent 对话弹窗的 Runtime 面板接入 `recentEvents`,展示最近 `thinking_summary / plan / action / observation / response / error` 事件,避免只从当前状态、observation 字符串或最近工具动作里倒推 Agent loop。
|
||||
- 2026-07-10 调整:后台 Runtime 每次追加 `.agent/runtime/events/<agentId>.jsonl` 后会发送 `game-creator-agent-runtime-update` Tauri 事件,payload 带当前 `AgentRuntimeResult`;开发单 Agent 聊天页、项目内 Agent 对话弹窗和主窗口 Agent 状态列表实时合并该结果,但事件不替代 `.agent/runtime/agents`、`events` 和 `tasks` 的落盘事实源。
|
||||
@@ -4145,11 +4147,12 @@
|
||||
## 2026-07-10 AI 游戏创作 Agent Runtime 执行边界
|
||||
|
||||
- 决策:开发单 Agent 对话默认使用可执行 Runtime,输入区通过 `执行 / 聊天` 分段控件显式区分;`执行` 调用 `start_game_creator_agent_runtime_task` 并保留工具策略、确认、取消、排队和状态事件,`聊天` 才使用无工具流式回复,不再保留并列的“后台运行”按钮。消息区使用固定响应式网格行和内部滚动,并在 Runtime 非终态期间显示当前等待对象。Runtime 完成前必须先把 assistant 回复写入发起 Session,再写 completed 终态和广播;落盘失败只能进入 failed。前端收到匹配当前项目、Agent、Session 和 runId 的终态后自动重读对话,切换 Session 会清除当前等待投影,旧 run 事件不得覆盖新 Session。
|
||||
- 2026-07-12 修正:Runtime 状态为空时也要保留其网格行位,消息区和输入区显式固定到第 5、6 行,禁止空 Runtime 容器通过 `display:none` 让长消息落入 `auto` 行并撑高页面;等待 LLM 期间消息区同步使用 `aria-busy` 暴露忙碌状态。
|
||||
- 2026-07-12 修正:Runtime 状态为空时也要保留其网格行位,消息区和输入区显式固定到第 5、6 行,禁止空 Runtime 容器通过 `display:none` 让长消息落入 `auto` 行并撑高页面;等待 LLM 期间消息区同步使用 `aria-busy` 暴露忙碌状态。消息区只在用户仍接近底部时自动跟随最新片段,用户向上查看历史后暂停跟随,切换会话、重新读取或主动发送时再恢复。
|
||||
- 2026-07-12 修正:OpenAI-compatible 流式响应的空 `choices` usage / metadata 包不得再报缺少 `choices[0]`。首个 delta 前只有 `StreamUnavailable / EmptyResponse / Deserialize` 协议兼容错误允许由 Rust 单 Agent 流式入口回退一次非流式请求;上游状态、鉴权、额度、超时、连接和请求错误直接保留原错误,前端不得再次发起普通 LLM 请求。已收到正文和完成原因后继续保留完整流式回复,不能被尾部坏包覆盖。
|
||||
- 决策:后台 Agent 首轮不得预加载任何需要工具权限控制的项目内容。planning 与 final reply 只拿身份、session/run 元数据、任务、工具策略和已获准 observation;记忆、黑板、对话、资产和文件内容必须通过对应工具进入。最新黑板、记忆和对话采用尾部保留截断。
|
||||
- 决策:同一 Agent 的前台聊天与后台队列共享 per-Agent OS 执行锁,前台 LLM 等待期间不持有项目写锁;同 Agent 后台投递保持 pending,前台结束后把当前锁直接移交给 drain,drain 异常不得反写已经完成的聊天结果,不同 Agent 继续并行。
|
||||
- 决策:重启恢复继续遵守 `agent.resume` 默认确认策略。自动 command 只允许 auto;默认 confirm 由主工作区或独立开发 Agent 聊天窗口的 UI 明确确认后调用独立 command,确认绑定发起项目,切换项目取消旧确认且旧项目异步结果不得污染新项目状态;独立 command 只忽略 confirm、不允许绕过 deny,临时失败必须允许重试。
|
||||
- 决策:后台 Agent loop 只有空 actions 才算收束;三轮预算耗尽仍有动作时写 `failed / budget-exhausted` 和 `loop-budget-exhausted`,不再生成总结后记成 completed。解析阶段保留 action 总数,超过单轮预算时写 `runtime.tool_budget` 并只执行前三个;Runtime 默认工具列表必须直接从可执行白名单派生。
|
||||
- 决策:后台 Agent loop 只有空 actions 且不存在 `project.verify` blocker 才算收束;每 6 轮只形成上下文压缩窗口,不是整个 run 的固定预算。窗口产生新的独立 observation 时压缩上下文并继续同一 run;最近 6 轮没有独立进展或相邻窗口重复时写 `failed / budget-exhausted` 和 `loop-budget-exhausted`,不再生成总结后记成 completed。解析阶段保留 action 总数,超过单轮预算时写 `runtime.tool_budget` 并只执行前三个;Runtime 默认工具列表必须直接从可执行白名单派生。
|
||||
- 决策:`agent.delegate` 子任务必须 durable 保存 `parentAgentId / parentRunId / delegationId`,其中 `delegationId` 从已持久化工具动作的 `actionId` 派生,不能使用执行时随机值;终态任务记录必须保存经过统一凭据清洗和安全截断的 `terminalDetail`,不能依赖可能被后续 run 覆盖的 Agent 全局 state。子任务进入 `completed / failed / cancelled / budget-exhausted` 任一终态后,Runtime 必须在 delegation 级 OS 文件锁内按固定 receipt runId 幂等生成且至多生成一次 `agent.delegate.result` 回执;不同委派并发写同一目标 Agent 时,runId 分配与 pending 追加还必须在目标 Agent 任务账本 OS 锁内原子完成。失败、排队或活跃取消、预算耗尽与成功同等需要回执,`needs-reconciliation` 只有最终取消后才回执。父 Agent 通过既有队列接收 `source=agent-delegate-receipt` 的续跑任务,回执 prompt 禁止重复同一委派,并携带完整的已清洗 `terminalDetail`,不能只保留 UI 摘要;排队期间不提前写入父会话,真正执行时才幂等落盘,用户消息或回执消息落盘失败时不得进入 LLM。回执任务必须保留父 run 关联,真正开始或恢复前再次核验父 run,关联缺失或父 run 不存在时失败关闭;父 run 已取消或普通失败时只保留 suppressed receipt 审计,不自动复活。父 Session 存在未结束委派时禁止切换或归档,极端竞态下回执回落到父 Agent 当前可写 Session。续跑继续遵守同 Agent FIFO、per-Agent OS 锁、权限确认、取消、恢复和 `needs-reconciliation` 屏障,不允许直接重入、插队或重复投递;恢复必须先恢复 pending action / reconciliation 屏障,再补齐“子终态已落盘、回执未入队”的崩溃窗口。
|
||||
- 决策:Agent loop 的语义事件类型固定为 `thinking_summary / plan / action / observation / response / error`。普通失败和预算耗尽必须先追加统一 `error` 事件,同时保留 `turn.failed / turn.budget_exhausted` 生命周期事件供旧读取方兼容;状态、phase 和清洗后的错误详情必须在两类事件中一致。Runtime 状态面板默认展示最新 4 条事件,但在当前后端最近事件窗口大于 4 条时必须允许展开全部返回记录,不能让 `plan`、早期 observation 或 thinking summary 永久不可见。
|
||||
- 验证:Rust 覆盖首轮上下文不泄露、工具后 observation 可见、六类语义事件、确认前后内容边界、前后台同 Agent 串行、前台结束后队列 drain、恢复确认 gate、预算耗尽失败、默认工具白名单一致性,以及 delegate 成功 / 失败 / 取消 / 预算耗尽终态回执、`delegationId` 幂等去重和父 Agent receipt 续跑仍受 FIFO / 锁 / 确认 / 恢复门禁;前端覆盖统一事件展示、主工作区和独立开发 Agent 聊天窗口的默认恢复确认条与显式恢复 command。
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user