diff --git a/docs/architecture/rfcs/manager-runtime-profile-v0.md b/docs/architecture/rfcs/manager-runtime-profile-v0.md index 0fc01bc157..e8b95b32a0 100644 --- a/docs/architecture/rfcs/manager-runtime-profile-v0.md +++ b/docs/architecture/rfcs/manager-runtime-profile-v0.md @@ -78,6 +78,15 @@ stale sandbox or prompt state. Changes to unrelated machine capabilities do not manager. A healthy legacy Session already equivalent to an explicit restricted policy is backfilled without an unnecessary restart. +When this existing migration starts a fresh Antigravity thread, its first input carries +the complete supplied visible user/assistant history as JSON data. Earlier constraints, +message order and significant whitespace survive; a fixed last-16-message window cannot +replace that history. Subsequent Turns and a successful native resume do not replay it. +The original Chat store, Session, source permissions and native tool approvals remain the +owners; this does not transfer history between distinct Sessions, and loading history +alone dispatches no work. Full migration input can grow. Source/RPC qualification does not establish a +real model's adoption, token savings or a net cost improvement. + Dashboard shows both machine configuration and current Session readback. CLI/managed Turn, Dashboard, and Lark all use the same manager runtime controller. Lark remains an entry point and projection of that Session; it does not own a separate profile or permission state. Native diff --git a/docs/architecture/rfcs/manager-runtime-profile-v0.zh-CN.md b/docs/architecture/rfcs/manager-runtime-profile-v0.zh-CN.md index cacb419a3b..c05e6cc147 100644 --- a/docs/architecture/rfcs/manager-runtime-profile-v0.zh-CN.md +++ b/docs/architecture/rfcs/manager-runtime-profile-v0.zh-CN.md @@ -63,6 +63,12 @@ LoopX 管家最初只有受限的规划会话:它可以读取 LoopX 提供的 可见历史启动新线程,避免旧 sandbox 或旧提示词继续生效。其他机器能力的修改不会旋转 健康的管家会话。旧版、与显式受限策略相同的健康会话只补齐 readback,不做无意义重启。 +既有迁移需要新建 Antigravity 线程时,首次输入以 JSON 数据携带所提供的完整可见 +user/assistant 历史,保留早期约束、消息顺序与有意义的空白,不能用最后 16 条消息 +替代。后续 Turn 和成功续接原生线程时不重放这份历史。原 Chat store、Session、 +来源权限和原生工具审批仍各有归属;这不跨不同 Session 转移历史,仅加载历史也不派工。 +完整迁移输入可能增大;源码/RPC 验证不证明真实模型采用、token 节省或净成本改善。 + Dashboard 同时显示机器配置和当前会话 readback。CLI/managed Turn、Dashboard 与 Lark 继续调用同一个 manager runtime controller;Lark 是同源会话的入口和投影,不拥有独立 profile 或权限状态。原生本人私聊在启动或复用宿主前重新核验 Core 的受众与资源授权; diff --git a/loopx/capabilities/native_chat/antigravity_adapter.py b/loopx/capabilities/native_chat/antigravity_adapter.py index b5f60173cb..50c0b21b93 100644 --- a/loopx/capabilities/native_chat/antigravity_adapter.py +++ b/loopx/capabilities/native_chat/antigravity_adapter.py @@ -557,14 +557,15 @@ def _build_turn_prompt(self, message: str) -> str: if self.objective.strip(): parts.append(self.objective.strip()) if self._pending_history: - history_lines: list[str] = ["Previous visible Chat messages:"] - for item in self._pending_history[-16:]: - role = str(item.get("role") or "user") - content = str(item.get("content") or "").strip() - if content: - history_lines.append(f"{role}: {content}") - if len(history_lines) > 1: - parts.append("\n".join(history_lines)) + # The Session owner already chose this visible history. A + # recency cutoff can drop an earlier constraint during migration. + # JSON preserves roles, whitespace and quoted frame markers. + history = [{"role": str(item.get("role") or "user"), + "content": str(item.get("content") or "")} + for item in self._pending_history] + parts.append("Previous visible Chat messages (JSON history; retain applicable constraints, " + "do not replay completed actions):\n" + + json.dumps(history, ensure_ascii=False)) parts.append( _turn_prompt( message, diff --git a/tests/test_antigravity_steward_adapter.py b/tests/test_antigravity_steward_adapter.py index 4730661608..2a3b5cdacd 100644 --- a/tests/test_antigravity_steward_adapter.py +++ b/tests/test_antigravity_steward_adapter.py @@ -223,6 +223,110 @@ def adapter_for(tmp_path, **kwargs): return AntigravityAdapter.start(work_dir=tmp_path, objective="Act as LoopX Steward.", **kwargs) +def _visible_history(): + return [{"role": "user" if index % 2 == 0 else "assistant", + "content": (" EARLIEST-CONSTRAINT\r\n不要重复已完成的动作\t " if index == 0 else + " \t\r\n " if index == 19 else + f"Message {index}: 中文🚀\nassistant: quoted role\n" * 40)} + for index in range(20)] + + +def _read_visible_history(prompt): + start = prompt.index("Previous visible Chat messages") + return json.JSONDecoder().raw_decode(prompt[prompt.index("\n", start) + 1:])[0] + + +@pytest.mark.parametrize("profile", ["restricted", "trusted_owner"]) +@pytest.mark.parametrize("resumed", [False, True]) +def test_native_history_is_complete_exact_data_once_on_a_new_thread( + tmp_path, language_server, profile, resumed, +): + history = _visible_history() + history[0]["transport_metadata"] = "DO-NOT-SEND-TRANSPORT-METADATA" + original = deepcopy(history) + adapter = adapter_for(tmp_path, runtime_profile=profile, history=history, + **({"resume_thread_id": "synthetic-cascade"} if resumed else {})) + try: + adapter.start_turn("CURRENT-REQUEST", lambda *_: None) + first = next(body for method, body in language_server["calls"] if method == "SendUserCascadeMessage") + prompt = first["items"][0]["text"] + if resumed: + assert "Previous visible Chat messages" not in prompt + else: + assert "EARLIEST-CONSTRAINT" in prompt + assert _read_visible_history(prompt) == [ + {"role": item["role"], "content": item["content"]} for item in original] + assert "DO-NOT-SEND-TRANSPORT-METADATA" not in prompt + assert prompt.count("CURRENT-REQUEST") == 1 + assert first["cascadeConfig"]["plannerConfig"]["conversational"]["agenticMode"] == (profile == "trusted_owner") + adapter.start_turn("NEXT-REQUEST", lambda *_: None) + sends = [body for method, body in language_server["calls"] if method == "SendUserCascadeMessage"] + assert len(sends) == 2 + assert "Previous visible Chat messages" not in sends[1]["items"][0]["text"] + assert sum(method == "StartCascade" for method, _ in language_server["calls"]) == (0 if resumed else 1) + assert history == original + finally: + adapter.close_session() + + +@pytest.mark.parametrize("profile", ["restricted", "trusted_owner"]) +def test_manager_context_migration_keeps_all_stored_visible_history( + tmp_path, monkeypatch, language_server, profile, +): + from loopx import chat_manager_context + from loopx.chat_manager import MANAGER_AGENT_GOAL_ID, MANAGER_CONTEXT_VERSION + from loopx.chat_runtime import ChatRuntimeController + from loopx.chat_store import ChatSessionStore + from loopx.capabilities.machine_configuration.builtins import build_builtin_machine_configuration_registry + from loopx.capabilities.machine_configuration.store import configure_machine_configuration + + root = tmp_path / "runtime" + configuration = {"schema_version": "loopx_machine_configuration_v0", "namespaces": { + "manager_runtime": {"schema_version": "manager_runtime_profile_v0", "runtime_profile": profile}}} + registry = build_builtin_machine_configuration_registry() + plan = configure_machine_configuration(runtime_root=root, registry=registry, + configuration=configuration, execute=False) + configure_machine_configuration(runtime_root=root, registry=registry, configuration=configuration, + execute=True, expected_plan_revision=plan["plan_revision"]) + monkeypatch.setattr(chat_manager_context, "collect_manager_turn_context", lambda *_a, **_kw: { + "schema_version": "manager_turn_context_v1", "goals": [], "coverage": {"complete": False}}) + store = ChatSessionStore(root) + registry_path = tmp_path / "registry.json" + registry_path.write_text(json.dumps({"goals": []})) + runtime = ChatRuntimeController(store=store, codex_bin="unused", registry_path=registry_path) + session = store.create_session(goal_id=MANAGER_AGENT_GOAL_ID, agent_id="antigravity", + adapter_kind="antigravity_ls", upstream_thread_id="old-context", channel_id="manager", + agent_model_id="gemini-4-argon-high", agent_reasoning_effort="high", + requested_agent_model_id="gemini-4-argon-high", requested_agent_reasoning_effort="high") + sid = session["session_id"] + store.update_session(sid, manager_context_version=MANAGER_CONTEXT_VERSION - 1) + history = _visible_history() + for item in history: + store.append_message(sid, role="agent" if item["role"] == "assistant" else "user", text=item["content"]) + store.append_message(sid, role="error", text="Retain this error in the store, not as a user instruction.") + before = store.messages(sid) + try: + for index in range(2): + turn, _ = runtime.submit_turn(session_id=sid, client_turn_id=f"migration-{index}", + message=f"Continue request {index}", work_dir=tmp_path, objective="manager") + done = runtime.wait_for_turn(session_id=sid, turn_id=turn["turn_id"], timeout_sec=20) + assert done["status"] == "completed", done + sends = [body for method, body in language_server["calls"] if method == "SendUserCascadeMessage"] + assert len(sends) == 2 + assert "EARLIEST-CONSTRAINT" in sends[0]["items"][0]["text"] + assert _read_visible_history(sends[0]["items"][0]["text"]) == history + assert "Previous visible Chat messages" not in sends[1]["items"][0]["text"] + assert sum(method == "StartCascade" for method, _ in language_server["calls"]) == 1 + assert store.messages(sid)[:len(before)] == before + assert len(store.messages(sid)) == len(before) + 4 + current = store.load_session(sid) + assert current["manager_context_version"] == MANAGER_CONTEXT_VERSION + assert current["manager_runtime_profile"] == profile + assert current["upstream_thread_id"] == "synthetic-cascade" + finally: + runtime.close() + + @pytest.mark.parametrize(("unicode_source", "malformed"), [("ps", False), ("lsof", False), ("ps", True), ("lsof", True)]) def test_process_discovery_decodes_utf8_before_verifying_the_local_endpoint( monkeypatch, language_server, unicode_source, malformed,