diff --git a/docs/api.md b/docs/api.md index dc70bfd4..12a44ad1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -37,7 +37,7 @@ curl -sS http://127.0.0.1:8000/api/chat \ -d '{"session_id":"demo","profile":"auto","message":"Explain this project"}' ``` -The success body contains `reply`, `session_id`, `profile`, `decision_assist`, `memory_receipt`, `cost`, `interaction`, and `context`. Interaction controls share this endpoint. `mode` selects a supported interaction mode; `question_response` resolves a pending clarification using its `request_id`, `action`, and `answers`; `plan_action` supplies `plan_id`, positive `version`, and `action` (`accept`, `revise`, or `cancel`). A question response and plan action cannot appear together. `system_one_backend` (legacy `fast_backend`) selects `off`, `jev`, or `kev`; `decision_assist_backend` selects its corresponding backend. These controls do not bypass authorization or tool gates. A Jev API key can be supplied for setup but must remain secret. +The success body contains `reply`, `session_id`, `profile`, `decision_assist`, `memory_receipt`, `cost`, `interaction`, and `context`. Interaction controls share this endpoint. `mode` selects a supported interaction mode; `question_response` resolves a pending clarification using its `request_id`, `action`, and `answers`; `plan_action` supplies `plan_id`, positive `version`, and `action` (`accept`, `revise`, or `cancel`). Accept actions must also echo the proposal's `fingerprint` from `interaction.pending_plan`; stale fingerprints are rejected so queued requests cannot accept a replacement proposal. Text `accept plan` requests snapshot the pending proposal identity when received. A question response and plan action cannot appear together. `system_one_backend` (legacy `fast_backend`) selects `off`, `jev`, or `kev`; `decision_assist_backend` selects its corresponding backend. These controls do not bypass authorization or tool gates. A Jev API key can be supplied for setup but must remain secret. ## Streaming chat diff --git a/openkyrozen/agent/loop.py b/openkyrozen/agent/loop.py index ccc9b3ce..67c046e4 100644 --- a/openkyrozen/agent/loop.py +++ b/openkyrozen/agent/loop.py @@ -67,18 +67,22 @@ def _chat_turn(self, user_input: str, clear_tasks: bool = False, profile: str | if plan_acceptance is not None: plan_id = plan_acceptance.get("plan_id") version = plan_acceptance.get("version") + fingerprint = plan_acceptance.get("fingerprint") if (not isinstance(plan_id, str) or not plan_id.strip() - or not isinstance(version, int) or isinstance(version, bool) or version < 1): + or not isinstance(version, int) or isinstance(version, bool) or version < 1 + or not isinstance(fingerprint, str) or not fingerprint): raise InteractionError("plan acceptance identity is invalid") current_state = self._interaction_controller.state() current_plan = current_state.get("pending_plan") or current_state.get("executing_plan") if (not current_plan or current_plan.get("plan_id") != plan_id - or current_plan.get("version") != version): + or current_plan.get("version") != version + or current_plan.get("fingerprint") != fingerprint): raise InteractionError("plan acceptance identity is stale") accepted_plan, created = self._interaction_controller.accept_plan( self.tasks, plan_id=plan_id if plan_acceptance is not None else None, version=version if plan_acceptance is not None else None, + fingerprint=fingerprint if plan_acceptance is not None else None, ) except InteractionError: early_reply = ( diff --git a/openkyrozen/agent/modes.py b/openkyrozen/agent/modes.py index 3373bd0f..c2c6d01f 100644 --- a/openkyrozen/agent/modes.py +++ b/openkyrozen/agent/modes.py @@ -738,23 +738,28 @@ def accepted_plan(self, plan_id: str | None = None, version: int | None = None) ) if field in accepted[key]}} def accept_plan(self, task_manager: Any, *, plan_id: str | None = None, - version: int | None = None) -> tuple[dict[str, Any], bool]: + version: int | None = None, fingerprint: str | None = None) -> tuple[dict[str, Any], bool]: state = self.state() plan = state.get("pending_plan") executing = state.get("executing_plan") if executing and not plan and (not plan_id or plan_id == executing.get("plan_id")): if version is not None and version != executing.get("version"): raise InteractionError("accepted plan version is stale") + if fingerprint is not None and fingerprint != executing.get("fingerprint"): + raise InteractionError("accepted plan fingerprint is stale") return executing, False if not plan: if executing and plan_id and plan_id != executing.get("plan_id"): raise InteractionError("accepted plan identity is stale") accepted = self.accepted_plan(plan_id, version) - if accepted: + if accepted and (fingerprint is None or accepted.get("fingerprint") == fingerprint): return accepted, False + if accepted: + raise InteractionError("accepted plan fingerprint is stale") raise InteractionError("plan is not pending") if ((plan_id and plan_id != plan.get("plan_id")) - or (version is not None and version != plan.get("version"))): + or (version is not None and version != plan.get("version")) + or (fingerprint is not None and fingerprint != plan.get("fingerprint"))): raise InteractionError("plan is not pending at the requested version") if executing and task_manager.working_plan is None: task_manager.recover() diff --git a/openkyrozen/interfaces/cli/chat.py b/openkyrozen/interfaces/cli/chat.py index 642bfa41..ed1be6cb 100644 --- a/openkyrozen/interfaces/cli/chat.py +++ b/openkyrozen/interfaces/cli/chat.py @@ -18,7 +18,8 @@ def _run_cli_chat(self, user_input, interaction_before): interaction_before["pending_question"] or interaction_before["pending_plan"] or is_plan_acceptance(sanitized) ), **({"plan_acceptance": {"plan_id": pending_plan["plan_id"], - "version": pending_plan["version"]}} + "version": pending_plan["version"], + "fingerprint": pending_plan["fingerprint"]}} if pending_plan and is_plan_acceptance(sanitized) else {})) except Exception as e: import traceback as _tb diff --git a/openkyrozen/interfaces/tui/commands.py b/openkyrozen/interfaces/tui/commands.py index a56053b4..fa5e3129 100644 --- a/openkyrozen/interfaces/tui/commands.py +++ b/openkyrozen/interfaces/tui/commands.py @@ -277,7 +277,8 @@ def _command(self, name: str, args: Any, request_id: str) -> None: self.interaction(request_id) elif action == "accept": plan = runtime._interaction_controller.state().get("pending_plan") - acceptance = ({"plan_id": plan["plan_id"], "version": plan["version"]} + acceptance = ({"plan_id": plan["plan_id"], "version": plan["version"], + "fingerprint": plan["fingerprint"]} if plan else None) self.submit("accept plan", request_id, plan_acceptance=acceptance) elif action == "cancel": diff --git a/openkyrozen/interfaces/tui/controls.py b/openkyrozen/interfaces/tui/controls.py index 5ee050e6..064a8c68 100644 --- a/openkyrozen/interfaces/tui/controls.py +++ b/openkyrozen/interfaces/tui/controls.py @@ -52,16 +52,25 @@ def _plan_action(self, payload: dict[str, Any], request_id: str) -> None: self.emit("error", request_id, code="invalid_plan_action", error="plan version must be a positive integer") return plan = runtime._interaction_controller.state().get("pending_plan") - if action == "accept" and not plan and runtime._interaction_controller.accepted_plan(plan_id, version): - self.emit("response", request_id, text="Plan was already accepted.") - self.interaction(request_id) - return + if action == "accept" and not plan: + accepted = runtime._interaction_controller.accepted_plan(plan_id, version) + if accepted and payload.get("fingerprint") == accepted.get("fingerprint"): + self.emit("response", request_id, text="Plan was already accepted.") + self.interaction(request_id) + return + if accepted: + self.emit("error", request_id, code="plan_not_pending", error="accepted plan fingerprint is stale") + return if not plan or (plan_id and plan_id != plan.get("plan_id")) or (version is not None and version != plan.get("version")): self.emit("error", request_id, code="plan_not_pending", error="plan is not pending at the requested version") return if action == "accept": + fingerprint = payload.get("fingerprint") + if not isinstance(fingerprint, str) or fingerprint != plan.get("fingerprint"): + self.emit("error", request_id, code="plan_not_pending", error="plan fingerprint is stale") + return self.submit("accept plan", request_id, plan_acceptance={ - "plan_id": plan["plan_id"], "version": plan["version"], + "plan_id": plan["plan_id"], "version": plan["version"], "fingerprint": fingerprint, }) elif action == "cancel": runtime.cancel_interaction_plan(plan_id, version) diff --git a/openkyrozen/interfaces/tui/turns.py b/openkyrozen/interfaces/tui/turns.py index 4e804ed2..9975d60b 100644 --- a/openkyrozen/interfaces/tui/turns.py +++ b/openkyrozen/interfaces/tui/turns.py @@ -166,6 +166,10 @@ def callback(event: dict[str, Any] | RuntimeEvent) -> None: def submit(self, text: str, request_id: str, *, plan_acceptance: dict[str, Any] | None = None) -> None: + if plan_acceptance is None and self.agent.is_plan_acceptance(text): + plan = self.agent._interaction_controller.state().get("pending_plan") + if plan: + plan_acceptance = {key: plan[key] for key in ("plan_id", "version", "fingerprint")} with self._state_lock: if self._busy: self.emit("error", request_id, code="busy", error="A turn is already running.") diff --git a/openkyrozen/interfaces/web/chat.py b/openkyrozen/interfaces/web/chat.py index 4a38b123..5ed3799c 100644 --- a/openkyrozen/interfaces/web/chat.py +++ b/openkyrozen/interfaces/web/chat.py @@ -259,14 +259,21 @@ def _apply_chat_controls(self, session: dict[str, Any], body: dict[str, Any], me plan_id = str(plan_action.get("plan_id") or "") or None version_raw = plan_action.get("version") version = version_raw if isinstance(version_raw, int) and not isinstance(version_raw, bool) else None + fingerprint = plan_action.get("fingerprint") if not plan_id or version is None or version < 1: raise HTTPException(400, "plan_action.plan_id and positive integer version are required") plan = controller.state().get("pending_plan") - if action == "accept" and not plan and controller.accepted_plan(plan_id, version): - return "", "Plan was already accepted." + if action == "accept" and not plan: + accepted = controller.accepted_plan(plan_id, version) + if accepted and isinstance(fingerprint, str) and accepted.get("fingerprint") == fingerprint: + return "", "Plan was already accepted." + if accepted: + raise HTTPException(409, "accepted plan fingerprint is stale") if not plan or (plan_id and plan_id != plan.get("plan_id")) or (version is not None and version != plan.get("version")): raise HTTPException(409, "plan is not pending at the requested version") if action == "accept": + if not isinstance(fingerprint, str) or fingerprint != plan.get("fingerprint"): + raise HTTPException(409, "plan fingerprint is stale; review the current proposal") message = "accept plan" elif action == "cancel": controller.cancel_plan(plan_id, version) @@ -404,9 +411,14 @@ async def api_chat(self, request: Request): if "fast_backend" in body or "system_one_backend" in body or "decision_assist_backend" in body else service._apply_chat_controls(session, body, msg)) plan_action = body.get("plan_action") - plan_acceptance = ({"plan_id": plan_action["plan_id"], "version": plan_action["version"]} + plan_acceptance = ({"plan_id": plan_action["plan_id"], "version": plan_action["version"], + "fingerprint": plan_action["fingerprint"]} if isinstance(plan_action, dict) and str(plan_action.get("action") or "").lower() == "accept" else None) + if plan_acceptance is None and service._agent.is_plan_acceptance(msg): + pending = service._interaction_for_session(session).state().get("pending_plan") + if pending: + plan_acceptance = {key: pending[key] for key in ("plan_id", "version", "fingerprint")} if not msg and immediate_reply is None: raise HTTPException(400, "Empty message") service._audit("CHAT", f"user={session['user_id']} msg={msg[:80]}", session["user_id"]) @@ -455,9 +467,14 @@ async def api_chat_stream(self, request: Request): if "fast_backend" in body or "system_one_backend" in body or "decision_assist_backend" in body else service._apply_chat_controls(session, body, msg)) plan_action = body.get("plan_action") - plan_acceptance = ({"plan_id": plan_action["plan_id"], "version": plan_action["version"]} + plan_acceptance = ({"plan_id": plan_action["plan_id"], "version": plan_action["version"], + "fingerprint": plan_action["fingerprint"]} if isinstance(plan_action, dict) and str(plan_action.get("action") or "").lower() == "accept" else None) + if plan_acceptance is None and service._agent.is_plan_acceptance(msg): + pending = service._interaction_for_session(session).state().get("pending_plan") + if pending: + plan_acceptance = {key: pending[key] for key in ("plan_id", "version", "fingerprint")} if not msg and immediate_reply is None: raise HTTPException(400, "Empty message") service._audit("CHAT_STREAM", f"user={session['user_id']} msg={msg[:80]}", session["user_id"]) diff --git a/openkyrozen/interfaces/web/templates/chat.html b/openkyrozen/interfaces/web/templates/chat.html index e6ac2129..8744f888 100644 --- a/openkyrozen/interfaces/web/templates/chat.html +++ b/openkyrozen/interfaces/web/templates/chat.html @@ -216,7 +216,7 @@