Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 6 additions & 2 deletions openkyrozen/agent/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
11 changes: 8 additions & 3 deletions openkyrozen/agent/modes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 2 additions & 1 deletion openkyrozen/interfaces/cli/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion openkyrozen/interfaces/tui/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
19 changes: 14 additions & 5 deletions openkyrozen/interfaces/tui/controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions openkyrozen/interfaces/tui/turns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
25 changes: 21 additions & 4 deletions openkyrozen/interfaces/web/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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"])
Expand Down
2 changes: 1 addition & 1 deletion openkyrozen/interfaces/web/templates/chat.html
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ <h2 id="history-title">Conversation history</h2>
for (const step of plan.steps || []) { const item = document.createElement('li'); item.textContent = `${step.title} — ${step.description} (Acceptance: ${(step.acceptance || []).join('; ')})`; list.appendChild(item); }
card.appendChild(list);
const actions = document.createElement('div'); actions.id = 'interaction-actions';
const accept = document.createElement('button'); accept.textContent = 'Accept'; accept.onclick = () => submitControl({plan_action: {plan_id: plan.plan_id, version: plan.version, action: 'accept'}}, 'Accepted plan');
const accept = document.createElement('button'); accept.textContent = 'Accept'; accept.onclick = () => submitControl({plan_action: {plan_id: plan.plan_id, version: plan.version, fingerprint: plan.fingerprint, action: 'accept'}}, 'Accepted plan');
const revise = document.createElement('button'); revise.textContent = 'Revise'; revise.onclick = () => { const input = document.getElementById('user-input'); input.placeholder = 'Describe the plan revision…'; input.focus(); };
const cancel = document.createElement('button'); cancel.textContent = 'Cancel'; cancel.onclick = () => submitControl({plan_action: {plan_id: plan.plan_id, version: plan.version, action: 'cancel'}}, 'Cancelled plan');
actions.append(accept, revise, cancel); card.appendChild(actions);
Expand Down
5 changes: 3 additions & 2 deletions tests/test_delegation.py
Original file line number Diff line number Diff line change
Expand Up @@ -892,7 +892,7 @@ def test_native_sdk_request_formats_routing_costs_and_failures(self):
fail = threading.Event()
def handle(request):
body = json.loads(request.content)
requests.append((request.url, dict(request.headers), body))
requests.append((request.url, httpx.Headers(request.headers), body))
is_claude = request.url.path == "/v1/messages"
if fail.is_set():
response = claude_http.Response if is_claude else httpx.Response
Expand Down Expand Up @@ -945,7 +945,8 @@ def handle(request):
for url, headers, body in requests:
if url.path == "/v1/messages":
self.assertEqual(url.host, "api.anthropic.com")
self.assertEqual(headers["x-api-key"], "fixture-claude")
self.assertIn(headers.get("x-api-key") or headers.get("authorization"),
{"fixture-claude", "Bearer fixture-claude"})
self.assertIn("system", body)
self.assertEqual(body["max_tokens"], 4096)
self.assertTrue(all(message["role"] != "system" for message in body["messages"]))
Expand Down
27 changes: 27 additions & 0 deletions tests/test_interaction.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import hashlib
import tempfile
import unittest
from openkyrozen.tools.models import ToolResult
Expand Down Expand Up @@ -1391,6 +1392,32 @@ def test_queued_plan_acceptance_rejects_a_newer_proposal(self):
self.assertEqual(session.interaction.state()["pending_plan"]["version"], newer["version"])
self.assertEqual(session.interaction.state()["executing_plan"]["version"], first["version"])

def test_queued_plan_acceptance_rejects_same_version_proposal_with_new_fingerprint(self):
session = self.agent.open_session("queued-plan-fingerprint")
first = session.interaction.propose_plan({
"title": "First proposal", "summary": "Create one file.",
"steps": [{"id": "file", "title": "Create file", "description": "Write it.",
"acceptance": ["File exists"]}],
})
replacement = {**first, "title": "Concurrent proposal", "summary": "Create a different file."}
fields = {key: replacement[key] for key in (
"plan_id", "version", "title", "summary", "assumptions", "steps", "bounds",
)}
replacement["fingerprint"] = hashlib.sha256(json.dumps(
fields, ensure_ascii=False, allow_nan=False, sort_keys=True, separators=(",", ":"),
).encode("utf-8")).hexdigest()
session.interaction._append("interaction.plan_proposed", replacement)

with patch.object(self.agent._agent_engine, "run", return_value="accepted"):
reply = self.agent.chat(session, "accept plan", plan_acceptance={
"plan_id": first["plan_id"], "version": first["version"],
"fingerprint": first["fingerprint"],
})

self.assertIn("changed", reply.lower())
self.assertIsNone(session.interaction.state()["executing_plan"])
self.assertEqual(session.interaction.state()["pending_plan"]["fingerprint"], replacement["fingerprint"])

def test_restore_history_refreshes_active_session_transcript(self):
from openkyrozen.agent.conversation import ConversationHistory
from types import SimpleNamespace
Expand Down
Loading
Loading