mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-10 10:12:20 +02:00
* fix(agent): allow remaining actions for an approved task * fix(agent): make approval continuation control-only * fix(ci): preserve approval taint and cache-buster contract * fix(ui): keep tool approvals in current chat * fix(ui): route tool approvals through chat submit * test(ui): pin approval submit routing * fix(agent): complete approval denial flow * fix(ui): avoid duplicate ask-user close icon * fix(agent): retain approved tool in continuation set * revert(ui): keep PR 6113 scoped to approval continuation * fix(agent): add task and chat approval scopes * fix(ui): prevent duplicate ask-user close icon * feat(ui): add ask-user option shortcuts * fix(compare): route ask-user choices per pane * fix(agent): keep skill-test approvals to a single action The chat card now reuses the wire value `approve` to mean chat-session scope, and `consume()` returned `allow_remaining_actions=True` for it unconditionally. The skill-test approval route was never updated: it still sends `approve` meaning "once", and its button still reads "Allow once", but the grant it got back set `approval_gate_bypassed` for the rest of the resumed run. That surface wraps the skill body and every transcript byte as untrusted context, so it is the last place where one click should ungate everything that follows. Give `consume()` an explicit `allow_continuation` flag. Callers that own a resumable chat keep the scope the user picked; callers that do not — the skill tester, unattended audits — get SINGLE_ACTION and the gate re-arms behind the sealed action, which is what their label promises. * fix(ui): cache-bust every module the approval click depends on chatStream.js, compare/index.js and compare/stream.js all changed behaviour but kept their old `?v=`, while chat.js and chatRenderer.js were bumped. A returning browser therefore serves the new chat.js — which now deliberately leaves the composer empty and clicks the send button — next to the cached chatStream.js that has no interceptor. With an empty composer that button sits at `data-mode="newchat"`, so the click opens a new chat and the approval is dropped. Bump the three, and version compare/stream.js's chatRenderer import to match everyone else's so the ask_user keydown listener binds to one module instance instead of two. * fix(ui): keep the digit shortcuts off tool approval cards With an approval card on screen and focus anywhere outside an input, a bare `1` fired `approve_task` — the widest of the three grants — with no modifier and no confirmation. That card is the one control whose entire purpose is deliberate consent after untrusted context influenced the run, and Deny sits at 3. Label the card with its kind and skip the shortcut for approvals. Ordinary ask_user questions keep 1-3. * fix(compare): restore a pane's ask_user card instead of dropping the choice renderAskUserCard removes the card as soon as onSubmit accepts, but the resume loop gave up silently after 10s if the originating stream still owned the pane. The user saw the click land, the card vanish, and nothing happen, with no way to get it back. Re-render the card on that deadline and say why. The reroll case still returns without sending — that choice belongs to a stream that no longer exists. * refactor(chat): drop the unreachable deny branch `if decision != "deny"` is always true — the deny path returns a StreamingResponse a few lines above. It reads as if deny still falls through to the toggle restore. --------- Co-authored-by: Léo <leograndcontact@gmail.com>
182 lines
6.0 KiB
Python
182 lines
6.0 KiB
Python
# core/models.py
|
|
"""
|
|
Pure data models — no database logic, no side effects.
|
|
|
|
These are simple datacontainers. All persistence is handled by SessionManager.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Dict, List, Any, Optional, TYPE_CHECKING
|
|
|
|
from src.tool_approval_scopes import (
|
|
CHAT_SESSION_APPROVAL_CONTEXT_MARKER,
|
|
CHAT_SESSION_APPROVAL_DECISION,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from .session_manager import SessionManager
|
|
|
|
# Module-level session manager singleton (single source of truth)
|
|
_SESSION_MANAGER_INSTANCE: Optional["SessionManager"] = None
|
|
|
|
|
|
def set_session_manager_instance(manager: "SessionManager"):
|
|
"""Set the global SessionManager singleton."""
|
|
global _SESSION_MANAGER_INSTANCE
|
|
_SESSION_MANAGER_INSTANCE = manager
|
|
|
|
|
|
def get_session_manager_instance() -> Optional["SessionManager"]:
|
|
"""Get the global SessionManager singleton."""
|
|
return _SESSION_MANAGER_INSTANCE
|
|
|
|
|
|
# Keep legacy name for backward compatibility
|
|
set_session_manager = set_session_manager_instance
|
|
get_session_manager = get_session_manager_instance
|
|
|
|
|
|
def _history_grants_chat_session_approval(
|
|
history: List["ChatMessage"],
|
|
session_id: str,
|
|
) -> bool:
|
|
"""Return whether this exact chat has a resolved session-scope grant."""
|
|
|
|
expected_session = str(session_id or "")
|
|
if not expected_session:
|
|
return False
|
|
for message in reversed(history or []):
|
|
metadata = getattr(message, "metadata", None)
|
|
if not isinstance(metadata, dict):
|
|
continue
|
|
tool_events = metadata.get("tool_events")
|
|
if not isinstance(tool_events, list):
|
|
continue
|
|
for event in reversed(tool_events):
|
|
ask_user = event.get("ask_user") if isinstance(event, dict) else None
|
|
if not isinstance(ask_user, dict):
|
|
continue
|
|
if (
|
|
ask_user.get("kind") == "tool_approval"
|
|
and ask_user.get("resolved") == CHAT_SESSION_APPROVAL_DECISION
|
|
and str(ask_user.get("session_id") or "") == expected_session
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
@dataclass
|
|
class ChatMessage:
|
|
"""A single chat message."""
|
|
role: str
|
|
content: str
|
|
metadata: Optional[Dict[str, Any]] = None
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""Convert to dict for API responses."""
|
|
result = {"role": self.role, "content": self.content}
|
|
if self.metadata:
|
|
result["metadata"] = self.metadata
|
|
return result
|
|
|
|
def get(self, key: str, default=None):
|
|
"""Dict-like access for compatibility."""
|
|
return getattr(self, key, default)
|
|
|
|
|
|
@dataclass
|
|
class Session:
|
|
"""A chat session — pure data container.
|
|
|
|
``.history`` is the authoritative mutable message list. Callers may
|
|
read, append, pop, or reassign it directly — these changes take
|
|
effect immediately. ``_history`` remains a compatibility alias that
|
|
always resolves to the authoritative ``history`` list.
|
|
|
|
Each session gets its own unique history list at construction time
|
|
(the dataclass default is never shared between instances).
|
|
"""
|
|
|
|
id: str
|
|
name: str
|
|
endpoint_url: str
|
|
model: str
|
|
rag: bool = False
|
|
archived: bool = False
|
|
headers: Optional[Dict[str, str]] = None
|
|
history: List[ChatMessage] = None
|
|
owner: Optional[str] = None
|
|
is_important: bool = False
|
|
message_count: int = 0
|
|
|
|
def __post_init__(self):
|
|
if self.headers is None:
|
|
self.headers = {}
|
|
# Ensure each session gets its OWN list (not the shared dataclass default)
|
|
if self.history is None:
|
|
self.history = []
|
|
|
|
@property
|
|
def _history(self) -> List[ChatMessage]:
|
|
"""Compatibility alias for callers that still reference ``_history``."""
|
|
return self.history
|
|
|
|
@_history.setter
|
|
def _history(self, messages: List[ChatMessage]):
|
|
self.history = messages
|
|
|
|
def add_message(self, message: ChatMessage):
|
|
"""
|
|
Add a message to this session.
|
|
|
|
Appends to the authoritative history list and increments
|
|
message_count. Delegates to SessionManager for persistence
|
|
if available.
|
|
"""
|
|
self.history.append(message)
|
|
self.message_count = len(self.history)
|
|
|
|
# Delegate to session manager for persistence
|
|
if _SESSION_MANAGER_INSTANCE:
|
|
_SESSION_MANAGER_INSTANCE._persist_message(self.id, message)
|
|
|
|
def get_context_messages(self) -> List[Dict[str, Any]]:
|
|
"""Get messages in format for LLM API.
|
|
|
|
Slash-command / setup replies are persisted to history so they render
|
|
in the transcript, but they are UI chatter (e.g. ``/setup ...`` and its
|
|
status lines) the user never meant as conversation. They carry
|
|
``metadata.source == "slash"``; exclude them here so they never reach
|
|
the model. Display/history-load paths use the raw ``history`` and are
|
|
unaffected.
|
|
"""
|
|
messages = [
|
|
msg.to_dict()
|
|
for msg in self.history
|
|
if (msg.metadata or {}).get("source") != "slash"
|
|
]
|
|
if not _history_grants_chat_session_approval(self.history, self.id):
|
|
return messages
|
|
|
|
# Keep the grant close to the latest user request so route-neutral
|
|
# compaction/trimming preserves it. Copy the metadata instead of
|
|
# mutating the durable transcript object.
|
|
for index in range(len(messages) - 1, -1, -1):
|
|
if messages[index].get("role") != "user":
|
|
continue
|
|
message = dict(messages[index])
|
|
metadata = dict(message.get("metadata") or {})
|
|
metadata[CHAT_SESSION_APPROVAL_CONTEXT_MARKER] = True
|
|
message["metadata"] = metadata
|
|
messages[index] = message
|
|
break
|
|
return messages
|
|
|
|
def get(self, key: str, default=None):
|
|
"""Dict-like access for compatibility."""
|
|
return getattr(self, key, default)
|
|
|
|
def __getitem__(self, key: str):
|
|
"""Allow session['field'] syntax."""
|
|
return getattr(self, key)
|