feat: plan agent v2, global chat mode, and user-mode v2 flag sync

- Add runtime/plan_agent_v2 package and shims; gateway/direct_loop/WS wiring

- Admin chat: interaction mode and specialist only in user menu; session API stores memory_mode and execution_mode only

- POST /admin/api/chat/user-mode mirrors plan_agent_version to AIA_EXPERT_PLAN_AGENT_V2_ENABLED (v2 to 1, v1 to 0)

- Composer cleanup (hidden mode select, no reasoning toggle in meta bar); tests and _local/system.env.example

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-02 18:41:45 +08:00
parent ae44cbcad5
commit 37522f492a
49 changed files with 4146 additions and 238 deletions

View file

@ -33,6 +33,7 @@ from oclaw.platform.files.file_attachments import (
from oclaw.platform.files.session_export import export_session_json, export_session_markdown
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.runtime.gateway import OclawGateway
from oclaw.runtime.plan_agent_v2.switch import v2_feature_enabled
from oclaw.runtime.types import StandardMessage, normalize_interaction_mode, normalize_requested_specialist
from oclaw.runtime.chat.history_tool_result_compact import compact_tool_results_in_session_history
@ -65,7 +66,7 @@ def _wiki_root_from_config() -> Path | None:
root = (Path(__file__).resolve().parents[2] / root).resolve()
return root
_CHAT_MSG_LIMIT = 256
_CHAT_MSG_LIMIT = 5000
_SESSION_TITLE_MAX_LEN = 120
_AVATAR_UPLOAD_MAX_BYTES = 2 * 1024 * 1024
_AVATAR_MIMES = frozenset({"image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"})
@ -698,66 +699,149 @@ def _chat_user_mode_setting_key(*, tenant_id: str, user_id: str, field: str) ->
return f"chat.user.mode.{tenant_id}.{user_id}.{field}"
def _normalize_execution_mode(payload: dict[str, Any] | None) -> str:
body = payload or {}
raw = str(body.get("execution_mode") or "").strip().lower()
return raw if raw in {"agent", "plan"} else "agent"
def _normalize_confirm_strategy(payload: dict[str, Any] | None) -> str:
body = payload or {}
raw = str(body.get("confirm_strategy") or "").strip().lower()
return raw if raw in {"auto", "strict", "off"} else "strict"
def _normalize_plan_agent_version(payload: dict[str, Any] | None) -> str:
body = payload or {}
raw = str(body.get("plan_agent_version") or "").strip().lower()
return raw if raw in {"v1", "v2"} else "v1"
def _resolve_user_menu_chat_settings(
*,
store: SqliteStore,
tenant_id: str,
user_id: str,
) -> tuple[str, str, str, str]:
"""User-wide settings (⋯ menu): mode + confirm + plan/agent version — all sessions share these keys."""
user_mode_key = _chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="interaction_mode")
user_specialist_key = _chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="specialist")
user_confirm_strategy_key = _chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="confirm_strategy")
user_plan_agent_version_key = _chat_user_mode_setting_key(
tenant_id=tenant_id, user_id=user_id, field="plan_agent_version"
)
mode_raw = str(store.get_setting(user_mode_key) or "").strip()
specialist_raw = str(store.get_setting(user_specialist_key) or "").strip()
confirm_raw = str(store.get_setting(user_confirm_strategy_key) or "").strip()
plan_agent_raw = str(store.get_setting(user_plan_agent_version_key) or "").strip()
interaction_mode = normalize_interaction_mode(mode_raw or "expert")
specialist = normalize_requested_specialist(specialist_raw or "generalist")
specialist = _apply_specialist_flags(store, specialist)
confirm_strategy = _normalize_confirm_strategy({"confirm_strategy": (confirm_raw or "strict")})
plan_agent_version = _normalize_plan_agent_version({"plan_agent_version": (plan_agent_raw or "v1")})
return interaction_mode, specialist, confirm_strategy, plan_agent_version
def _persist_user_menu_chat_settings(
*,
store: SqliteStore,
tenant_id: str,
user_id: str,
interaction_mode: str,
specialist: str,
confirm_strategy: str,
plan_agent_version: str,
) -> None:
store.set_setting(
_chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="interaction_mode"),
interaction_mode,
)
store.set_setting(
_chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="specialist"),
specialist,
)
store.set_setting(
_chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="confirm_strategy"),
confirm_strategy,
)
store.set_setting(
_chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="plan_agent_version"),
plan_agent_version,
)
store.set_setting("AIA_EXPERT_PLAN_CONFIRM_STRATEGY", confirm_strategy)
def _resolve_session_dialog_chat_settings(
*,
store: SqliteStore,
tenant_id: str,
user_id: str,
session_id: str,
) -> tuple[str, str]:
"""Per-session dialog only: memory_mode + execution_mode (session keys). Mode/specialist are user-global."""
session_memory_mode_key = _chat_session_mode_setting_key(
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="memory_mode"
)
session_execution_mode_key = _chat_session_mode_setting_key(
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="execution_mode"
)
memory_raw = str(store.get_setting(session_memory_mode_key) or "").strip()
execution_raw = str(store.get_setting(session_execution_mode_key) or "").strip()
memory_mode = _normalize_memory_mode({"memory_mode": (memory_raw or "default")})
execution_mode = _normalize_execution_mode({"execution_mode": (execution_raw or "agent")})
return memory_mode, execution_mode
def _persist_session_dialog_chat_settings(
*,
store: SqliteStore,
tenant_id: str,
user_id: str,
session_id: str,
memory_mode: str,
execution_mode: str,
) -> None:
store.set_setting(
_chat_session_mode_setting_key(tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="memory_mode"),
memory_mode,
)
store.set_setting(
_chat_session_mode_setting_key(tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="execution_mode"),
execution_mode,
)
def _resolve_mode_settings(
*,
store: SqliteStore,
tenant_id: str,
user_id: str,
session_id: str,
) -> tuple[str, str, str]:
"""Resolve chat mode with global-user preference first, then session fallback."""
user_mode_key = _chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="interaction_mode")
user_specialist_key = _chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="specialist")
user_memory_mode_key = _chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="memory_mode")
session_mode_key = _chat_session_mode_setting_key(
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="interaction_mode"
) -> tuple[str, str, str, str, str, str]:
"""User-wide mode/specialist + session memory/exec + user confirm/plan_agent for gateway + REST send."""
u_im, u_sp, u_cs, u_pav = _resolve_user_menu_chat_settings(store=store, tenant_id=tenant_id, user_id=user_id)
s_mm, s_em = _resolve_session_dialog_chat_settings(
store=store, tenant_id=tenant_id, user_id=user_id, session_id=str(session_id)
)
session_specialist_key = _chat_session_mode_setting_key(
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="specialist"
)
session_memory_mode_key = _chat_session_mode_setting_key(
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="memory_mode"
)
mode_raw = str(store.get_setting(user_mode_key) or "").strip() or str(store.get_setting(session_mode_key) or "").strip()
specialist_raw = str(store.get_setting(user_specialist_key) or "").strip() or str(
store.get_setting(session_specialist_key) or ""
).strip()
memory_raw = str(store.get_setting(user_memory_mode_key) or "").strip() or str(store.get_setting(session_memory_mode_key) or "").strip()
interaction_mode = normalize_interaction_mode(mode_raw or "expert")
specialist = normalize_requested_specialist(specialist_raw or "generalist")
specialist = _apply_specialist_flags(store, specialist)
memory_mode = _normalize_memory_mode({"memory_mode": (memory_raw or "default")})
return interaction_mode, specialist, memory_mode
return u_im, u_sp, s_mm, s_em, u_cs, u_pav
def _persist_mode_settings(
def _seed_new_session_dialog_from_user_defaults(
*,
store: SqliteStore,
tenant_id: str,
user_id: str,
session_id: str,
interaction_mode: str,
specialist: str,
memory_mode: str,
) -> None:
"""Persist as global user default and current-session compatibility snapshot."""
for key in (
_chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="interaction_mode"),
_chat_session_mode_setting_key(tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="interaction_mode"),
):
store.set_setting(key, interaction_mode)
for key in (
_chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="specialist"),
_chat_session_mode_setting_key(tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="specialist"),
):
store.set_setting(key, specialist)
for key in (
_chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="memory_mode"),
_chat_session_mode_setting_key(tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="memory_mode"),
):
store.set_setting(key, memory_mode)
"""New session: default memory + execution only (mode/specialist come from user menu at resolve time)."""
_persist_session_dialog_chat_settings(
store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=str(session_id),
memory_mode=_normalize_memory_mode({"memory_mode": "default"}),
execution_mode=_normalize_execution_mode({"execution_mode": "agent"}),
)
def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStore, str | None], dict[str, Any]]) -> None:
@ -805,20 +889,8 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
user_id = str(ctx.get("user_id") or "")
title = str(payload.get("title") or "").strip() or ("新会话" if _api_lang(store) == "zh" else "New Chat")
s = store.create_session_for_user(title=title, tenant_id=tenant_id, user_id=user_id)
interaction_mode, specialist, memory_mode = _resolve_mode_settings(
store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=str(s.id),
)
_persist_mode_settings(
store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=str(s.id),
interaction_mode=interaction_mode,
specialist=specialist,
memory_mode=memory_mode,
_seed_new_session_dialog_from_user_defaults(
store=store, tenant_id=tenant_id, user_id=user_id, session_id=str(s.id)
)
return {
"ok": True,
@ -885,20 +957,8 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
title=("新会话" if lang == "zh" else "New Chat"), tenant_id=tenant_id, user_id=user_id
)
next_id = str(ns.id)
interaction_mode, specialist, memory_mode = _resolve_mode_settings(
store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=next_id,
)
_persist_mode_settings(
store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=next_id,
interaction_mode=interaction_mode,
specialist=specialist,
memory_mode=memory_mode,
_seed_new_session_dialog_from_user_defaults(
store=store, tenant_id=tenant_id, user_id=user_id, session_id=next_id
)
return {"ok": True, "next_session_id": next_id}
@ -971,6 +1031,7 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
@chat.get("/sessions/{session_id}/messages")
def api_chat_messages(
session_id: str,
limit: int = Query(default=_CHAT_MSG_LIMIT, ge=1, le=20000),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
@ -981,7 +1042,7 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
if not sess:
raise HTTPException(status_code=404, detail="session_not_found")
meta = store.get_session_messages_meta(session_id)
msgs = store.get_messages(session_id=session_id, limit=_CHAT_MSG_LIMIT)
msgs = store.get_messages(session_id=session_id, limit=int(limit))
msgs = _filter_internal_instruction_user_messages(msgs)
return {
"ok": True,
@ -1151,13 +1212,26 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
sess = _resolve_chat_session(store, ctx, session_id)
if not sess:
raise HTTPException(status_code=404, detail="session_not_found")
interaction_mode, specialist, memory_mode = _resolve_mode_settings(
store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=str(session_id),
s_mm, s_em = _resolve_session_dialog_chat_settings(
store=store, tenant_id=tenant_id, user_id=user_id, session_id=str(session_id)
)
return {"ok": True, "interaction_mode": interaction_mode, "specialist": specialist, "memory_mode": memory_mode}
u_im, u_sp, u_cs, u_pav = _resolve_user_menu_chat_settings(store=store, tenant_id=tenant_id, user_id=user_id)
return {
"ok": True,
"interaction_mode": u_im,
"specialist": u_sp,
"memory_mode": s_mm,
"execution_mode": s_em,
"confirm_strategy": u_cs,
"plan_agent_version": u_pav,
"plan_agent_v2_globally_enabled": bool(v2_feature_enabled(store=store)),
"global_menu": {
"interaction_mode": u_im,
"specialist": u_sp,
"confirm_strategy": u_cs,
"plan_agent_version": u_pav,
},
}
@chat.post("/sessions/{session_id}/mode")
def api_chat_session_mode_set(
@ -1173,20 +1247,90 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
sess = _resolve_chat_session(store, ctx, session_id)
if not sess:
raise HTTPException(status_code=404, detail="session_not_found")
interaction_mode = normalize_interaction_mode(payload.get("interaction_mode"))
specialist = normalize_requested_specialist(payload.get("specialist"))
specialist = _apply_specialist_flags(store, specialist)
memory_mode = _normalize_memory_mode(payload)
_persist_mode_settings(
execution_mode = _normalize_execution_mode(payload)
_persist_session_dialog_chat_settings(
store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=str(session_id),
memory_mode=memory_mode,
execution_mode=execution_mode,
)
s_mm, s_em = _resolve_session_dialog_chat_settings(
store=store, tenant_id=tenant_id, user_id=user_id, session_id=str(session_id)
)
u_im, u_sp, u_cs, u_pav = _resolve_user_menu_chat_settings(store=store, tenant_id=tenant_id, user_id=user_id)
return {
"ok": True,
"interaction_mode": u_im,
"specialist": u_sp,
"memory_mode": s_mm,
"execution_mode": s_em,
"confirm_strategy": u_cs,
"plan_agent_version": u_pav,
"plan_agent_v2_globally_enabled": bool(v2_feature_enabled(store=store)),
"global_menu": {
"interaction_mode": u_im,
"specialist": u_sp,
"confirm_strategy": u_cs,
"plan_agent_version": u_pav,
},
}
@chat.get("/user-mode")
def api_chat_user_mode_get(
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
tenant_id = str(ctx.get("tenant_id") or "")
user_id = str(ctx.get("user_id") or "")
u_im, u_sp, u_cs, u_pav = _resolve_user_menu_chat_settings(store=store, tenant_id=tenant_id, user_id=user_id)
return {
"ok": True,
"interaction_mode": u_im,
"specialist": u_sp,
"confirm_strategy": u_cs,
"plan_agent_version": u_pav,
"plan_agent_v2_globally_enabled": bool(v2_feature_enabled(store=store)),
}
@chat.post("/user-mode")
def api_chat_user_mode_set(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
tenant_id = str(ctx.get("tenant_id") or "")
user_id = str(ctx.get("user_id") or "")
interaction_mode = normalize_interaction_mode(payload.get("interaction_mode"))
specialist = normalize_requested_specialist(payload.get("specialist"))
specialist = _apply_specialist_flags(store, specialist)
confirm_strategy = _normalize_confirm_strategy(payload)
plan_agent_version = _normalize_plan_agent_version(payload)
_persist_user_menu_chat_settings(
store=store,
tenant_id=tenant_id,
user_id=user_id,
interaction_mode=interaction_mode,
specialist=specialist,
memory_mode=memory_mode,
confirm_strategy=confirm_strategy,
plan_agent_version=plan_agent_version,
)
return {"ok": True, "interaction_mode": interaction_mode, "specialist": specialist, "memory_mode": memory_mode}
u_im, u_sp, u_cs, u_pav = _resolve_user_menu_chat_settings(store=store, tenant_id=tenant_id, user_id=user_id)
# Mirror Plan/Agent version to the gateway feature gate (⋯ menu is the control surface).
store.set_setting("AIA_EXPERT_PLAN_AGENT_V2_ENABLED", "1" if str(u_pav or "").strip().lower() == "v2" else "0")
return {
"ok": True,
"interaction_mode": u_im,
"specialist": u_sp,
"confirm_strategy": u_cs,
"plan_agent_version": u_pav,
"plan_agent_v2_globally_enabled": bool(v2_feature_enabled(store=store)),
}
@chat.get("/admin/user-stats")
def api_chat_admin_user_stats(
@ -1751,27 +1895,44 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
attachments = _parse_attachments_payload(payload.get("attachments"))
interaction_mode, selected_specialist = _normalize_chat_mode(payload)
memory_mode = _normalize_memory_mode(payload)
execution_mode = _normalize_execution_mode(payload)
if "interaction_mode" not in payload and "chat_mode" not in payload:
interaction_mode, _, _ = _resolve_mode_settings(
interaction_mode, _, _, _, _, _ = _resolve_mode_settings(
store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=str(session_id),
)
if "specialist" not in payload:
_, selected_specialist, _ = _resolve_mode_settings(
_, selected_specialist, _, _, _, _ = _resolve_mode_settings(
store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=str(session_id),
)
if "memory_mode" not in payload:
_, _, memory_mode = _resolve_mode_settings(
_, _, memory_mode, _, _, _ = _resolve_mode_settings(
store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=str(session_id),
)
if "execution_mode" not in payload:
_, _, _, execution_mode, _, _ = _resolve_mode_settings(
store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=str(session_id),
)
if "plan_agent_version" not in payload:
_, _, _, _, _, plan_agent_version = _resolve_mode_settings(
store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=str(session_id),
)
else:
plan_agent_version = _normalize_plan_agent_version(payload)
selected_specialist = _apply_specialist_flags(store, selected_specialist)
if not text_raw and not attachments:
raise HTTPException(status_code=400, detail="text_or_attachments_required")
@ -1826,6 +1987,8 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
"interaction_mode": interaction_mode,
"selected_specialist": selected_specialist,
"memory_mode": memory_mode,
"execution_mode": execution_mode,
"plan_agent_version": plan_agent_version,
},
)
gw_result = gw.handle_turn(
@ -1869,27 +2032,44 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
attachments = _parse_attachments_payload(payload.get("attachments"))
interaction_mode, selected_specialist = _normalize_chat_mode(payload)
memory_mode = _normalize_memory_mode(payload)
execution_mode = _normalize_execution_mode(payload)
if "interaction_mode" not in payload and "chat_mode" not in payload:
interaction_mode, _, _ = _resolve_mode_settings(
interaction_mode, _, _, _, _, _ = _resolve_mode_settings(
store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=str(session_id),
)
if "specialist" not in payload:
_, selected_specialist, _ = _resolve_mode_settings(
_, selected_specialist, _, _, _, _ = _resolve_mode_settings(
store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=str(session_id),
)
if "memory_mode" not in payload:
_, _, memory_mode = _resolve_mode_settings(
_, _, memory_mode, _, _, _ = _resolve_mode_settings(
store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=str(session_id),
)
if "execution_mode" not in payload:
_, _, _, execution_mode, _, _ = _resolve_mode_settings(
store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=str(session_id),
)
if "plan_agent_version" not in payload:
_, _, _, _, _, plan_agent_version = _resolve_mode_settings(
store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=str(session_id),
)
else:
plan_agent_version = _normalize_plan_agent_version(payload)
selected_specialist = _apply_specialist_flags(store, selected_specialist)
if not text_raw and not attachments:
raise HTTPException(status_code=400, detail="text_or_attachments_required")
@ -1933,6 +2113,8 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
"interaction_mode": interaction_mode,
"selected_specialist": selected_specialist,
"memory_mode": memory_mode,
"execution_mode": execution_mode,
"plan_agent_version": plan_agent_version,
}
_DONE = object()