From bb1a9f04531a213e8e0909eecbf9cba18aea9de7 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Tue, 3 Nov 2026 04:14:31 +0800 Subject: [PATCH] fix(chat): keep native turns open through transient idle Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/manager-runtime-profile-v0.md | 6 ++ .../rfcs/manager-runtime-profile-v0.zh-CN.md | 4 + .../native_chat/antigravity_adapter.py | 13 ++- tests/test_antigravity_steward_adapter.py | 92 ++++++++++++++++++- 4 files changed, 112 insertions(+), 3 deletions(-) diff --git a/docs/architecture/rfcs/manager-runtime-profile-v0.md b/docs/architecture/rfcs/manager-runtime-profile-v0.md index 0fc01bc157..722fb6c8d3 100644 --- a/docs/architecture/rfcs/manager-runtime-profile-v0.md +++ b/docs/architecture/rfcs/manager-runtime-profile-v0.md @@ -56,6 +56,12 @@ These defaults do not change ordinary Goal Chat models or grant other Agents too Argon tiers must be offered by the local account and match the requested reasoning effort. A missing original cascade fails resume without silently starting a replacement. Stop waits for the native cascade to become idle; timeout or partial output is not successful completion. + During execution, native `IDLE` alone is not completion: it can appear between input, tool + and planner steps. LoopX keeps observing the original invocation without resending input; + completion requires a final answer with the normal stop marker and all covered current + steps to be done, matching native result recovery's completion requirement. + Transient idle does not extend the existing no-progress or hard deadline. Permission gates, + native errors, unverified stop reasons and another input retain their existing refusal paths. - `trusted_owner` applies to the local private owner-manager and a native private steward binding freshly verified by the existing Core conversation owner. That owner checks the App, owner principal, private source, current resource grant and frozen Session identity. 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..7d218c8f65 100644 --- a/docs/architecture/rfcs/manager-runtime-profile-v0.zh-CN.md +++ b/docs/architecture/rfcs/manager-runtime-profile-v0.zh-CN.md @@ -46,6 +46,10 @@ LoopX 管家最初只有受限的规划会话:它可以读取 LoopX 提供的 这些契约。Argon 档位必须由本地账号提供,并与请求的思考深度一致。原始 cascade 丢失 时续接失败,不静默创建替代会话。停止等待原生 cascade 回到 idle;超时或部分输出 不代表成功完成。 + 执行时,原生 `IDLE` 本身不代表完成:输入、工具与 planner 步骤之间也可能出现此状态。 + LoopX 继续观察原调用,不重发输入;最终答案须带正常停止标记,且本次覆盖的全部步骤均 + 已完成才能收尾,与原生结果恢复的完成要求一致。短暂 idle 不延长既有无进展或硬截止 + 时间;权限等待、原生错误、未核验的停止原因及另一个输入仍沿既有拒绝路径处理。 - `trusted_owner` 适用于本地私有 Owner 管家和由既有 Core conversation owner 重新核验的 原生本人管家私聊。Core 核验 App、Owner 身份、私聊来源、当前资源授权和冻结的 Session 身份;受众证明本身不授予工具,仍按实际机器策略执行,包括显式受限选择。其他外部 diff --git a/loopx/capabilities/native_chat/antigravity_adapter.py b/loopx/capabilities/native_chat/antigravity_adapter.py index b5f60173cb..6bf7428478 100644 --- a/loopx/capabilities/native_chat/antigravity_adapter.py +++ b/loopx/capabilities/native_chat/antigravity_adapter.py @@ -796,6 +796,16 @@ def _run_turn( if saw_running_or_new_step and status == "CASCADE_RUN_STATUS_IDLE" and observed_total == total_steps: final = current_steps[-1][1] if current_steps else {} planner = final.get("plannerResponse") or {} + # Native IDLE can occur between input, tool and planner steps. + # Keep observing this invocation; only a finished answer after + # all current work settles can close it. The existing idle and + # hard deadlines, permission gates and cancellation still apply. + if (planner.get("stopReason") in {None, "STOP_REASON_STOP_PATTERN"} + and (final.get("type") != "CORTEX_STEP_TYPE_PLANNER_RESPONSE" or planner.get("toolCalls") + or (planner.get("stopReason") is None and not self._read_only_review) + or any(step.get("status") in {"CORTEX_STEP_STATUS_GENERATING", "CORTEX_STEP_STATUS_RUNNING"} + for _, step in current_steps))): + continue if self._read_only_review: inputs = [step for _, step in current_steps if step.get("type") == "CORTEX_STEP_TYPE_USER_INPUT"] execution = (inputs[0].get("metadata") or {}).get("executionId") if len(inputs) == 1 else None @@ -807,8 +817,9 @@ def _run_turn( gate=None, error_code="upstream_transport_failure") if (final.get("type") != "CORTEX_STEP_TYPE_PLANNER_RESPONSE" or final.get("status") != "CORTEX_STEP_STATUS_DONE" or planner.get("toolCalls") + or any(step.get("status") != "CORTEX_STEP_STATUS_DONE" for _, step in current_steps) or not str(planner.get("modifiedResponse") or planner.get("response") or "").strip() - or planner.get("stopReason") not in {None, "STOP_REASON_STOP_PATTERN"}): + or planner.get("stopReason") != "STOP_REASON_STOP_PATTERN"): raise CodexChatAgentError("Antigravity ended without a verified final response; execution may have occurred.", gate=None, error_code="upstream_transport_failure") break diff --git a/tests/test_antigravity_steward_adapter.py b/tests/test_antigravity_steward_adapter.py index 4730661608..4438c5a13b 100644 --- a/tests/test_antigravity_steward_adapter.py +++ b/tests/test_antigravity_steward_adapter.py @@ -581,6 +581,89 @@ def test_default_core_keeps_an_original_progressing_invocation_past_five_minutes controller.close_session(sid) +@pytest.mark.parametrize("pause", ["before-input", "input", "planner", "command", "command-done", "earlier-command", "unconfirmed-answer", "tool-call"]) +def test_transient_idle_does_not_finish_or_cancel_the_original_turn( + tmp_path, language_server, monkeypatch, pause, +): + from loopx.chat_runtime import ChatRuntimeController + from loopx.chat_store import ChatSessionStore + + user = {"type": "CORTEX_STEP_TYPE_USER_INPUT", "status": "CORTEX_STEP_STATUS_DONE"} + planner = {"type": "CORTEX_STEP_TYPE_PLANNER_RESPONSE", "status": "CORTEX_STEP_STATUS_GENERATING", + "plannerResponse": {"thinking": "private native reasoning"}} + command = {"type": "CORTEX_STEP_TYPE_RUN_COMMAND", "status": "CORTEX_STEP_STATUS_GENERATING", + "runCommand": {"commandLine": "synthetic-work", "combinedOutput": ""}} + final = {"type": "CORTEX_STEP_TYPE_PLANNER_RESPONSE", "status": "CORTEX_STEP_STATUS_DONE", + "plannerResponse": {"response": "original task finished", "stopReason": "STOP_REASON_STOP_PATTERN"}} + paused = { + "before-input": [], "input": [user], "planner": [user, planner], "command": [user, command], + "command-done": [user, {**command, "status": "CORTEX_STEP_STATUS_DONE"}], + "earlier-command": [user, command, final], + "unconfirmed-answer": [user, {"type": "CORTEX_STEP_TYPE_PLANNER_RESPONSE", "status": "CORTEX_STEP_STATUS_DONE", + "plannerResponse": {"response": "intermediate answer"}}], + "tool-call": [user, {"type": "CORTEX_STEP_TYPE_PLANNER_RESPONSE", "status": "CORTEX_STEP_STATUS_DONE", + "plannerResponse": {"response": "working", "toolCalls": [{"name": "run_command"}]}}], + }[pause] + running = deepcopy(paused) or [user] + if not any(step["type"] == "CORTEX_STEP_TYPE_RUN_COMMAND" for step in running): + running.append(command) + completed = [{**step, "status": "CORTEX_STEP_STATUS_DONE"} for step in running] + if completed[-1] != final: + completed.append(final) + _native_clock(monkeypatch, language_server, [ + {"at": 1, "steps": [], "status": "CASCADE_RUN_STATUS_RUNNING"}, + {"at": 2, "steps": paused, "status": "CASCADE_RUN_STATUS_IDLE"}, + {"at": 3, "steps": running, "status": "CASCADE_RUN_STATUS_RUNNING"}, + {"at": 4, "steps": completed, "status": "CASCADE_RUN_STATUS_IDLE"}, + ]) + if pause == "tool-call": + # Ordinary Chat is restricted. Exercise native tool continuation only + # through the existing trusted-owner provider profile. + result = adapter_for(tmp_path, runtime_profile="trusted_owner").start_turn("finish the task") + assert result["message"] == "original task finished" + assert language_server["now"] == 4 + assert not language_server["cancelled"].is_set() + assert sum(method == "SendUserCascadeMessage" for method, _ in language_server["calls"]) == 1 + return + store = ChatSessionStore(tmp_path / "runtime" / "chat") + controller = ChatRuntimeController(store=store, codex_bin="unused-codex") + session, _ = controller.open_session(goal_id="synthetic-goal", agent_id="antigravity", work_dir=tmp_path, + objective="Synthetic conversation", mode="new", channel_id="synthetic-argon-chat") + sid = session["session_id"] + try: + turn, _ = controller.submit_turn(session_id=sid, client_turn_id="one-original-input", message="finish the task", + work_dir=tmp_path, objective="Synthetic conversation") + result = controller.wait_for_turn(session_id=sid, turn_id=turn["turn_id"], timeout_sec=8) + assert result["status"] == "completed" + assert result["response"]["message"] == "original task finished" + assert language_server["now"] == 4 + assert not language_server["cancelled"].is_set() + assert [m["text"] for m in store.messages(sid) if m["role"] == "agent"] == ["original task finished"] + assert sum(method == "SendUserCascadeMessage" for method, _ in language_server["calls"]) == 1 + finally: + controller.close_session(sid) + + +@pytest.mark.parametrize("pending", ["command", "unconfirmed-answer"]) +def test_transient_idle_cannot_reset_the_no_progress_deadline(tmp_path, language_server, monkeypatch, pending): + from loopx.chat_agent import CodexChatAgentError + + user = {"type": "CORTEX_STEP_TYPE_USER_INPUT", "status": "CORTEX_STEP_STATUS_DONE"} + command = {"type": "CORTEX_STEP_TYPE_RUN_COMMAND", "status": "CORTEX_STEP_STATUS_GENERATING"} + if pending == "unconfirmed-answer": + command = {"type": "CORTEX_STEP_TYPE_PLANNER_RESPONSE", "status": "CORTEX_STEP_STATUS_DONE", + "plannerResponse": {"response": "intermediate answer"}} + _native_clock(monkeypatch, language_server, [ + {"at": 1, "steps": [user, command], "status": "CASCADE_RUN_STATUS_IDLE"}, + {"at": 302, "steps": [user, command], "status": "CASCADE_RUN_STATUS_IDLE"}, + ]) + with pytest.raises(CodexChatAgentError) as error: + adapter_for(tmp_path, runtime_profile="trusted_owner").start_turn("finish the task") + assert error.value.error_code == "response_timeout" + assert language_server["cancelled"].is_set() + assert sum(method == "SendUserCascadeMessage" for method, _ in language_server["calls"]) == 1 + + @pytest.mark.parametrize("hard_limit,stagnant", [(300.0, False), (None, True)]) def test_explicit_deadline_and_default_no_progress_timeout_still_stop(tmp_path, language_server, monkeypatch, hard_limit, stagnant): from loopx.chat_agent import CodexChatAgentError @@ -603,14 +686,19 @@ def test_invalid_hard_timeout_creates_no_native_invocation(tmp_path, language_se @pytest.mark.parametrize("reason", ["STOP_REASON_CLIENT_STREAM_ERROR", "unrecognized-native-stop"]) -def test_idle_done_with_an_unverified_native_stop_never_becomes_success(tmp_path, language_server, monkeypatch, reason): +@pytest.mark.parametrize("pending", ["none", "generating", "tool-call"]) +def test_idle_done_with_an_unverified_native_stop_never_becomes_success(tmp_path, language_server, monkeypatch, reason, pending): from loopx.chat_agent import CodexChatAgentError frames = _native_frames(stopped=reason) frames[-1]["at"] = 1.0 + if pending == "generating": + frames[-1]["steps"][-1]["status"] = "CORTEX_STEP_STATUS_GENERATING" + elif pending == "tool-call": + frames[-1]["steps"][-1]["plannerResponse"]["toolCalls"] = [{"name": "run_command"}] _native_clock(monkeypatch, language_server, [frames[-1]]) events = [] with pytest.raises(CodexChatAgentError) as error: - adapter_for(tmp_path).start_turn("task", lambda kind, data: events.append((kind, data))) + adapter_for(tmp_path, runtime_profile="trusted_owner").start_turn("task", lambda kind, data: events.append((kind, data))) assert error.value.error_code == "upstream_transport_failure" assert not any(kind == "answer.final" for kind, _ in events) assert len([1 for method, _ in language_server["calls"] if method == "SendUserCascadeMessage"]) == 1