diff --git a/.gitignore b/.gitignore index 1a718ed2..ccafbccf 100644 --- a/.gitignore +++ b/.gitignore @@ -33,10 +33,8 @@ data/mcp_local.env data/google_oauth_client.json data/_pre_merge_sqlite_*/ # oclaw 子仓库运行态目录(勿提交) -_local/ -_local/*.env -_local/*.json -_local/*.txt +_local/* +!_local/system.env.example data/channel_sidecar/ data/wiki/ data/**/node_modules/ @@ -48,11 +46,7 @@ runtime/operations/scripts/.run/ desktop/node_modules/ desktop/dist/ desktop/runtime-data/ -# 本地密钥(随仓库目录迁移时记得复制此文件;勿提交) -_local/google_oauth_client.json -_local/mcp_local.env -# MCP admin export / 安装后自动备份(可重装 JSON,本机 path 与列表可能不同) -_local/mcp_registry_migrated.json +# _local/ 下其余文件均忽略(密钥、mcp_local.env 等) platform/data/*.sqlite platform/data/*.sqlite-journal platform/data/*.sqlite-shm diff --git a/_local/system.env.example b/_local/system.env.example new file mode 100644 index 00000000..aa12991f --- /dev/null +++ b/_local/system.env.example @@ -0,0 +1,353 @@ +# ============================================================================= +# 【仓库约定 — 必读】 +# +# 1) 新增「进程环境变量」(代码里新出现 os.getenv / 读取 os.environ)时: +# - 必须在本文件登记:变量名、含义、典型取值、与前端/管理后台是否有关联。 +# - 提交代码评审时一并更新本模板(复制为 _local/system.env 后本地填写真实值;system.env 勿提交)。 +# +# 2) 加载时机:网关入口 modules/http/fastapi_app.py 会调用 load_system_env(), +# 仅加载「_local/system.env」单一路径(platform/config/bootstrap_env.py)。 +# 已在操作系统 / 启动脚本里 export 的变量优先级更高(不会被文件覆盖)。 +# +# 3) 与 SQLite app_setting / 管理后台的关系: +# - 多数运行参数可在「管理后台」写入数据库;具体优先级以代码为准(常见:后台优先, +# 仅部分开关保留「环境变量优先」)。 +# - 本节标注「仅存数据库」的项不要指望写在本 env 文件生效(除非代码明确读取 getenv)。 +# +# 4) 布尔值约定:未特殊说明时,1/true/yes/on 表示开启;0/false/no/off 表示关闭。 +# +# 用法:复制本文件为同目录 system.env,按需取消注释并填写。 +# copy _local\system.env.example _local\system.env +# ============================================================================= + +# ----------------------------------------------------------------------------- +# 一、HTTP 网关监听(uvicorn / FastAPI) +# ----------------------------------------------------------------------------- +# AIA_ASSISTANT_GATEWAY_HOST 监听地址,默认 0.0.0.0。 +# AIA_ASSISTANT_GATEWAY_PORT 监听端口,默认 8787。 +# 【前端】无专门开关;由部署/启动脚本决定。 +AIA_ASSISTANT_GATEWAY_HOST=0.0.0.0 +AIA_ASSISTANT_GATEWAY_PORT=8787 + +# AIA_PREWARM_INTERVAL_SECONDS 后台预热任务周期(秒),默认 600,合法范围代码内 clamp。 +# 【前端】无。 +AIA_PREWARM_INTERVAL_SECONDS=600 + +# ----------------------------------------------------------------------------- +# 二、数据目录、SQLite、密钥与语言(进程级) +# ----------------------------------------------------------------------------- +# AIA_ASSISTANT_DB_PATH / OPS_ASSISTANT_DB_PATH SQLite 库路径(二选一即可),相对路径相对仓库根。 +# 【前端】无直接开关;决定连接哪份数据库文件。 +AIA_ASSISTANT_DB_PATH= +OPS_ASSISTANT_DB_PATH= + +# AIA_ASSISTANT_MASTER_KEY 用于加密迁移密钥等;不设则部分迁移不可用。 +# 【前端】管理后台「密钥迁移」相关界面会检测是否配置(提示文案)。 +AIA_ASSISTANT_MASTER_KEY= + +# AIA_ASSISTANT_PASSWORD / OPS_ASSISTANT_PASSWORD 管理后台登录密码(旧名 OPS_* 兼容)。 +# 【前端】登录表单;环境变量常用于自动化部署注入初始密码。 +AIA_ASSISTANT_PASSWORD= +OPS_ASSISTANT_PASSWORD= + +# AIA_ASSISTANT_LANG 全局默认语言倾向(如 zh),具体会话可被覆盖。 +# 【前端】聊天语言一般由会话/UI 控制。 +AIA_ASSISTANT_LANG= + +# AIA_ASSISTANT_PREMERGE_BACKUP_KEEP / OPS_* 数据库预合并备份保留策略相关。 +# AIA_LEGACY_DB_FORCE_PREMERGE / OPS_* 是否强制走遗留库合并逻辑。 +AIA_ASSISTANT_PREMERGE_BACKUP_KEEP= +OPS_ASSISTANT_PREMERGE_BACKUP_KEEP= +AIA_LEGACY_DB_FORCE_PREMERGE= +OPS_LEGACY_DB_FORCE_PREMERGE= + +# ----------------------------------------------------------------------------- +# 三、主配置文件路径(oclaw.json 与别名) +# ----------------------------------------------------------------------------- +# AIA_OCLAW_CONFIG_PATH 主配置 JSON(agents、工作区等);多处 gateway/direct_loop 读取。 +# OCLAW_CONFIG_PATH 别名,hooks_runtime / user_config_hooks / gateway server_methods 等。 +# 【前端】无单一开关;配置内容决定模型与工作区。 +AIA_OCLAW_CONFIG_PATH= +OCLAW_CONFIG_PATH= + +# OCLAW_RUNTIME_CONFIG_JSON 内联 JSON 配置片段(调试/容器注入)。 +OCLAW_RUNTIME_CONFIG_JSON= + +# ----------------------------------------------------------------------------- +# 四、工作区与路径守护(工具可读目录) +# ----------------------------------------------------------------------------- +# OCLAW_WORKSPACE 逻辑工作区根路径;多处注入 prompt / hooks。 +# OCLAW_STATE_DIR Agent 状态目录,默认 .oclaw。 +# OCLAW_SHELL 子进程 shell 覆盖。 +# OCLAW_HOME boot-md / command-logger 等与 OCLAW_STATE_DIR 二选一的别名路径。 +OCLAW_WORKSPACE= +OCLAW_STATE_DIR=.oclaw +OCLAW_SHELL= +OCLAW_HOME= + +# AIA_WORKSPACE_ROOT / OPS_WORKSPACE_ROOT 路径守护主根覆盖。 +# AIA_WORKSPACE_EXTRA_ROOTS / OPS_* 额外允许访问的根,竖线或分隔符依代码。 +# 【前端】Admin「用户/会话」里「额外根路径」等与上述 env 合并(见 app.js 文案)。 +AIA_WORKSPACE_ROOT= +OPS_WORKSPACE_ROOT= +AIA_WORKSPACE_EXTRA_ROOTS= +OPS_WORKSPACE_EXTRA_ROOTS= + +# AIA_MCP_FILESYSTEM_EXTRA_ROOTS / OPS_* MCP 官方 filesystem 进程额外根路径。 +# 【前端】管理后台「网关/MCP」设置项可写库;与此 env 合并。 +AIA_MCP_FILESYSTEM_EXTRA_ROOTS= +OPS_MCP_FILESYSTEM_EXTRA_ROOTS= + +# ----------------------------------------------------------------------------- +# 五、Expert Plan / Agent v2(专家模式计划态) +# ----------------------------------------------------------------------------- +# AIA_EXPERT_PLAN_AGENT_V2_ENABLED 是否启用 expert 下的 plan_agent_v2 网关分支。 +# 取值 1/true/on:启用;留空或 0/false:否。 +# 优先级:SQLite app_setting 非空则优先于 env(见 runtime/plan_agent_v2/switch.py)。 +# 【前端】聊天 ⋯ 菜单里「Plan / Agent 版本」选 v2/v1 会通过 /admin/api/chat/user-mode 写入同一键(v2→1,v1→0)。 +# 首次在未写入库前仅 env 生效;一旦在菜单里保存过,以库为准。 +AIA_EXPERT_PLAN_AGENT_V2_ENABLED=0 + +# 【仅存数据库 · 写在此处无效】AIA_EXPERT_PLAN_CONFIRM_STRATEGY(strict/auto/off) +# 计划确认策略;由 Admin chat API 写入库;前端 chat.js「确认策略」菜单与会话同步。 +# 【仅存数据库 · 写在此处无效】AIA_EXPERT_PLAN_FILE_DIR 计划文件目录。 +# 【仅存数据库】会话级 execution_mode / confirm_strategy 走接口元数据与 chat_api,不靠 getenv。 + +# ----------------------------------------------------------------------------- +# 六、LLM 提供商密钥与默认模型(环境兜底;正式账号多在 DB llm_profile) +# ----------------------------------------------------------------------------- +OPENAI_API_KEY= +OPENAI_BASE_URL= +OPENAI_MODEL= +OPENAI_EMBEDDING_MODEL= + +ANTHROPIC_API_KEY= +ANTHROPIC_BASE_URL= +ANTHROPIC_MODEL= + +GOOGLE_API_KEY= +GEMINI_API_KEY= + +OLLAMA_BASE_URL= +OPENAI_BASE_URL_OLLAMA= +OLLAMA_MODEL= + +DASHSCOPE_API_KEY= +DASHSCOPE_BASE_HTTP_API_URL= + +# AIA_ASSISTANT_MODE 全局助手模式字符串(与 chat_models 等协调);测试里可用 OPS_ASSISTANT_MODE。 +AIA_ASSISTANT_MODE= + +# --- OpenAI 兼容「思考」字段 --- +AIA_LLM_THINKING_FORCE_DISABLED= +AIA_LLM_THINKING_FORCE_ENABLED= +AIA_LLM_THINKING_DISABLED= + +# --- Gemini SSE --- +AIA_GEMINI_THINKING= +AIA_GEMINI_THINKING_LEVEL= +AIA_GEMINI_THINKING_BUDGET= + +# ----------------------------------------------------------------------------- +# 七、直连循环(direct_loop):空回复、工具结果长度、trace +# ----------------------------------------------------------------------------- +AIA_IMAGE_TOOL_RESULT_REPLAY_CAP_CHARS= +AIA_VIDEO_TOOL_RESULT_REPLAY_CAP_CHARS= +AIA_TOOL_WIRE_FROZEN_ON_STARTUP= +AIA_TRACE_TOOL_EXPOSURE_PLAN= + +AIA_EMPTY_ASSISTANT_RETRY_MAX= +AIA_EMPTY_ASSISTANT_RETRY_DELAY_MS= +AIA_EMPTY_ASSISTANT_RETRY_TOTAL_TIMEOUT_MS= + +# 【管理后台】部分同名项可在运维设置里配置(如 AIA_SSE_QUEUE_MAXSIZE);chat_api 读 env 作兜底。 +AIA_SSE_QUEUE_MAXSIZE= + +# ----------------------------------------------------------------------------- +# 八、工具清单 / MCP / 插件 / 高风险工具可见性 +# ----------------------------------------------------------------------------- +AIA_SKILL_TOOLCALL_ENABLED= +AIA_PUBLIC_TOOLS_ALLOW_HIGH= +AIA_ENABLE_MCP_TOOLS=1 +OPS_ENABLE_MCP_TOOLS= +AIA_PLUGIN_TOOLS_ENABLED=1 +AIA_PLUGIN_TOOL_IDS= +AIA_ENABLE_RUN_COMMAND= +AIA_LOCAL_ADAPTER_STARTUP_SELF_CHECK=1 + +# AIA_MCP_SPECIALISTS 允许使用 MCP 的专家角色列表,逗号分隔。 +AIA_MCP_SPECIALISTS=generalist,manager + +# AIA_MCP_ENV_ALLOWLIST 允许注入 MCP 子进程的环境变量名列表。 +# 【前端】管理后台可维护同名设置。 +AIA_MCP_ENV_ALLOWLIST= + +# AIA_MCP_SQLITE_COMMAND 表格附件走 MCP 时的 sqlite 命令路径覆盖。 +AIA_TABULAR_USE_MCP= +AIA_TABULAR_SQL_TIMEOUT_MS= + +# ----------------------------------------------------------------------------- +# 九、工具运行时:上下文长度、日志、历史摘要 +# ----------------------------------------------------------------------------- +AIA_TOOL_LLM_MESSAGE_MAX_CHARS= +AIA_TOOL_HISTORY_SUMMARY_AFTER_CALLS= +AIA_TOOL_LOG_MAX_CHARS= + +# ----------------------------------------------------------------------------- +# 十、Skills 根目录与注入 +# ----------------------------------------------------------------------------- +AIA_SKILLS_ROOT= +AIA_DISABLE_LEGACY_SKILLS_FALLBACK= +AIA_SKILLS_PROMPT_IN_SYSTEM= +AIA_SKILLS_PROMPT_MAX_CHARS=18000 + +# AIA_SKILL_ROLE_BINDING_ENABLED / AIA_SKILL_ROLE_BINDING_MANAGER_INHERIT +# 技能与角色绑定;env 优先于 store(见 runtime/skill_role_binding.py)。 +# 【前端】高级设置里可操作同名开关(app.js 文案引用)。 +AIA_SKILL_ROLE_BINDING_ENABLED= +AIA_SKILL_ROLE_BINDING_MANAGER_INHERIT= + +# --- ClawHub --- +AIA_CLAWHUB_SITE= +AIA_CLAWHUB_REGISTRY= +AIA_CLAWHUB_TOKEN= +AIA_CLAWHUB_API_BASE= +CLAWHUB_SITE= +CLAWHUB_REGISTRY= +CLAWHUB_TOKEN= + +# --- Cocoloop --- +AIA_COCOLOOP_API_BASE= +COCOLOOP_API_BASE= + +# ----------------------------------------------------------------------------- +# 十一、附件 ACL、体积上限 +# ----------------------------------------------------------------------------- +AIA_ATTACHMENT_ACL_STRICT= +AIA_MAX_ATTACHMENT_BYTES= + +# ----------------------------------------------------------------------------- +# 十二、路由与运行时日志 +# ----------------------------------------------------------------------------- +AIA_OCLAW_ROUTER_MODE= +AIA_RUNTIME_LOG_DIR= + +# ----------------------------------------------------------------------------- +# 十三、RAG / 记忆 +# ----------------------------------------------------------------------------- +AIA_RAG_MODE= +AIA_RAG_EMBEDDING_MODE= +AIA_MEMORY_EPISODIC_TTL_DAYS= +MEMORY_EPISODIC_TTL_DAYS= + +# ----------------------------------------------------------------------------- +# 十四、WebSocket(/ws) +# ----------------------------------------------------------------------------- +OCLAW_WS_REQUIRE_AUTH=1 +OCLAW_WS_ALLOWED_ORIGINS= +OCLAW_WS_RATE_LIMIT_WINDOW_MS=60000 +OCLAW_WS_RATE_LIMIT_CONN_PER_WINDOW=120 +OCLAW_WS_RATE_LIMIT_IP_PER_WINDOW=240 +OCLAW_WS_RATE_LIMIT_USER_PER_WINDOW=360 +OCLAW_WS_SEND_QUEUE_MAX_MESSAGES=256 +OCLAW_WS_SEND_QUEUE_MAX_BYTES= +OCLAW_WS_EVENT_REPLAY_MAX=256 + +# ----------------------------------------------------------------------------- +# 十五、模型请求:工具 JSON、Replay 策略、Agent 消息回放 +# ----------------------------------------------------------------------------- +AIA_OPENAI_TOOLS_MAX_JSON_CHARS= +AIA_SHRINK_OPENAI_TOOLS= +AIA_SHRINK_OPENAI_TOOLS_MAX_JSON= + +AIA_REPLAY_POLICY_ENABLED= +AIA_REPLAY_REPAIR_TOOL_PAIRING= +AIA_TOOL_CALL_ID_MAX_LEN= + +AIA_REPLAY_TOOL_FULL_ROUNDS= +AIA_REPLAY_REASONING_SIGNATURE_POLICY= + +# ----------------------------------------------------------------------------- +# 十六、图片 / 音视频工具(OpenAI、DashScope) +# ----------------------------------------------------------------------------- +OPENAI_IMAGE_MODEL= +OPENAI_AUDIO_TRANSCRIPTION_MODEL= + +AIA_IMAGE_MODEL= +AIA_IMAGE_BASE_URL= +AIA_IMAGE_API_KEY= +AIA_IMAGE_CHAT_ENDPOINT= + +DASHSCOPE_IMAGE_N= +DASHSCOPE_IMAGE_WATERMARK= +DASHSCOPE_IMAGE_NEGATIVE_PROMPT= +DASHSCOPE_IMAGE_PROMPT_EXTEND= +DASHSCOPE_IMAGE_SIZE= +DASHSCOPE_IMAGE_MODEL= + +AIA_IMAGE_RETRIES= +AIA_IMAGE_RETRY_BACKOFF_SEC= +AIA_IMAGE_STATUS_RETRIES= +AIA_IMAGE_STATUS_RETRY_BACKOFF_SEC= + +CLOUDFLARE_ACCOUNT_ID= +CLOUDFLARE_API_TOKEN= + +# ----------------------------------------------------------------------------- +# 十七、Web 搜索(official Bing/Google HTTP API) +# ----------------------------------------------------------------------------- +OCLAW_WEB_SEARCH_OFFICIAL_API_KEY= +OCLAW_WEB_SEARCH_OFFICIAL_API_ENDPOINT= +OCLAW_WEB_SEARCH_BING_API_KEY= +OCLAW_WEB_SEARCH_BING_API_ENDPOINT= +OCLAW_WEB_SEARCH_GOOGLE_API_KEY= +OCLAW_WEB_SEARCH_GOOGLE_CSE_ID= +OCLAW_WEB_SEARCH_GOOGLE_API_ENDPOINT= + +# ----------------------------------------------------------------------------- +# 十八、微信 / 企业微信 长连接 Runner(interfaces/channels/wecom) +# ----------------------------------------------------------------------------- +WECOM_LONGCONN_MODE= +WECOM_LONGCONN_INTERVAL_SEC= +WECOM_LONGCONN_WS_URL= +WECOM_LONGCONN_PULL_URL= +WECOM_LONGCONN_SEND_RETRY= +WECOM_LONGCONN_MOCK_TEXT= +WECOM_LONGCONN_DELIVER_OUTBOUND= +WECOM_LONGCONN_USE_RESPONSE_URL= +AIA_WECOM_LONGCONN_WORKERS= +WECOM_LONGCONN_WORKERS= +AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE= +WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE= + +# ----------------------------------------------------------------------------- +# 十九、Wiki Worker、杂项、对外版本号 +# ----------------------------------------------------------------------------- +AIA_WIKI_WORKER_POLL_SECONDS=4 +AIA_WIKI_WORKER_ID=wiki-worker-main + +AIA_ILINK_BOT_TOKEN= + +OCLAW_VERSION=0.1 + +# runtime/operations/main.py 启动时可写入: +# AIA_ASSISTANT_GATEWAY_HOST / PORT(已由第一节覆盖) + +# ----------------------------------------------------------------------------- +# 二十、Skill 内第三方(示例:Tavily) +# ----------------------------------------------------------------------------- +TAVILY_API_KEY= + +# ----------------------------------------------------------------------------- +# 二十一、下列 keys 常在「管理后台 → 网关/运维」中配置(SQLite),与 env 二选一或叠加 +# 若代码未 getenv,请使用后台保存;不要仅写在本文件。 +# ----------------------------------------------------------------------------- +# AIA_TURN_MAX_CONTEXT_MESSAGES / AIA_TURN_MAX_TOOL_ROUNDS / AIA_TURN_MAX_TOOL_WORKERS +# AIA_OCLAW_MAX_ATTEMPTS +# AIA_ENABLE_PLUGIN_TOOLS(注意与 AIA_PLUGIN_TOOLS_ENABLED 命名区分,以后台为准) +# AIA_TOOL_CONTEXT_TRUNCATE_ENABLED +# AIA_CHAT_SHOW_TTFT_DEBUG +# AIA_SKILL_RUNTIME_ENABLED / AIA_SKILL_AUTO_INSTALL_ENABLED +# AIA_OCLAW_RETRYABLE_ERROR_CODES / AIA_OCLAW_RETRY_CODES_STRICT_MODE +# rag_mode、embedding 相关部分亦在后台 memory/RAG 页 +# 【前端】多数上述项在 Admin 设置表单中有对应勾选或输入框。 diff --git a/docs/plan_agent_v2_gateway_cutover_patch.md b/docs/plan_agent_v2_gateway_cutover_patch.md new file mode 100644 index 00000000..4ee42199 --- /dev/null +++ b/docs/plan_agent_v2_gateway_cutover_patch.md @@ -0,0 +1,36 @@ +# Plan Agent V2 Gateway Cutover Draft + +## Purpose +- Provide a minimal, reviewable gateway cutover sketch without changing production routing yet. +- Keep existing `runtime/gateway.py` behavior unchanged until explicit cutover approval. + +## Draft Helper +- New module: + - `runtime/plan_agent_v2_gateway_cutover.py` +- Entrypoint: + - `maybe_handle_expert_turn_v2_draft(...)` + +## Draft Behavior +- If v2 shadow is not selected: + - returns `handled=False`, gateway should continue legacy flow. +- If decision is `enter_plan` or `stay_plan`: + - returns `handled=True` with an `OclawGatewayResult` built from v2 shadow compatibility mapper. +- If decision is `run_agent`: + - returns `handled=False` and provides `system_prompt_override`. + - gateway would continue legacy execution path but with injected approved-plan context. + +## Why This Is Safe +- No import or call-site changes in `runtime/gateway.py` yet. +- Feature remains effectively dormant unless future cutover patch wires this helper. +- Existing tests continue to validate legacy and shadow independently. + +## Future Minimal Cutover (single commit) +- In `OclawGateway.handle_turn(...)` expert path, add one early branch: + 1) call `maybe_handle_expert_turn_v2_draft(...)` + 2) if `handled=True`, return result immediately + 3) else continue existing flow; if `system_prompt_override` exists, use it as specialist system prompt + +## Rollback +- Revert only the gateway wiring commit. +- Keep shadow modules and tests as dormant assets. + diff --git a/docs/plan_agent_v2_shadow.md b/docs/plan_agent_v2_shadow.md new file mode 100644 index 00000000..6ab799bb --- /dev/null +++ b/docs/plan_agent_v2_shadow.md @@ -0,0 +1,103 @@ +# Plan Agent V2 (Shadow) Design + +## Goal +- Build a complete plan/agent pipeline in shadow mode first. +- Keep legacy runtime path unchanged until final one-shot cutover. +- Support instant rollback via a single feature switch. + +## Scope +- Target only `interaction_mode=expert`. +- `interaction_mode=comprehensive` remains on legacy path. +- Current implementation is dry-run/shadow ready, not wired into gateway production flow. + +## Runtime Components +- Package root: `runtime/plan_agent_v2/` + - `models.py`: state model (`PlanAgentStateV2`) + - `state_store.py`: session state persistence + - `manager.py`: plan lifecycle (`enter/confirm/exit`) + - `tool_policy.py`: plan-mode tool filtering policy + - `prompt_injector.py`: plan-mode and approved-plan prompt injection + - `tool_specs.py`: shadow plan tools (`enter_plan_mode_v2`, `exit_plan_mode_v2`) + - `switch.py`: feature switch and routing predicate + - `adapter.py`: expert-mode plan decision logic + - `gateway_adapter.py`: gateway-side shadow adapter + - `trace.py`: plan events trace helper + - `compat.py`: legacy result-shape compatibility helpers + +## Legacy Compatibility +- Flat module paths are still available and now forward to package modules: + - `runtime/plan_agent_v2_*.py` -> `runtime/plan_agent_v2/*` +- This prevents existing imports from breaking during migration. + +## Session State Contract +- Stored under key: + - `AIA_PLAN_AGENT_V2_STATE:` +- Serialized JSON fields: + - `mode`: `normal|plan` + - `owner_specialist` + - `plan_id` + - `plan_path` + - `plan_content` + - `plan_confirmed` + - `entered_at_ms` + - `updated_at_ms` + +## Feature Switches +- `AIA_EXPERT_PLAN_AGENT_V2_ENABLED` + - default: off + - effect: allow expert path to route to shadow v2 when wired +- `AIA_EXPERT_PLAN_FILE_DIR` + - optional plan file root override +- `AIA_EXPERT_PLAN_CONFIRM_STRATEGY` + - `strict` (default): confirmation in `plan` mode is blocked until user switches to `agent` + - `auto`: confirmation in `plan` mode auto-switches to execution + - `off`: disable confirmation-mode gate (same confirm behavior as `auto`) + +## Admin API Mode Fields +- `GET /admin/api/chat/sessions/{session_id}/mode` + - now returns `confirm_strategy` together with `interaction_mode/specialist/memory_mode/execution_mode`. +- `POST /admin/api/chat/sessions/{session_id}/mode` + - accepts optional `confirm_strategy` (`strict|auto|off`) + - persists per-user and per-session mode settings + - mirrors to runtime key `AIA_EXPERT_PLAN_CONFIRM_STRATEGY` for immediate effect in expert v2 turns + +## Routing Contract (Shadow) +- Predicate: + - `should_route_to_v2(store, interaction_mode, force_flag=False)` +- Rules: + - non-expert mode: always false + - expert + `force_flag=True`: true + - expert + feature on: true + - otherwise: false + +## Adapter Outputs +- `evaluate_for_expert_mode(...)` returns: + - `action`: `enter_plan|stay_plan|run_agent` + - `reply_text` + - `plan_state` + - `system_prompt_override` (set on `run_agent`) + +## Trace Events +- Emitted by `emit_plan_agent_v2_trace(...)`: + - `plan_mode_entered` + - `plan_mode_active` + - `plan_mode_confirmed` + +## Tests +- Shadow core tests: + - `tests/test_plan_agent_v2_shadow.py` +- Gateway dry-run comparison tests: + - `tests/test_plan_agent_v2_gateway_dryrun.py` + +## Cutover Plan (Later, Not Yet Applied) +- Add one gateway branch: + - if `should_route_to_v2(...)` then call `evaluate_gateway_expert_turn_shadow(...)` + - else keep legacy path +- Keep cutover in one commit for easy rollback. + +## Rollback +- Runtime rollback: + - set `AIA_EXPERT_PLAN_AGENT_V2_ENABLED=false` +- Code rollback: + - revert only gateway branch commit; shadow modules can remain dormant. + diff --git a/interfaces/admin/chat_api.py b/interfaces/admin/chat_api.py index 65ce63b8..f761b674 100644 --- a/interfaces/admin/chat_api.py +++ b/interfaces/admin/chat_api.py @@ -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() diff --git a/interfaces/admin/static/chat.html b/interfaces/admin/static/chat.html index bf99637d..4e2d33e5 100644 --- a/interfaces/admin/static/chat.html +++ b/interfaces/admin/static/chat.html @@ -563,7 +563,7 @@ diff --git a/interfaces/admin/static/chat.js b/interfaces/admin/static/chat.js index 05957a1b..07ed59eb 100644 --- a/interfaces/admin/static/chat.js +++ b/interfaces/admin/static/chat.js @@ -1,6 +1,7 @@ /* Standalone /chat page: same bearer + /admin/api/chat as admin SPA. */ const PAGE_SIZE = 35; +const CHAT_MESSAGES_FETCH_LIMIT = 5000; const I18N = { zh: { @@ -36,7 +37,7 @@ const I18N = { "chat.tools": "推理", "chat.tools.hidden": "推理已隐藏", "chat.tools.visible": "推理已显示", - "chat.compressHistory": "压缩历史", + "chat.compressHistory": "压缩对话", "chat.compressHistoryPrompt": "将本会话历史工具输出按回放策略写回压缩(不可逆)?建议仅在发现超大 tool_result/导出卡顿时使用。", "chat.compressHistoryOk": "压缩完成:扫描 {scanned} 条 tool 消息,重写 {rewritten} 条,超限压缩 {compacted} 条(已跳过 {skipped} 条已压缩)。", "chat.compressHistoryFail": "压缩失败:{error}", @@ -72,6 +73,18 @@ const I18N = { "chat.modeLabel": "模式", "chat.modeComprehensive": "综合", "chat.modeExpert": "专家", + "chat.execModeLabel": "执行态", + "chat.execModeAgent": "Agent", + "chat.execModePlan": "Plan", + "chat.execModeApplied": "执行态:{mode}", + "chat.confirmStrategyLabel": "确认策略", + "chat.planAgentVersionLabel": "Plan / Agent 版本", + "chat.planAgentVersionV1": "v1(经典)", + "chat.planAgentVersionV2": "v2", + "chat.planAgentVersionV2Disabled": "v2(未启用,需 AIA_EXPERT_PLAN_AGENT_V2_ENABLED=1)", + "chat.confirmStrategyStrict": "Strict(需切换 Agent)", + "chat.confirmStrategyAuto": "Auto(自动确认执行)", + "chat.confirmStrategyOff": "Off(不拦截确认)", "chat.specialistGeneralist": "通用", "chat.specialistOps": "运维", "chat.specialistImage": "图像", @@ -191,7 +204,7 @@ const I18N = { "chat.tools": "Reasoning", "chat.tools.hidden": "Reasoning hidden", "chat.tools.visible": "Reasoning visible", - "chat.compressHistory": "Compress history", + "chat.compressHistory": "Compress chat", "chat.compressHistoryPrompt": "Rewrite this session's historical tool outputs using replay-guard compaction (irreversible). Use only when a session is polluted by huge tool_result.", "chat.compressHistoryOk": "Compaction done: scanned {scanned} tool messages, rewritten {rewritten}, oversized compacted {compacted} (skipped {skipped} already compacted).", "chat.compressHistoryFail": "Compaction failed: {error}", @@ -227,6 +240,17 @@ const I18N = { "chat.modeLabel": "Mode", "chat.modeComprehensive": "Comprehensive", "chat.modeExpert": "Expert", + "chat.execModeLabel": "Execution", + "chat.execModeAgent": "Agent", + "chat.execModePlan": "Plan", + "chat.execModeApplied": "Execution: {mode}", + "chat.confirmStrategyLabel": "Confirm Strategy", + "chat.planAgentVersionLabel": "Plan / Agent version", + "chat.planAgentVersionV1": "v1 (classic)", + "chat.planAgentVersionV2": "v2", + "chat.confirmStrategyStrict": "Strict (switch to Agent first)", + "chat.confirmStrategyAuto": "Auto (confirm executes directly)", + "chat.confirmStrategyOff": "Off (no confirm-mode gate)", "chat.specialistGeneralist": "Generalist", "chat.specialistOps": "Ops", "chat.specialistImage": "Image", @@ -326,7 +350,18 @@ const CHAT_URL_SCOPE_KEY = "ops_chat_url_scope"; const CHAT_SPECIALIST_PREF_KEY = "ops_chat_specialist_pref"; const CHAT_INTERACTION_MODE_KEY = "ops_chat_interaction_mode"; const CHAT_MEMORY_MODE_KEY = "ops_chat_memory_mode"; +const CHAT_EXECUTION_MODE_KEY = "ops_chat_execution_mode"; +const CHAT_CONFIRM_STRATEGY_KEY = "ops_chat_confirm_strategy"; +const CHAT_PLAN_AGENT_VERSION_KEY = "ops_chat_plan_agent_version"; +const CHAT_USER_MENU_MODE_KEY = "ops_chat_user_menu_mode"; const CHAT_REASONING_TOGGLE_KEY = "ops_chat_reasoning_toggle"; +const EXECUTION_MODE_AGENT = "agent"; +const EXECUTION_MODE_PLAN = "plan"; +const CONFIRM_STRATEGY_STRICT = "strict"; +const CONFIRM_STRATEGY_AUTO = "auto"; +const CONFIRM_STRATEGY_OFF = "off"; +const PLAN_AGENT_V1 = "v1"; +const PLAN_AGENT_V2 = "v2"; const ADMIN_CHAT_SHOW_TOOL_OUTPUT_DEFAULT = false; const REASONING_BLOCK_MAX_CHARS = 12000; const CHAT_ENABLE_WIKI_EVENT_POLLER = false; @@ -510,6 +545,10 @@ function _buildRenderRows(msgs) { let agg = null; const flush = () => { if (!agg) return; + if (!Array.isArray(agg._items) || !agg._items.length) { + agg = null; + return; + } rows.push(agg); agg = null; }; @@ -539,7 +578,7 @@ function _buildRenderRows(msgs) { if (String(content || "").trim()) { agg._items.push({ kind: "reasoning", text: content }); } - } else if (String(content || "").trim()) { + } else if ((eventType === "assistant_text" || eventType === "assistant" || !eventType) && String(content || "").trim()) { agg._items.push({ kind: "assistant_text", text: content }); } const tc = m.tool_calls; @@ -2209,30 +2248,80 @@ function syncAuthUserLabel() { "data-menu-action": "profile", text: t("chat.myProfile"), }), + ]; + const bridge = window.__chatUserMenuPrefs; + if (bridge && typeof bridge === "object") { + items.push(el("div", { class: "chat-sess-menu-sep" })); + items.push(el("div", { class: "muted", style: "padding:6px 10px 2px;font-size:12px;", text: t("chat.modeLabel") })); + const modeSel = el("select", { class: "input", style: "width:100%;margin:4px 8px 8px;max-width:calc(100% - 16px);" }); + try { + const rows = Array.isArray(bridge.getModeOptions && bridge.getModeOptions()) ? bridge.getModeOptions() : []; + rows.forEach((r) => modeSel.appendChild(el("option", { value: String(r.value || ""), text: String(r.label || r.value || "") }))); + modeSel.value = String((bridge.getModeValue && bridge.getModeValue()) || ""); + } catch (_) {} + modeSel.addEventListener("change", () => { + try { + Promise.resolve(bridge.setModeValue && bridge.setModeValue(modeSel.value)).catch(() => {}); + } catch (_) {} + }); + items.push(modeSel); + items.push(el("div", { class: "muted", style: "padding:6px 10px 2px;font-size:12px;", text: t("chat.planAgentVersionLabel") })); + const pavSel = el("select", { class: "input", style: "width:100%;margin:4px 8px 8px;max-width:calc(100% - 16px);" }); + try { + const prow = Array.isArray(bridge.getPlanAgentVersionOptions && bridge.getPlanAgentVersionOptions()) + ? bridge.getPlanAgentVersionOptions() + : []; + prow.forEach((r) => + pavSel.appendChild(el("option", { value: String(r.value || ""), text: String(r.label || r.value || "") })), + ); + pavSel.value = String((bridge.getPlanAgentVersionValue && bridge.getPlanAgentVersionValue()) || ""); + } catch (_) {} + pavSel.addEventListener("change", () => { + try { + Promise.resolve(bridge.setPlanAgentVersionValue && bridge.setPlanAgentVersionValue(pavSel.value)).catch(() => {}); + } catch (_) {} + }); + items.push(pavSel); + items.push(el("div", { class: "muted", style: "padding:2px 10px 2px;font-size:12px;", text: t("chat.confirmStrategyLabel") })); + const csSel = el("select", { class: "input", style: "width:100%;margin:4px 8px 8px;max-width:calc(100% - 16px);" }); + try { + const rows = Array.isArray(bridge.getConfirmStrategyOptions && bridge.getConfirmStrategyOptions()) + ? bridge.getConfirmStrategyOptions() + : []; + rows.forEach((r) => csSel.appendChild(el("option", { value: String(r.value || ""), text: String(r.label || r.value || "") }))); + csSel.value = String((bridge.getConfirmStrategyValue && bridge.getConfirmStrategyValue()) || ""); + } catch (_) {} + csSel.addEventListener("change", () => { + try { + Promise.resolve(bridge.setConfirmStrategyValue && bridge.setConfirmStrategyValue(csSel.value)).catch(() => {}); + } catch (_) {} + }); + items.push(csSel); + const reasonWrap = el("label", { class: "switch-wrap", style: "margin:2px 8px 8px;" }, [ + el("input", { type: "checkbox", class: "switch-input" }), + el("span", { class: "switch-slider" }), + el("span", { class: "muted", text: t("chat.tools") }), + ]); + const reasonCb = reasonWrap.querySelector("input.switch-input"); + try { + reasonCb.checked = !!(bridge.getReasoningVisible && bridge.getReasoningVisible()); + } catch (_) {} + reasonCb.addEventListener("change", () => { + try { + Promise.resolve(bridge.setReasoningVisible && bridge.setReasoningVisible(!!reasonCb.checked)).catch(() => {}); + } catch (_) {} + }); + items.push(reasonWrap); + } + items.push(el("div", { class: "chat-sess-menu-sep" })); + items.push( el("button", { type: "button", class: "chat-sess-menu-item", "data-menu-action": "lang", text: t("lang.switch"), }), - el("button", { - type: "button", - class: "chat-sess-menu-item", - "data-menu-action": "dispatchLabels", - text: t("chat.dispatchLabelsEdit"), - }), - ]; - if (isAdminViewer) { - items.push(el("div", { class: "chat-sess-menu-sep" })); - items.push( - el("button", { - type: "button", - class: "chat-sess-menu-item", - "data-menu-action": "attachmentAclBackfill", - text: t("chat.attachmentAclBackfill"), - }), - ); - } + ); items.push(el("div", { class: "chat-sess-menu-sep" })); items.push( el("button", { @@ -2242,11 +2331,21 @@ function syncAuthUserLabel() { text: t("auth.logout"), }), ); - const menu = el("div", { class: "chat-sess-menu-pop", style: "position:fixed;" }, items); + const menu = el("div", { class: "chat-sess-menu-pop", style: "position:fixed;min-width:220px;" }, items); const rect = moreBtn.getBoundingClientRect(); - menu.style.left = `${Math.min(rect.left, window.innerWidth - 220)}px`; - menu.style.top = `${Math.max(8, rect.top - 92)}px`; + menu.style.position = "fixed"; document.body.appendChild(menu); + const mrect = menu.getBoundingClientRect(); + const pad = 8; + let left = rect.left; + let top = rect.bottom + 4; + if (top + mrect.height > window.innerHeight - pad) { + top = rect.top - 4 - mrect.height; + } + left = Math.max(pad, Math.min(left, window.innerWidth - pad - mrect.width)); + top = Math.max(pad, Math.min(top, window.innerHeight - pad - mrect.height)); + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; const close = (e) => { if (!menu.contains(e.target)) { menu.remove(); @@ -2442,19 +2541,83 @@ async function renderChatUi() { disabled: "disabled", }); const composerShell = el("div", { class: "chat-composer-shell" }); - const toolToggleCb = el("input", { type: "checkbox", class: "switch-input" }); - const toolToggleWrap = el("label", { class: "switch-wrap", title: t("chat.tools.visible") }, [ - toolToggleCb, - el("span", { class: "switch-slider" }), - el("span", { class: "muted", text: t("chat.tools") }), - ]); const MAIN_MODE_VALUE = "comprehensive"; const EXCLUDED_SPECIALISTS = new Set(["main", "memory", "manager_self", "pycache", "__pycache__"]); let specialistCatalog = []; + /** Hidden: mode is global-only (⋯ menu). Kept for specialist option list in `publishUserMenuPrefsBridge`. */ const modeSelect = el("select", { class: "input", - style: "min-width:120px;max-width:160px;padding:6px 8px;", + style: "display:none;", + "aria-hidden": "true", + tabIndex: -1, }); + let globalMenuModeValue = String(localStorage.getItem(CHAT_USER_MENU_MODE_KEY) || MAIN_MODE_VALUE).toLowerCase(); + const modelSelect = el("select", { + class: "input", + style: "min-width:150px;max-width:240px;padding:6px 8px;", + title: t("chat.activeModelLabel"), + }); + let modelSelectNameToId = new Map(); + const normalizeExecutionMode = (v) => { + const raw = String(v || "").trim().toLowerCase(); + return raw === EXECUTION_MODE_PLAN ? EXECUTION_MODE_PLAN : EXECUTION_MODE_AGENT; + }; + const normalizeConfirmStrategy = (v) => { + const raw = String(v || "").trim().toLowerCase(); + if (raw === CONFIRM_STRATEGY_AUTO) return CONFIRM_STRATEGY_AUTO; + if (raw === CONFIRM_STRATEGY_OFF) return CONFIRM_STRATEGY_OFF; + return CONFIRM_STRATEGY_STRICT; + }; + const executionModeLabel = (v) => + normalizeExecutionMode(v) === EXECUTION_MODE_PLAN ? t("chat.execModePlan") : t("chat.execModeAgent"); + let currentExecutionMode = normalizeExecutionMode(localStorage.getItem(CHAT_EXECUTION_MODE_KEY) || EXECUTION_MODE_AGENT); + let currentConfirmStrategy = normalizeConfirmStrategy(localStorage.getItem(CHAT_CONFIRM_STRATEGY_KEY) || CONFIRM_STRATEGY_STRICT); + const normalizePlanAgentVersion = (v) => { + const raw = String(v || "").trim().toLowerCase(); + return raw === PLAN_AGENT_V2 ? PLAN_AGENT_V2 : PLAN_AGENT_V1; + }; + let planAgentV2GloballyEnabled = false; + let currentPlanAgentVersion = normalizePlanAgentVersion(localStorage.getItem(CHAT_PLAN_AGENT_VERSION_KEY) || PLAN_AGENT_V1); + const execSelect = el("select", { + class: "input", + style: "min-width:96px;max-width:140px;padding:6px 8px;", + }); + const refreshExecutionSelect = () => { + const prev = normalizeExecutionMode(execSelect.value || currentExecutionMode); + execSelect.innerHTML = ""; + execSelect.appendChild(el("option", { value: EXECUTION_MODE_AGENT, text: t("chat.execModeAgent") })); + execSelect.appendChild(el("option", { value: EXECUTION_MODE_PLAN, text: t("chat.execModePlan") })); + execSelect.value = prev; + }; + const setExecutionMode = (v, { persistLocal = true, saveSession = true } = {}) => { + currentExecutionMode = normalizeExecutionMode(v); + if (persistLocal) localStorage.setItem(CHAT_EXECUTION_MODE_KEY, currentExecutionMode); + refreshExecutionSelect(); + execSelect.value = currentExecutionMode; + if (saveSession && activeId) saveSessionModePreference(); + }; + execSelect.addEventListener("change", () => { + setExecutionMode(execSelect.value, { persistLocal: true, saveSession: true }); + }); + refreshExecutionSelect(); + execSelect.value = currentExecutionMode; + const execSelectWrap = el("span", { class: "chat-exec-mode-wrap", style: "display:inline-flex;align-items:center;" }, [ + execSelect, + ]); + const outboundPlanAgentVersion = () => { + const modeVal = String(globalMenuModeValue || MAIN_MODE_VALUE).toLowerCase(); + if (modeVal === MAIN_MODE_VALUE) return PLAN_AGENT_V1; + return normalizePlanAgentVersion(currentPlanAgentVersion); + }; + const refreshExecUi = () => { + const modeVal = String(globalMenuModeValue || MAIN_MODE_VALUE).toLowerCase(); + const expert = modeVal !== MAIN_MODE_VALUE; + const show = + expert && + planAgentV2GloballyEnabled && + normalizePlanAgentVersion(currentPlanAgentVersion) === PLAN_AGENT_V2; + execSelectWrap.style.display = show ? "inline-flex" : "none"; + }; const modeOptionLabel = (v) => { const key = String(v || "").trim().toLowerCase(); if (key === MAIN_MODE_VALUE) return t("chat.modeComprehensive"); @@ -2472,12 +2635,17 @@ async function renderChatUi() { return specialistCatalog.some((x) => String(x.id || "").toLowerCase() === key); }; const persistModeSelection = () => { - const v = String(modeSelect.value || MAIN_MODE_VALUE).toLowerCase(); + const v = String(globalMenuModeValue || MAIN_MODE_VALUE).toLowerCase(); localStorage.setItem(CHAT_INTERACTION_MODE_KEY, v); if (v !== MAIN_MODE_VALUE) localStorage.setItem(CHAT_SPECIALIST_PREF_KEY, v); }; + const syncHiddenModeSelectFromGlobal = () => { + const g = String(globalMenuModeValue || MAIN_MODE_VALUE).toLowerCase(); + if (Array.from(modeSelect.options || []).some((o) => String(o.value || "") === g)) modeSelect.value = g; + else modeSelect.value = MAIN_MODE_VALUE; + }; const applyModeOptions = () => { - const prev = String(modeSelect.value || localStorage.getItem(CHAT_INTERACTION_MODE_KEY) || MAIN_MODE_VALUE).toLowerCase(); + const prev = String(globalMenuModeValue || MAIN_MODE_VALUE).toLowerCase(); modeSelect.innerHTML = ""; modeSelect.appendChild(el("option", { value: MAIN_MODE_VALUE, text: modeOptionLabel(MAIN_MODE_VALUE) })); modeSelect.appendChild(el("option", { value: "generalist", text: modeOptionLabel("generalist") })); @@ -2486,17 +2654,15 @@ async function renderChatUi() { if (!sid || sid === "generalist") return; modeSelect.appendChild(el("option", { value: sid, text: modeOptionLabel(sid) })); }); - if (Array.from(modeSelect.options).some((o) => String(o.value || "") === prev)) modeSelect.value = prev; - else modeSelect.value = MAIN_MODE_VALUE; + if (Array.from(modeSelect.options).some((o) => String(o.value || "") === prev)) { + modeSelect.value = prev; + } else { + modeSelect.value = MAIN_MODE_VALUE; + globalMenuModeValue = MAIN_MODE_VALUE; + } }; - const _im = String(localStorage.getItem(CHAT_INTERACTION_MODE_KEY) || MAIN_MODE_VALUE).toLowerCase(); - modeSelect.value = _im; const _mm = String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default").toLowerCase(); localStorage.setItem(CHAT_MEMORY_MODE_KEY, _mm === "store_only" ? "store_only" : "default"); - modeSelect.addEventListener("change", () => { - persistModeSelection(); - saveSessionModePreference(); - }); const loadSpecialistCatalog = async () => { try { const r = await apiGet("/admin/api/experts"); @@ -2528,42 +2694,150 @@ async function renderChatUi() { const p = profiles.find((x) => String(x.id || "") === aid) || null; const modelName = String((p && p.model) || "").trim() || "-"; activeModelText.textContent = `${t("chat.activeModelLabel")}: ${modelName}`; + modelSelect.innerHTML = ""; + modelSelectNameToId = new Map(); + const usedKeys = new Set(); + profiles.forEach((row) => { + const pid = String(row && row.id ? row.id : ""); + if (!pid) return; + const rawName = String(row.name || "").trim(); + const keyBase = rawName || pid; + let key = keyBase; + let n = 2; + while (usedKeys.has(key)) { + key = `${keyBase}#${n}`; + n += 1; + } + usedKeys.add(key); + modelSelectNameToId.set(key, pid); + const display = String(row.model || row.name || pid); + modelSelect.appendChild(el("option", { value: key, text: display })); + }); + if (p) { + const rawName = String(p.name || "").trim(); + const firstKey = Array.from(modelSelectNameToId.keys()).find((k) => modelSelectNameToId.get(k) === aid) || ""; + const key = firstKey || rawName; + if (key && Array.from(modelSelect.options).some((o) => String(o.value || "") === key)) { + modelSelect.value = key; + } + } else if (modelSelect.options.length > 0) { + modelSelect.value = String(modelSelect.options[0].value || ""); + } } catch (_) { activeModelText.textContent = `${t("chat.activeModelLabel")}: -`; + modelSelect.innerHTML = ""; + modelSelect.appendChild(el("option", { value: "", text: "-" })); + modelSelect.value = ""; } }; + modelSelect.addEventListener("change", async () => { + const selectedKey = String(modelSelect.value || "").trim(); + const pid = String(modelSelectNameToId.get(selectedKey) || "").trim(); + if (!pid) return; + try { + await apiPost("/admin/api/models/active", { profile_id: pid }); + await refreshActiveModelText(); + } catch (e) { + statusBar.textContent = `${t("chat.error")}: ${String(e)}`; + } + }); const loadSessionModePreference = async () => { - if (!activeId) return; + if (!activeId) { + setExecutionMode(currentExecutionMode, { persistLocal: true, saveSession: false }); + refreshExecUi(); + return; + } try { const resp = await apiGet(`/admin/api/chat/sessions/${encodeURIComponent(activeId)}/mode`); const m = String((resp && resp.interaction_mode) || "").toLowerCase(); const s = String((resp && resp.specialist) || "").toLowerCase(); const mm = String((resp && resp.memory_mode) || "").toLowerCase(); - if (m === "comprehensive") modeSelect.value = MAIN_MODE_VALUE; - else if (m === "expert" && isSelectableSpecialist(s)) modeSelect.value = s; + const em = String((resp && resp.execution_mode) || "").toLowerCase(); + const cs = String((resp && resp.confirm_strategy) || "").toLowerCase(); + planAgentV2GloballyEnabled = !!(resp && resp.plan_agent_v2_globally_enabled); + const pavRaw = String((resp && resp.plan_agent_version) || "").trim().toLowerCase(); + currentPlanAgentVersion = normalizePlanAgentVersion(pavRaw || localStorage.getItem(CHAT_PLAN_AGENT_VERSION_KEY)); + localStorage.setItem(CHAT_PLAN_AGENT_VERSION_KEY, currentPlanAgentVersion); + const gm = resp && resp.global_menu && typeof resp.global_menu === "object" ? resp.global_menu : null; + if (gm) { + const gIm = String(gm.interaction_mode || "").toLowerCase(); + const gSp = String(gm.specialist || "").toLowerCase(); + if (gIm === "comprehensive") globalMenuModeValue = MAIN_MODE_VALUE; + else if (gIm === "expert") { + if (isSelectableSpecialist(gSp)) globalMenuModeValue = gSp; + else globalMenuModeValue = "generalist"; + } else globalMenuModeValue = MAIN_MODE_VALUE; + } else { + try { + const ur = await apiGet("/admin/api/chat/user-mode"); + if (ur && ur.ok) { + planAgentV2GloballyEnabled = !!(ur.plan_agent_v2_globally_enabled != null ? ur.plan_agent_v2_globally_enabled : planAgentV2GloballyEnabled); + const gum = String((ur.interaction_mode || "").toLowerCase()); + const gus = String((ur.specialist || "").toLowerCase()); + currentConfirmStrategy = normalizeConfirmStrategy(ur.confirm_strategy || currentConfirmStrategy); + localStorage.setItem(CHAT_CONFIRM_STRATEGY_KEY, currentConfirmStrategy); + currentPlanAgentVersion = normalizePlanAgentVersion( + ur.plan_agent_version || localStorage.getItem(CHAT_PLAN_AGENT_VERSION_KEY), + ); + localStorage.setItem(CHAT_PLAN_AGENT_VERSION_KEY, currentPlanAgentVersion); + if (gum === "comprehensive") globalMenuModeValue = MAIN_MODE_VALUE; + else if (gum === "expert") { + if (isSelectableSpecialist(gus)) globalMenuModeValue = gus; + else globalMenuModeValue = "generalist"; + } else globalMenuModeValue = MAIN_MODE_VALUE; + } + } catch (_) { + if (m === "comprehensive") globalMenuModeValue = MAIN_MODE_VALUE; + else if (m === "expert" && isSelectableSpecialist(s)) globalMenuModeValue = s; + else globalMenuModeValue = MAIN_MODE_VALUE; + } + } + localStorage.setItem(CHAT_USER_MENU_MODE_KEY, String(globalMenuModeValue || MAIN_MODE_VALUE).toLowerCase()); + syncHiddenModeSelectFromGlobal(); if (["default", "store_only"].includes(mm)) localStorage.setItem(CHAT_MEMORY_MODE_KEY, mm); + setExecutionMode(em, { persistLocal: true, saveSession: false }); + currentConfirmStrategy = normalizeConfirmStrategy(cs || currentConfirmStrategy); + localStorage.setItem(CHAT_CONFIRM_STRATEGY_KEY, currentConfirmStrategy); persistModeSelection(); const mml = String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default").toLowerCase(); localStorage.setItem(CHAT_MEMORY_MODE_KEY, mml === "store_only" ? "store_only" : "default"); + refreshExecUi(); + publishUserMenuPrefsBridge(); + } catch (_) { + setExecutionMode(currentExecutionMode, { persistLocal: true, saveSession: false }); + refreshExecUi(); + } + }; + const saveUserGlobalModePreference = async () => { + try { + const modeVal = String(globalMenuModeValue || MAIN_MODE_VALUE).toLowerCase(); + const isMain = modeVal === MAIN_MODE_VALUE; + const resp = await apiPost("/admin/api/chat/user-mode", { + interaction_mode: isMain ? "comprehensive" : "expert", + specialist: isMain ? "generalist" : modeVal, + confirm_strategy: String(currentConfirmStrategy || CONFIRM_STRATEGY_STRICT), + plan_agent_version: String(currentPlanAgentVersion || PLAN_AGENT_V1), + }); + if (resp && typeof resp.plan_agent_v2_globally_enabled === "boolean") { + planAgentV2GloballyEnabled = !!resp.plan_agent_v2_globally_enabled; + } + localStorage.setItem(CHAT_USER_MENU_MODE_KEY, modeVal); } catch (_) {} }; const saveSessionModePreference = async () => { if (!activeId) return; try { - const modeVal = String(modeSelect.value || MAIN_MODE_VALUE).toLowerCase(); - const isMain = modeVal === MAIN_MODE_VALUE; await apiPost(`/admin/api/chat/sessions/${encodeURIComponent(activeId)}/mode`, { - interaction_mode: isMain ? "comprehensive" : "expert", - specialist: isMain ? "generalist" : modeVal, memory_mode: String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default"), + execution_mode: String(currentExecutionMode || EXECUTION_MODE_AGENT), }); } catch (_) {} }; await refreshActiveModelText(); + refreshExecUi(); const composerMetaBar = el("div", { class: "row", style: "gap:8px;padding:2px 4px 6px;align-items:center;" }, [ - el("span", { class: "muted", text: t("chat.modeLabel") }), - modeSelect, - toolToggleWrap, + execSelectWrap, + modelSelect, el("button", { type: "button", class: "btn", @@ -2811,27 +3085,73 @@ async function renderChatUi() { }; attachBtn.addEventListener("click", () => fileInput.click()); - const syncToolToggleBtn = () => { - toolToggleCb.checked = !!showToolOutput; - toolToggleWrap.title = showToolOutput ? t("chat.tools.visible") : t("chat.tools.hidden"); - }; - toolToggleCb.addEventListener("change", () => { - showToolOutput = !!toolToggleCb.checked; + const setReasoningVisible = (next, { showStatus = true } = {}) => { + showToolOutput = !!next; adminChatShowToolOutput = showToolOutput; localStorage.setItem(CHAT_REASONING_TOGGLE_KEY, showToolOutput ? "1" : "0"); - syncToolToggleBtn(); const isStreaming = composerShell.classList.contains("chat-composer-shell--busy"); - if (isStreaming) { + if (showStatus && isStreaming) { // Avoid clearing active stream bubble mid-turn (loadMessagesForActive() resets messagesEl). statusBar.textContent = `${showToolOutput ? t("chat.tools.visible") : t("chat.tools.hidden")} · ${ currentLang === "zh" ? "本轮结束后应用到历史消息" : "applies to history after current turn" }`; return; } - statusBar.textContent = showToolOutput ? t("chat.tools.visible") : t("chat.tools.hidden"); + if (showStatus) statusBar.textContent = showToolOutput ? t("chat.tools.visible") : t("chat.tools.hidden"); loadMessagesForActive().catch(() => {}); - }); - syncToolToggleBtn(); + }; + const publishUserMenuPrefsBridge = () => { + try { + window.__chatUserMenuPrefs = { + getModeOptions: () => + Array.from(modeSelect.options || []).map((o) => ({ + value: String(o.value || ""), + label: String(o.text || o.label || o.value || ""), + })), + getModeValue: () => String(globalMenuModeValue || MAIN_MODE_VALUE).toLowerCase(), + setModeValue: async (v) => { + const next = String(v || "").toLowerCase(); + if (!Array.from(modeSelect.options || []).some((o) => String(o.value || "") === next)) return; + globalMenuModeValue = next; + localStorage.setItem(CHAT_USER_MENU_MODE_KEY, globalMenuModeValue); + syncHiddenModeSelectFromGlobal(); + await saveUserGlobalModePreference(); + refreshExecUi(); + publishUserMenuPrefsBridge(); + }, + getPlanAgentVersionOptions: () => [ + { value: PLAN_AGENT_V1, label: t("chat.planAgentVersionV1") }, + { value: PLAN_AGENT_V2, label: t("chat.planAgentVersionV2") }, + ], + getPlanAgentVersionValue: () => String(currentPlanAgentVersion || PLAN_AGENT_V1), + setPlanAgentVersionValue: async (v) => { + const next = normalizePlanAgentVersion(v); + currentPlanAgentVersion = next; + localStorage.setItem(CHAT_PLAN_AGENT_VERSION_KEY, currentPlanAgentVersion); + await saveUserGlobalModePreference(); + refreshExecUi(); + publishUserMenuPrefsBridge(); + }, + getConfirmStrategyOptions: () => [ + { value: CONFIRM_STRATEGY_STRICT, label: t("chat.confirmStrategyStrict") }, + { value: CONFIRM_STRATEGY_AUTO, label: t("chat.confirmStrategyAuto") }, + { value: CONFIRM_STRATEGY_OFF, label: t("chat.confirmStrategyOff") }, + ], + getConfirmStrategyValue: () => String(currentConfirmStrategy || CONFIRM_STRATEGY_STRICT), + setConfirmStrategyValue: async (v) => { + currentConfirmStrategy = normalizeConfirmStrategy(v); + localStorage.setItem(CHAT_CONFIRM_STRATEGY_KEY, currentConfirmStrategy); + await saveUserGlobalModePreference(); + publishUserMenuPrefsBridge(); + }, + getReasoningVisible: () => !!showToolOutput, + setReasoningVisible: async (v) => { + setReasoningVisible(!!v, { showStatus: true }); + }, + }; + } catch (_) {} + }; + publishUserMenuPrefsBridge(); fileInput.addEventListener("change", () => { const fs = fileInput.files; if (!fs || !fs.length) return; @@ -3045,7 +3365,9 @@ async function renderChatUi() { } statusBar.textContent = t("chat.loading"); try { - const resp = await apiGet(`/admin/api/chat/sessions/${encodeURIComponent(activeId)}/messages`); + const resp = await apiGet( + `/admin/api/chat/sessions/${encodeURIComponent(activeId)}/messages?limit=${CHAT_MESSAGES_FETCH_LIMIT}`, + ); if (rid !== loadMessagesForActive._rid) return; const msgs = Array.isArray(resp.messages) ? resp.messages : []; const renderRows = _buildRenderRows(msgs); @@ -3317,7 +3639,18 @@ async function renderChatUi() { this.ws = null; } } - async sendSessionSend({ sessionId, text, attachments, interactionMode, specialist, memoryMode, idempotencyKey, signal, onEvent }) { + async sendSessionSend({ + sessionId, + text, + attachments, + interactionMode, + specialist, + memoryMode, + executionMode, + idempotencyKey, + signal, + onEvent, + }) { await this._openAndHandshake(); this._trackSessionSubscription(sessionId); await this._sendReqAndAwait("sessions.messages.subscribe", { sessionKey: String(sessionId || "") }); @@ -3336,6 +3669,8 @@ async function renderChatUi() { interaction_mode: String(interactionMode || "expert"), specialist: String(specialist || "generalist"), memory_mode: String(memoryMode || "default"), + execution_mode: String(executionMode || "agent"), + plan_agent_version: outboundPlanAgentVersion(), }, }; this.ws.send(JSON.stringify(req)); @@ -3381,6 +3716,7 @@ async function renderChatUi() { const p = msg.payload || {}; doneMeta = { interaction_mode: String(p.interactionMode || interactionMode || ""), + execution_mode: String(p.executionMode || executionMode || EXECUTION_MODE_AGENT || ""), selected_specialist: String(p.selectedSpecialist || specialist || ""), dispatch_reason: String(p.dispatchReason || ""), manager_selected_specialist: String(p.managerSelectedSpecialist || ""), @@ -3458,6 +3794,55 @@ async function renderChatUi() { if (!("content" in m) && !("text" in m)) return null; return m; }; + const _expandAssistantMessageForRender = (message) => { + if (!message || typeof message !== "object") return []; + const base = { + role: "assistant", + id: message.id, + timestamp: message.timestamp != null ? message.timestamp : new Date().toISOString(), + attachments: message.attachments || null, + }; + const out = []; + const contentItems = Array.isArray(message.content) ? message.content : []; + for (const item of contentItems) { + if (!item || typeof item !== "object") continue; + const typ = String(item.type || "").toLowerCase(); + if (typ === "reasoning" || typ === "reasoning_text" || typ === "thinking" || typ === "thought") { + const reasoningText = decodeEscapedNewlines(String(item.text || item.content || item.summary || "")).trim(); + if (reasoningText) out.push({ ...base, content: reasoningText, event_type: "reasoning" }); + continue; + } + const textBody = decodeEscapedNewlines( + String(item.text || item.output_text || item.content || item.value || ""), + ).trim(); + if (!textBody) continue; + if (typ === "text" || typ === "output_text" || typ === "assistant_text" || !typ) { + out.push({ ...base, content: textBody, event_type: "assistant_text" }); + } + } + if (out.length) { + const lastIdx = out.length - 1; + out[lastIdx] = { + ...out[lastIdx], + tool_calls: message.tool_calls != null ? message.tool_calls : message.toolCalls, + }; + return out; + } + const textFallback = decodeEscapedNewlines( + typeof message.content === "string" ? message.content : typeof message.text === "string" ? message.text : "", + ).trim(); + if (textFallback) { + return [ + { + ...base, + content: textFallback, + event_type: "assistant_text", + tool_calls: message.tool_calls != null ? message.tool_calls : message.toolCalls, + }, + ]; + } + return []; + }; const sendMessageStream = async (userText, attachmentPayload, turnId) => { const token = localStorage.getItem(AUTH_TOKEN_KEY) || ""; @@ -3501,7 +3886,9 @@ async function renderChatUi() { setTimeout(resolve, Math.max(0, Number(ms) || 0)); }); const _recoverLatestAssistantFromHistory = async () => { - const resp = await apiGet(`/admin/api/chat/sessions/${encodeURIComponent(activeId)}/messages`); + const resp = await apiGet( + `/admin/api/chat/sessions/${encodeURIComponent(activeId)}/messages?limit=${CHAT_MESSAGES_FETCH_LIMIT}`, + ); const msgs = Array.isArray(resp && resp.messages) ? resp.messages : []; if (!msgs.length) return false; let last = null; @@ -3867,15 +4254,8 @@ ${autoLimit ? `
auto-added claus }; const appendFinalAssistant = async (message, fallbackText) => { const normalized = _normalizeAssistantMessage(message, { requireRole: false, requireContentArray: false }); - if (normalized && !_isSilentReplyStream(extractWsAssistantText(normalized))) { - const norm = { - ...normalized, - role: "assistant", - content: extractWsAssistantText(normalized), - timestamp: normalized.timestamp != null ? normalized.timestamp : new Date().toISOString(), - tool_calls: normalized.tool_calls != null ? normalized.tool_calls : normalized.toolCalls, - }; - const rows = _buildRenderRows([norm]); + if (normalized) { + const rows = _buildRenderRows(_expandAssistantMessageForRender(normalized)); const last = rows && rows.length ? rows[rows.length - 1] : null; if (last) { if (streamRow && streamRow.parentNode) streamRow.remove(); @@ -3921,14 +4301,17 @@ ${autoLimit ? `
auto-added claus sessionId: activeId, text: userText, attachments: attachmentPayload, - interactionMode: String(modeSelect.value || MAIN_MODE_VALUE).toLowerCase() === MAIN_MODE_VALUE ? "comprehensive" : "expert", + interactionMode: + String(globalMenuModeValue || MAIN_MODE_VALUE).toLowerCase() === MAIN_MODE_VALUE ? "comprehensive" : "expert", idempotencyKey: String(turnId || ""), - specialist: String(modeSelect.value || MAIN_MODE_VALUE).toLowerCase() === MAIN_MODE_VALUE - ? "generalist" - : (isSelectableSpecialist(String(modeSelect.value || "").toLowerCase()) - ? String(modeSelect.value || "generalist").toLowerCase() - : "generalist"), + specialist: + String(globalMenuModeValue || MAIN_MODE_VALUE).toLowerCase() === MAIN_MODE_VALUE + ? "generalist" + : isSelectableSpecialist(String(globalMenuModeValue || "").toLowerCase()) + ? String(globalMenuModeValue || "generalist").toLowerCase() + : "generalist", memoryMode: String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default"), + executionMode: String(currentExecutionMode || EXECUTION_MODE_AGENT), signal: abortController.signal, onEvent: async (frame) => { wsLastActivityAt = Date.now(); @@ -4306,6 +4689,9 @@ ${autoLimit ? `
auto-added claus const baseText = t("chat.execApplied", { mode: m, specialist: s }); const memoryModeNow = String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default").toLowerCase(); const memoryText = ` · ${t("chat.memoryApplied", { mode: memoryModeShortLabel(memoryModeNow) })}`; + const execModeFromServer = String((doneMeta && doneMeta.execution_mode) || "").trim().toLowerCase(); + const effectiveExecMode = execModeFromServer === EXECUTION_MODE_PLAN ? EXECUTION_MODE_PLAN : currentExecutionMode; + const execText = ` · ${t("chat.execModeApplied", { mode: executionModeLabel(effectiveExecMode) })}`; const reason = reasonLabel(doneMeta && doneMeta.dispatch_reason); const dyn = doneMeta && doneMeta.dynamic_agent_used ? ` · dynamic=${String(doneMeta.dynamic_agent_name || "1")}` : ""; const routeText = reason && reason !== "-" ? ` · ${reason}${dyn}` : dyn; @@ -4387,10 +4773,10 @@ ${autoLimit ? `
auto-added claus const topReasonLabel = String((stats.reasonLabels && stats.reasonLabels[topReason]) || reasonLabel(topReason)); const topReasonText = t("chat.dynamicStatsDetail", { rate: ratePct, reason: topReasonLabel }); const topMixText = t("chat.dynamicTopReasons", { items: top5 || "-" }); - statusBar.textContent = `${baseText}${memoryText}${routeText}${markerText}${ttftText}${phaseText} · ${t("chat.dynamicStats", { dynamic: stats.dynamic, fallback: stats.fallback })} · ${topReasonText} · ${topMixText} · ${t("chat.dynamicAllReasons")}${transportTag ? ` · transport=${transportTag}` : ""}`; + statusBar.textContent = `${baseText}${memoryText}${execText}${routeText}${markerText}${ttftText}${phaseText} · ${t("chat.dynamicStats", { dynamic: stats.dynamic, fallback: stats.fallback })} · ${topReasonText} · ${topMixText} · ${t("chat.dynamicAllReasons")}${transportTag ? ` · transport=${transportTag}` : ""}`; } else { _statusReasonPairs = []; - statusBar.textContent = `${baseText}${memoryText}${routeText}${markerText}${ttftText}${phaseText}${transportTag ? ` · transport=${transportTag}` : ""}`; + statusBar.textContent = `${baseText}${memoryText}${execText}${routeText}${markerText}${ttftText}${phaseText}${transportTag ? ` · transport=${transportTag}` : ""}`; } } catch (e) { statusBar.textContent = `${t("chat.error")}: ${String(e)}`; @@ -4442,7 +4828,6 @@ ${autoLimit ? `
auto-added claus navFooter, ]), el("div", { class: "chat-main" }, [ - el("div", { class: "muted", style: "font-size:12px;line-height:1.2;padding:10px 12px 0;" }, [activeModelText]), messagesEl, statusBar, el("div", { class: "chat-composer" }, [fileInput, composerShell]), @@ -4561,28 +4946,6 @@ document.body.addEventListener("click", async (e) => { await boot(); return; } - if (action === "dispatchLabels") { - const status = document.querySelector(".chat-status"); - await openDispatchLabelsEditor(status); - return; - } - if (action === "attachmentAclBackfill") { - const status = document.querySelector(".chat-status"); - if (!(await confirmChatAction(t("chat.attachmentAclBackfillPrompt")))) return; - try { - const res = await apiPost("/admin/api/chat/admin/attachments/acl/backfill", {}); - const ok = !!(res && (res.ok === true || res.ok === 1)); - if (ok) { - if (status) status.textContent = t("chat.attachmentAclBackfillOk", res); - } else { - const err = String((res && (res.error || res.detail)) || "backfill_failed"); - if (status) status.textContent = t("chat.attachmentAclBackfillFail", { error: err }); - } - } catch (err) { - if (status) status.textContent = t("chat.attachmentAclBackfillFail", { error: String(err) }); - } - return; - } } }); diff --git a/interfaces/gateway/server_methods/chat.py b/interfaces/gateway/server_methods/chat.py index 234793be..1a2ead97 100644 --- a/interfaces/gateway/server_methods/chat.py +++ b/interfaces/gateway/server_methods/chat.py @@ -84,6 +84,9 @@ def _chat_send_handler(opts: dict[str, Any]) -> None: run_id = run_id.strip() if isinstance(run_id, str) and run_id.strip() else None if run_id is None: run_id = f"chat-{session_key.strip()}" + execution_mode = str(params.get("execution_mode") or "agent").strip().lower() or "agent" + if execution_mode not in {"agent", "plan"}: + execution_mode = "agent" normalized_transport: dict[str, Any] = {} if isinstance(params, dict): channel = params.get("channel") @@ -117,6 +120,7 @@ def _chat_send_handler(opts: dict[str, Any]) -> None: "runId": run_id, "sessionKey": session_key.strip(), "message": message.strip(), + "executionMode": execution_mode, **normalized_transport, }, ) diff --git a/interfaces/http/fastapi_app.py b/interfaces/http/fastapi_app.py index 89cd1926..6a87e63e 100644 --- a/interfaces/http/fastapi_app.py +++ b/interfaces/http/fastapi_app.py @@ -1,5 +1,9 @@ from __future__ import annotations +from oclaw.platform.config.bootstrap_env import load_system_env + +load_system_env() + import asyncio import os import shutil @@ -280,6 +284,7 @@ def create_app() -> FastAPI: def main() -> int: + load_system_env() host = (os.getenv("AIA_ASSISTANT_GATEWAY_HOST") or "0.0.0.0").strip() port = int(os.getenv("AIA_ASSISTANT_GATEWAY_PORT") or "8787") try: diff --git a/interfaces/ws/protocol_schemas/chat.json b/interfaces/ws/protocol_schemas/chat.json index 9a9e9127..5d62dd1c 100644 --- a/interfaces/ws/protocol_schemas/chat.json +++ b/interfaces/ws/protocol_schemas/chat.json @@ -14,6 +14,8 @@ "thinking": { "type": "string" }, "interaction_mode": { "type": "string" }, "specialist": { "type": "string" }, + "execution_mode": { "type": "string" }, + "plan_agent_version": { "type": "string" }, "relay_share_envelope": { "type": "object" }, "acp_parent_run_id": { "type": "string" }, "acp_child_run_id": { "type": "string" }, diff --git a/interfaces/ws/server_methods_bridge.py b/interfaces/ws/server_methods_bridge.py index e2674305..91e31561 100644 --- a/interfaces/ws/server_methods_bridge.py +++ b/interfaces/ws/server_methods_bridge.py @@ -51,6 +51,12 @@ def build_gateway_context( p = dict(params or {}) interaction_mode = str(p.get("interaction_mode") or "comprehensive").strip().lower() or "comprehensive" specialist = str(p.get("specialist") or "generalist").strip().lower() or "generalist" + execution_mode = str(p.get("execution_mode") or "agent").strip().lower() or "agent" + if execution_mode not in {"agent", "plan"}: + execution_mode = "agent" + plan_agent_version = str(p.get("plan_agent_version") or "v1").strip().lower() or "v1" + if plan_agent_version not in {"v1", "v2"}: + plan_agent_version = "v1" if interaction_mode != "expert": specialist = "generalist" raw_env = dict(p.get("relay_share_envelope") or {}) if isinstance(p.get("relay_share_envelope"), dict) else {} @@ -62,6 +68,8 @@ def build_gateway_context( "idempotencyKey": str(p.get("idempotencyKey") or uuid.uuid4().hex), "interaction_mode": interaction_mode, "specialist": specialist, + "execution_mode": execution_mode, + "plan_agent_version": plan_agent_version, "relay_share_envelope": norm_env if ok_env else {}, "acp_parent_run_id": str(p.get("acp_parent_run_id") or ""), "acp_child_run_id": str(p.get("acp_child_run_id") or ""), @@ -81,6 +89,12 @@ def build_gateway_context( message = str(p.get("message") or "").strip() interaction_mode = str(p.get("interaction_mode") or "comprehensive").strip().lower() or "comprehensive" specialist = str(p.get("specialist") or "generalist").strip().lower() or "generalist" + execution_mode = str(p.get("execution_mode") or "agent").strip().lower() or "agent" + if execution_mode not in {"agent", "plan"}: + execution_mode = "agent" + plan_agent_version = str(p.get("plan_agent_version") or "v1").strip().lower() or "v1" + if plan_agent_version not in {"v1", "v2"}: + plan_agent_version = "v1" if interaction_mode != "expert": specialist = "generalist" raw_env = dict(p.get("relay_share_envelope") or {}) if isinstance(p.get("relay_share_envelope"), dict) else {} @@ -92,6 +106,8 @@ def build_gateway_context( "idempotencyKey": run_id, "interaction_mode": interaction_mode, "specialist": specialist, + "execution_mode": execution_mode, + "plan_agent_version": plan_agent_version, "relay_share_envelope": norm_env if ok_env else {}, "acp_parent_run_id": str(p.get("acp_parent_run_id") or ""), "acp_child_run_id": str(p.get("acp_child_run_id") or ""), diff --git a/interfaces/ws/turn_runner.py b/interfaces/ws/turn_runner.py index b26af3b9..1f7ffe94 100644 --- a/interfaces/ws/turn_runner.py +++ b/interfaces/ws/turn_runner.py @@ -89,6 +89,12 @@ async def run_agent_turn_via_bridge( accepted_ms = now_ms() msg_text = str(p.get("message") or "").strip() attachments = list(p.get("attachments") or []) + execution_mode = str(p.get("execution_mode") or "agent").strip().lower() or "agent" + if execution_mode not in {"agent", "plan"}: + execution_mode = "agent" + plan_agent_version = str(p.get("plan_agent_version") or "v1").strip().lower() or "v1" + if plan_agent_version not in {"v1", "v2"}: + plan_agent_version = "v1" store = SqliteStore(db_path()) gw = OclawGateway(store=store) @@ -190,6 +196,8 @@ async def run_agent_turn_via_bridge( metadata={ "interaction_mode": str(p.get("interaction_mode") or "comprehensive"), "selected_specialist": str(p.get("specialist") or "generalist"), + "execution_mode": execution_mode, + "plan_agent_version": plan_agent_version, "relay_share_envelope": dict(p.get("relay_share_envelope") or {}) if isinstance(p.get("relay_share_envelope"), dict) else {}, @@ -329,6 +337,7 @@ async def run_agent_turn_via_bridge( "selectedSpecialist": str(p.get("specialist") or "generalist"), "interactionMode": str(p.get("interaction_mode") or "comprehensive"), "dispatchReason": "execution_failed", + "executionMode": execution_mode, "managerSelectedSpecialist": str(p.get("specialist") or "generalist"), "requestedSpecialist": str(p.get("specialist") or "generalist"), "dynamicAgentUsed": False, @@ -464,6 +473,7 @@ async def run_agent_turn_via_bridge( "selectedSpecialist": str(getattr(result, "selected_specialist", "generalist") or "generalist"), "interactionMode": str(getattr(result, "interaction_mode", "comprehensive") or "comprehensive"), "dispatchReason": str(getattr(result, "dispatch_reason", "") or ""), + "executionMode": execution_mode, "managerSelectedSpecialist": str(getattr(result, "manager_selected_specialist", "generalist") or "generalist"), "requestedSpecialist": str(getattr(result, "requested_specialist", "generalist") or "generalist"), "dynamicAgentUsed": bool(getattr(result, "dynamic_agent_used", False) or False), diff --git a/platform/config/bootstrap_env.py b/platform/config/bootstrap_env.py new file mode 100644 index 00000000..2f55fdbe --- /dev/null +++ b/platform/config/bootstrap_env.py @@ -0,0 +1,90 @@ +"""Load optional env files before the rest of the app reads ``os.environ``. + +Only ``_local/system.env`` (next to the committed template ``_local/system.env.example``) is read. +Variables already set in the process environment are never overwritten (shell/export wins). + +新增进程环境变量时:必须在 ``_local/system.env.example`` 用中文登记说明(见该文件顶部的仓库约定)。 +""" + +from __future__ import annotations + +import os +from pathlib import Path + +_LOADED = False + + +def _project_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def _parse_env_file(path: Path) -> dict[str, str | None]: + out: dict[str, str | None] = {} + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return out + for line in text.splitlines(): + s = line.strip() + if not s or s.startswith("#"): + continue + if s.startswith("export "): + s = s[7:].strip() + if "=" not in s: + continue + k, _, rest = s.partition("=") + key = k.strip() + if not key: + continue + val = rest.strip() + if len(val) >= 2 and val[0] == val[-1] and val[0] in "\"'": + val = val[1:-1] + out[key] = val + return out + + +def load_system_env(*, force: bool = False) -> list[str]: + """Merge env files into ``os.environ`` for keys not already set. + + Returns the list of existing files that contributed (merged order). + """ + global _LOADED + if _LOADED and not force: + return [] + + root = _project_root() + candidates = [root / "_local" / "system.env"] + + merged: dict[str, str | None] = {} + dotenv_values = None + try: + from dotenv import dotenv_values as _dv # type: ignore + + dotenv_values = _dv + except ImportError: + pass + + loaded_paths: list[str] = [] + for p in candidates: + if not p.is_file(): + continue + loaded_paths.append(str(p.resolve())) + if dotenv_values is not None: + vals = dotenv_values(p) + for k, v in vals.items(): + merged[str(k)] = v + else: + merged.update(_parse_env_file(p)) + + for key, val in merged.items(): + if not key or key in os.environ: + continue + if val is None: + continue + os.environ[str(key)] = str(val) + + _LOADED = True + return loaded_paths + + +__all__ = ["load_system_env"] diff --git a/requirements.txt b/requirements.txt index 8d451742..cb708b13 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,3 +17,4 @@ pytest>=8.0.0 cryptography>=42.0.0 anthropic PyYAML>=6.0.0 +python-dotenv>=1.0.0 diff --git a/runtime/direct_loop.py b/runtime/direct_loop.py index 10eefbcb..14dd2586 100644 --- a/runtime/direct_loop.py +++ b/runtime/direct_loop.py @@ -35,6 +35,8 @@ _DIRECT_LOOP_OC_STAGE: dict[str, str] = { "tool_pairing_guard": "tool_pairing_guard", } _THINK_BLOCK_RE = re.compile(r"<(think|redacted_thinking)>\s*(.*?)\s*\s*", flags=re.IGNORECASE | re.DOTALL) +_DSML_INVOKE_NAME_RE = re.compile(r"invoke\s+name\s*=\s*['\"]([^'\"\s>]+)['\"]", flags=re.IGNORECASE) +_JSON_TOOL_NAME_RE = re.compile(r"['\"]name['\"]\s*:\s*['\"]([^'\"\s]{1,120})['\"]", flags=re.IGNORECASE) _TOOL_WIRE_CACHE_LOCK = threading.Lock() _TOOL_WIRE_CACHE: dict[str, tuple[float, list[dict[str, Any]]]] = {} _TOOL_WIRE_CACHE_TTL_SEC = 300.0 @@ -54,6 +56,16 @@ def _safe_int(raw: Any, default: int, *, min_value: int = 1, max_value: int = 2_ return min(value, max_value) +def _safe_nonneg_int(raw: Any, default: int, *, max_value: int = 2_000_000) -> int: + try: + value = int(raw) + except Exception: + return max(0, int(default)) + if value < 0: + return max(0, int(default)) + return min(value, max_value) + + def _oclaw_config_path() -> Path: raw = str(os.getenv("AIA_OCLAW_CONFIG_PATH") or "").strip() if raw: @@ -820,6 +832,163 @@ def _tool_names_for_trace(tools: list[dict[str, Any]]) -> list[str]: return out +def _chat_with_empty_body_retry( + *, + model: Any, + msgs: list[dict[str, Any]], + llm_tools: list[dict[str, Any]], + on_token: Optional[Callable[[str], None]], + on_progress: Optional[Callable[[str], None]], + progress_label: str = "oclaw: think", +) -> Any: + # Empty assistant body can occur transiently at upstream gateways. + # Retry until non-empty (bounded by retry count and total timeout). + retry_max = _safe_nonneg_int(os.getenv("AIA_EMPTY_ASSISTANT_RETRY_MAX"), 1, max_value=3) + retry_delay_ms = _safe_nonneg_int(os.getenv("AIA_EMPTY_ASSISTANT_RETRY_DELAY_MS"), 1200, max_value=15_000) + retry_total_timeout_ms = _safe_nonneg_int(os.getenv("AIA_EMPTY_ASSISTANT_RETRY_TOTAL_TIMEOUT_MS"), 30_000, max_value=300_000) + started = time.perf_counter() + retries_done = 0 + resp = model.chat(msgs, llm_tools, on_token=on_token) + while True: + content = str(getattr(resp, "content", "") or "") + tool_calls = list(getattr(resp, "tool_calls", []) or []) + textual_tool_intent = (not tool_calls) and bool(_extract_textual_tool_intent_names(content)) + if (content.strip() or tool_calls) and not textual_tool_intent: + return resp + elapsed_ms = int((time.perf_counter() - started) * 1000.0) + if retries_done >= retry_max or elapsed_ms >= retry_total_timeout_ms: + return resp + if textual_tool_intent: + if on_progress: + on_progress(f"{progress_label} retry-native-tool-calls ({retries_done + 1}/{retry_max})…") + repair_msgs = list(msgs) + [ + { + "role": "system", + "content": ( + "Do not output textual tool intent/templates (DSML/XML/JSON). " + "If a tool is needed, return native tool_calls only." + ), + } + ] + retries_done += 1 + resp = model.chat(repair_msgs, llm_tools, on_token=on_token) + continue + if on_progress: + on_progress(f"{progress_label} retry-empty ({retries_done + 1}/{retry_max})…") + if retry_delay_ms > 0: + time.sleep(float(retry_delay_ms) / 1000.0) + retries_done += 1 + resp = model.chat(msgs, llm_tools, on_token=on_token) + + +def _extract_dsml_invoke_names(text: str) -> list[str]: + raw = str(text or "") + if not raw: + return [] + out: list[str] = [] + seen: set[str] = set() + for m in _DSML_INVOKE_NAME_RE.finditer(raw): + nm = str(m.group(1) or "").strip() + if not nm or nm in seen: + continue + seen.add(nm) + out.append(nm) + if len(out) >= 8: + break + return out + + +def _extract_textual_tool_intent_names(text: str) -> list[str]: + raw = str(text or "") + if not raw: + return [] + lower = raw.lower() + marker_hit = ("tool_calls" in lower) or ("invoke name" in lower) or ("parameter name" in lower) + if not marker_hit: + return [] + out: list[str] = [] + seen: set[str] = set() + for nm in _extract_dsml_invoke_names(raw): + key = str(nm or "").strip() + if key and key not in seen: + seen.add(key) + out.append(key) + if len(out) < 8: + for m in _JSON_TOOL_NAME_RE.finditer(raw): + nm = str(m.group(1) or "").strip() + if not nm or nm in seen: + continue + seen.add(nm) + out.append(nm) + if len(out) >= 8: + break + if out: + return out + return ["unknown_tool"] + + +def _persist_dsml_protocol_mismatch_step( + *, + store: Any, + session_id: str, + turn_uuid: str, + assistant_text: str, + invoke_names: list[str], +) -> _LoopStepResult: + names = [str(x or "").strip() for x in (invoke_names or []) if str(x or "").strip()] + if not names: + names = ["unknown_tool"] + stored_tool_calls: list[dict[str, Any]] = [] + for nm in names: + stored_tool_calls.append( + { + "id": f"call_dsml_{uuid.uuid4().hex}", + "name": nm, + "arguments": {}, + "thought_signature": None, + } + ) + assistant_row = store.add_message( + session_id=session_id, + role="assistant", + content="", + tool_calls=stored_tool_calls, + turn_uuid=turn_uuid, + event_type="tool_call", + event_payload={ + "protocol_mismatch": "textual_tool_intent", + "raw_excerpt": str(assistant_text or "")[:2000], + }, + ) + for tc in stored_tool_calls: + tcid = str(tc.get("id") or "").strip() + tname = str(tc.get("name") or "").strip() or "unknown_tool" + tool_result = { + "ok": False, + "error_code": "model_protocol_mismatch_dsml", + "error": "model_returned_textual_tool_intent_instead_of_native_tool_calls", + "detail": {"tool_name": tname}, + } + store.add_message( + session_id=session_id, + role="tool", + content=_json_dumps_safe(tool_result), + tool_calls={ + "tool_call_id": tcid, + "name": tname, + "assistant_message_id": int(getattr(assistant_row, "id", 0) or 0), + }, + turn_uuid=turn_uuid, + event_type="tool_result", + event_payload={"tool_name": tname, "protocol_mismatch": "textual_tool_intent"}, + ) + return _LoopStepResult( + assistant_text="", + llm_tool_calls=[], + assistant_msg_id=int(getattr(assistant_row, "id", 0) or 0), + ) + + def _persist_assistant_step( *, store: Any, @@ -843,10 +1012,8 @@ def _persist_assistant_step( reasoning_chunks, assistant_body = _split_reasoning_and_body(assistant_text, explicit_reasoning=reasoning_text) reasoning_full = "\n".join([str(x or "").strip() for x in reasoning_chunks if str(x or "").strip()]).strip() - if not str(assistant_body or "").strip() and not stored_tool_calls: - # Provider/model can occasionally return an empty body; persist a visible stub - # so UI doesn't look "stuck" and operators can diagnose from history. - assistant_body = "(空响应)模型返回了空内容,请重试一次;若持续出现,请检查模型网关/上游返回。" + # Keep empty body as-is when model returns nothing and there are no tool calls. + # The UI should treat this as an invisible intermediate/final empty response. if not thinking_mode_enabled: for idx, chunk in enumerate(reasoning_chunks): store.add_message( @@ -1022,20 +1189,37 @@ def run_oclaw_direct_loop( lang=lang, wire_policy_role=wire_policy_role, ) - resp = model.chat(msgs, llm_tools, on_token=on_token) + resp = _chat_with_empty_body_retry( + model=model, + msgs=msgs, + llm_tools=llm_tools, + on_token=on_token, + on_progress=on_progress, + progress_label="oclaw: think", + ) assistant_text = str(getattr(resp, "content", "") or "") reasoning_text = str(getattr(resp, "reasoning_content", "") or "") llm_tool_calls = list(getattr(resp, "tool_calls", []) or []) + textual_tool_intent_names = _extract_textual_tool_intent_names(assistant_text) if not llm_tool_calls else [] - step = _persist_assistant_step( - store=store, - session_id=session_id, - turn_uuid=turn_uuid, - assistant_text=assistant_text, - reasoning_text=reasoning_text, - llm_tool_calls=llm_tool_calls, - thinking_mode_enabled=bool(getattr(model, "thinking_mode_enabled", False)), - ) + if textual_tool_intent_names: + step = _persist_dsml_protocol_mismatch_step( + store=store, + session_id=session_id, + turn_uuid=turn_uuid, + assistant_text=assistant_text, + invoke_names=textual_tool_intent_names, + ) + else: + step = _persist_assistant_step( + store=store, + session_id=session_id, + turn_uuid=turn_uuid, + assistant_text=assistant_text, + reasoning_text=reasoning_text, + llm_tool_calls=llm_tool_calls, + thinking_mode_enabled=bool(getattr(model, "thinking_mode_enabled", False)), + ) final_text = step.assistant_text if not step.llm_tool_calls: break @@ -1109,7 +1293,14 @@ def run_oclaw_direct_loop( active_turn_uuid=turn_uuid, ) # Final pass forbids extra tool calls; model must synthesize answer. - resp = model.chat(msgs, [], on_token=on_token) + resp = _chat_with_empty_body_retry( + model=model, + msgs=msgs, + llm_tools=[], + on_token=on_token, + on_progress=on_progress, + progress_label="oclaw: finalize", + ) step = _persist_assistant_step( store=store, session_id=session_id, diff --git a/runtime/gateway.py b/runtime/gateway.py index 9a8d4c7d..6b7cc556 100644 --- a/runtime/gateway.py +++ b/runtime/gateway.py @@ -36,6 +36,7 @@ from oclaw.runtime.worker import ensure_worker_started from oclaw.runtime.orchestration.trace import new_span_id, new_trace_id from oclaw.runtime.chat.tool_runtime import compact_turn_tool_messages_for_storage from oclaw.runtime.chat.model_path_audit import ensure_no_tool_or_embedded_image_payload +from oclaw.runtime.tools.base import ToolRegistry from oclaw.runtime.tools.local_sdk import local_adapter_startup_self_check logger = logging.getLogger(__name__) @@ -951,6 +952,92 @@ class OclawGateway: except Exception: selected_executor = executor + system_prompt_override = "" + tools_override = None + if interaction_mode == "expert": + from oclaw.runtime.plan_agent_v2.gateway_adapter import evaluate_gateway_expert_turn_shadow + from oclaw.runtime.plan_agent_v2.tool_specs import DEFAULT_SESSION_KEY, materialize_plan_mode_v2_tools + + execution_mode = str(base_metadata.get("execution_mode") or "agent").strip().lower() + if execution_mode not in {"agent", "plan"}: + execution_mode = "agent" + try: + self.store.set_setting(DEFAULT_SESSION_KEY, str(msg.session_id or "")) + except Exception: + pass + # Respect store setting AIA_EXPERT_PLAN_AGENT_V2_ENABLED (default off); do not force cutover. + shadow = evaluate_gateway_expert_turn_shadow( + store=self.store, + msg=msg, + lang=lang, + interaction_mode=interaction_mode, + requested_specialist=requested_specialist, + execution_mode=execution_mode, + base_system_prompt=str(getattr(selected_executor, "system_prompt", "") or ""), + force_flag=False, + trace_id=trace_id, + parent_span_id=None, + ) + if shadow.used_v2 and shadow.decision is not None: + action = str(shadow.decision.action or "") + if action in {"enter_plan", "stay_plan"}: + elapsed_ms = int((time.perf_counter() - t0) * 1000) + _trace_local( + event_type="response_sent", + payload={"ok": True, "elapsed_ms": elapsed_ms, "mode": "sync_direct", "plan_action": action}, + started_at=t0, + ) + _flush_trace_rows() + return OclawGatewayResult( + run_id=rid, + reply_text=str(shadow.decision.reply_text or ""), + trace_id=trace_id, + elapsed_ms=elapsed_ms, + mode="sync_direct", + selected_specialist=requested_specialist, + interaction_mode=interaction_mode, + dispatch_reason=f"plan_agent_v2:{action}", + manager_selected_specialist=requested_specialist, + requested_specialist=requested_specialist, + dynamic_agent_used=False, + dynamic_agent_name="", + relay_pointer_count=int(relay_stats.get("relay_pointer_count") or 0), + relay_envelope_present=bool(relay_stats.get("relay_envelope_present")), + relay_envelope_pointer_count=int(relay_stats.get("relay_envelope_pointer_count") or 0), + relay_ttl_turn_count=int(ttl_stats.get("turn") or 0), + relay_ttl_session_count=int(ttl_stats.get("session") or 0), + relay_ttl_keep_count=int(ttl_stats.get("keep") or 0), + ) + if action == "run_agent": + system_prompt_override = str(shadow.decision.system_prompt_override or "") + exec_tools = getattr(selected_executor, "tools", None) + if isinstance(exec_tools, ToolRegistry): + merged = ToolRegistry(exec_tools.list() + materialize_plan_mode_v2_tools(store=self.store)) + tools_override = merged + _trace_local( + event_type="plan_mode_tools_augmented", + payload={"base_count": len(exec_tools.list()), "merged_count": len(merged.list())}, + started_at=t0, + ) + try: + plan_mode = str((shadow.decision.plan_state or {}).get("mode") or "").strip().lower() + except Exception: + plan_mode = "" + if plan_mode == "plan": + from oclaw.runtime.plan_agent_v2.tool_policy import filter_tools_for_mode + + if isinstance(tools_override, ToolRegistry): + filtered = filter_tools_for_mode(registry=tools_override, mode="plan") + tools_override = ToolRegistry(filtered) + _trace_local( + event_type="plan_mode_tools_filtered", + payload={ + "before_count": len(merged.list()) if isinstance(exec_tools, ToolRegistry) else len(filtered), + "after_count": len(filtered), + }, + started_at=t0, + ) + route_mode = "sync_direct" route_msg = StandardMessage( session_id=msg.session_id, @@ -1064,10 +1151,10 @@ class OclawGateway: ) try: model = getattr(selected_executor, "model", None) - tools = getattr(selected_executor, "tools", None) + tools = tools_override if tools_override is not None else getattr(selected_executor, "tools", None) if model is None or tools is None: raise RuntimeError("executor missing model/tools") - sys_prompt = str(getattr(selected_executor, "system_prompt", "") or "") + sys_prompt = system_prompt_override or str(getattr(selected_executor, "system_prompt", "") or "") if self._has_tabular_ref_attachments(msg): sys_prompt = f"{sys_prompt}\n\n{self._tabular_query_system_hint(lang)}".strip() if self._has_text_ref_attachments(msg): diff --git a/runtime/plan_agent_v2/__init__.py b/runtime/plan_agent_v2/__init__.py new file mode 100644 index 00000000..af0fe6e9 --- /dev/null +++ b/runtime/plan_agent_v2/__init__.py @@ -0,0 +1,42 @@ +from .adapter import PlanAgentV2Decision, evaluate_for_expert_mode +from .compat import build_shadow_gateway_result, legacy_gateway_result_keys +from .gateway_adapter import GatewayPlanV2AdapterOutput, evaluate_gateway_expert_turn_shadow +from .manager import PlanModeManagerV2 +from .models import PLAN_MODE_NORMAL, PLAN_MODE_PLAN, PlanAgentStateV2 +from .prompt_injector import build_plan_mode_prefix, inject_plan_context +from .state_store import PlanAgentStateStoreV2 +from .switch import should_route_to_v2, v2_feature_enabled +from .tool_policy import filter_tools_for_mode, plan_mode_allowed_tool_names +from .tool_specs import ( + enter_plan_mode_v2_tool, + exit_plan_mode_v2_tool, + is_plan_mode_v2_active, + materialize_plan_mode_v2_tools, +) +from .trace import emit_plan_agent_v2_trace + +__all__ = [ + "PLAN_MODE_NORMAL", + "PLAN_MODE_PLAN", + "PlanAgentStateV2", + "PlanAgentStateStoreV2", + "PlanModeManagerV2", + "build_plan_mode_prefix", + "inject_plan_context", + "filter_tools_for_mode", + "plan_mode_allowed_tool_names", + "enter_plan_mode_v2_tool", + "exit_plan_mode_v2_tool", + "materialize_plan_mode_v2_tools", + "is_plan_mode_v2_active", + "v2_feature_enabled", + "should_route_to_v2", + "PlanAgentV2Decision", + "evaluate_for_expert_mode", + "GatewayPlanV2AdapterOutput", + "evaluate_gateway_expert_turn_shadow", + "emit_plan_agent_v2_trace", + "legacy_gateway_result_keys", + "build_shadow_gateway_result", +] + diff --git a/runtime/plan_agent_v2/adapter.py b/runtime/plan_agent_v2/adapter.py new file mode 100644 index 00000000..86ef7929 --- /dev/null +++ b/runtime/plan_agent_v2/adapter.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from .manager import PlanModeManagerV2 +from .models import PLAN_MODE_PLAN +from .prompt_injector import build_plan_mode_prefix, inject_plan_context +from .trace import emit_plan_agent_v2_trace + + +@dataclass(frozen=True) +class PlanAgentV2Decision: + action: str # enter_plan | stay_plan | run_agent + reply_text: str + plan_state: dict[str, Any] + system_prompt_override: str = "" + + +def _is_confirm_text(text: str) -> bool: + t = str(text or "").strip().lower() + return t in {"确认", "确认计划", "同意", "通过", "approve", "approved", "confirm", "yes"} + + +def _normalize_user_text(text: str) -> str: + return " ".join(str(text or "").strip().lower().split()) + + +def _is_low_signal_continue(text_norm: str) -> bool: + t = str(text_norm or "").strip().lower() + return t in { + "继续", + "继续啊", + "继续吧", + "可以", + "好的", + "好", + "ok", + "okay", + "go on", + "continue", + } + + +def _confirm_strategy(store: Any) -> str: + try: + raw = str(store.get_setting("AIA_EXPERT_PLAN_CONFIRM_STRATEGY") or "").strip().lower() + except Exception: + raw = "" + if raw in {"auto", "strict", "off"}: + return raw + return "strict" + + +def _last_user_text_norm_from_history(*, store: Any, session_id: str) -> str: + """Most recent persisted user message (current turn is usually not persisted yet).""" + try: + msgs = store.get_messages(session_id=session_id, limit=120) + except Exception: + return "" + for m in reversed(msgs): + if str(getattr(m, "role", "") or "").strip().lower() == "user": + return _normalize_user_text(str(getattr(m, "content", "") or "")) + return "" + + +def _agent_conversation_stall_suffix(*, lang: str) -> str: + is_en = str(lang or "").startswith("en") + if is_en: + return ( + "\n\n[Conversation stall guard — agent mode]\n" + "The user's latest message matches their previous user message in this session.\n" + "- Do not repeat your last assistant reply or restate \"I will now…\" boilerplate.\n" + "- Make substantive progress: execute the next concrete tool step, produce new actionable output, " + "or ask exactly one specific blocking question.\n" + ) + return ( + "\n\n【对话停滞防护 · agent 模式】\n" + "检测到用户本条输入与上一轮用户输入相同(会话已持久化部分)。\n" + "- 禁止复述上一轮助手回复或重复「接下来我将…」式独白。\n" + "- 必须给出实质进展:执行具体工具步骤、写出新的可执行结果,或只提一个关键追问。\n" + ) + + +def evaluate_for_expert_mode( + *, + store: Any, + session_id: str, + lang: str, + requested_specialist: str, + user_text: str, + execution_mode: str = "agent", + base_system_prompt: str, + trace_id: str | None = None, + parent_span_id: str | None = None, +) -> PlanAgentV2Decision: + mgr = PlanModeManagerV2(store=store) + st = mgr.load_state(session_id=session_id) + txt = str(user_text or "").strip() + txt_norm = _normalize_user_text(txt) + exec_mode = str(execution_mode or "").strip().lower() + if exec_mode not in {"agent", "plan"}: + exec_mode = "plan" + confirm_strategy = _confirm_strategy(store) + + if exec_mode == "agent" and st.mode != PLAN_MODE_PLAN: + emit_plan_agent_v2_trace( + store=store, + session_id=session_id, + trace_id=trace_id, + parent_span_id=parent_span_id, + event_type="plan_mode_bypassed", + payload={"requested_mode": "agent", "plan_mode_state": str(st.mode or "")}, + ) + last_user_norm = _last_user_text_norm_from_history(store=store, session_id=session_id) + stall = bool(txt_norm and last_user_norm and txt_norm == last_user_norm) + override = "" + if stall: + emit_plan_agent_v2_trace( + store=store, + session_id=session_id, + trace_id=trace_id, + parent_span_id=parent_span_id, + event_type="agent_mode_conversation_stall", + payload={"reason": "repeated_user_message"}, + ) + base = str(base_system_prompt or "").strip() + suffix = _agent_conversation_stall_suffix(lang=lang).strip() + override = f"{base}\n\n{suffix}".strip() + return PlanAgentV2Decision( + action="run_agent", + reply_text="", + plan_state=st.to_dict(), + system_prompt_override=override, + ) + + if st.mode != PLAN_MODE_PLAN: + entered = mgr.enter(session_id=session_id, owner_specialist=requested_specialist, force_new_plan=False) + emit_plan_agent_v2_trace( + store=store, + session_id=session_id, + trace_id=trace_id, + parent_span_id=parent_span_id, + event_type="plan_mode_entered", + payload={"owner_specialist": entered.owner_specialist, "plan_id": entered.plan_id}, + ) + prefix = build_plan_mode_prefix(state=entered, lang=lang) + return PlanAgentV2Decision( + action="run_agent", + reply_text="", + plan_state=entered.to_dict(), + system_prompt_override=f"{prefix}\n\n{str(base_system_prompt or '').strip()}".strip(), + ) + + st = mgr.refresh_plan_content(session_id=session_id) + st = mgr.update_loop_guard(session_id=session_id, user_text_norm=txt_norm) + if _is_confirm_text(txt): + if exec_mode != "agent" and confirm_strategy == "strict": + emit_plan_agent_v2_trace( + store=store, + session_id=session_id, + trace_id=trace_id, + parent_span_id=parent_span_id, + event_type="plan_mode_confirm_blocked", + payload={ + "reason": "execution_mode_not_agent", + "requested_mode": exec_mode, + "confirm_strategy": confirm_strategy, + }, + ) + blocked_reply = ( + "Plan is ready. Please switch to agent mode, then confirm to execute." + if str(lang or "").startswith("en") + else "计划已就绪。请先切换到 agent 模式,再回复“确认”开始执行。" + ) + return PlanAgentV2Decision( + action="stay_plan", + reply_text=blocked_reply, + plan_state=st.to_dict(), + system_prompt_override="", + ) + if exec_mode != "agent" and confirm_strategy == "auto": + emit_plan_agent_v2_trace( + store=store, + session_id=session_id, + trace_id=trace_id, + parent_span_id=parent_span_id, + event_type="plan_mode_confirm_auto_switched", + payload={"from_mode": exec_mode, "to_mode": "agent", "confirm_strategy": confirm_strategy}, + ) + confirmed = mgr.confirm(session_id=session_id) + emit_plan_agent_v2_trace( + store=store, + session_id=session_id, + trace_id=trace_id, + parent_span_id=parent_span_id, + event_type="plan_mode_confirmed", + payload={ + "plan_id": confirmed.plan_id, + "plan_confirmed": bool(confirmed.plan_confirmed), + "confirm_strategy": confirm_strategy, + }, + ) + next_system = inject_plan_context(base_system=base_system_prompt, state=confirmed, lang=lang) + reply = mgr.build_approved_execution_message(state=confirmed, lang=lang) + return PlanAgentV2Decision( + action="run_agent", + reply_text=reply, + plan_state=confirmed.to_dict(), + system_prompt_override=next_system, + ) + + if _is_low_signal_continue(txt_norm): + emit_plan_agent_v2_trace( + store=store, + session_id=session_id, + trace_id=trace_id, + parent_span_id=parent_span_id, + event_type="plan_mode_active", + payload={"plan_id": st.plan_id, "plan_path": st.plan_path, "loop_guard": "low_signal_continue"}, + ) + low_signal_reply = ( + "Plan mode detected a low-information continuation. " + "Please provide concrete plan adjustments, or switch to agent mode and reply 'confirm' to execute." + if str(lang or "").startswith("en") + else "检测到低信息续写(如“继续/可以”)。请给出具体计划修改点,或切换到 agent 模式后回复“确认”直接执行。" + ) + return PlanAgentV2Decision( + action="stay_plan", + reply_text=low_signal_reply, + plan_state=st.to_dict(), + system_prompt_override="", + ) + + if int(st.plan_loop_count or 0) >= 2: + emit_plan_agent_v2_trace( + store=store, + session_id=session_id, + trace_id=trace_id, + parent_span_id=parent_span_id, + event_type="plan_mode_active", + payload={"plan_id": st.plan_id, "plan_path": st.plan_path, "loop_guard": "hard_block"}, + ) + anti_loop_reply = ( + "I am in plan mode. I will only output a concise executable plan. " + "If you want me to execute, switch to agent mode and reply 'confirm'." + if str(lang or "").startswith("en") + else "当前为 plan 模式,我只输出可执行计划。若要开始执行,请切换到 agent 模式并回复“确认”。" + ) + return PlanAgentV2Decision( + action="stay_plan", + reply_text=anti_loop_reply, + plan_state=st.to_dict(), + system_prompt_override="", + ) + + prefix = build_plan_mode_prefix(state=st, lang=lang) + anti_loop_suffix = ( + "\n\n[Anti-loop guard]\n" + "- Do not repeat the previous response.\n" + "- If user asks similarly, refine with more concrete steps, checks, and fallback.\n" + "- Keep output as plan only; do not pretend execution is complete." + ) + emit_plan_agent_v2_trace( + store=store, + session_id=session_id, + trace_id=trace_id, + parent_span_id=parent_span_id, + event_type="plan_mode_active", + payload={"plan_id": st.plan_id, "plan_path": st.plan_path, "loop_count": int(st.plan_loop_count or 0)}, + ) + return PlanAgentV2Decision( + action="run_agent", + reply_text="", + plan_state=st.to_dict(), + system_prompt_override=f"{prefix}{anti_loop_suffix}\n\n{str(base_system_prompt or '').strip()}".strip(), + ) + + +__all__ = ["PlanAgentV2Decision", "evaluate_for_expert_mode"] + diff --git a/runtime/plan_agent_v2/compat.py b/runtime/plan_agent_v2/compat.py new file mode 100644 index 00000000..d18bab92 --- /dev/null +++ b/runtime/plan_agent_v2/compat.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from typing import Any + +from .adapter import PlanAgentV2Decision +from oclaw.runtime.gateway import OclawGatewayResult + + +def legacy_gateway_result_keys() -> set[str]: + return set(OclawGatewayResult.__dataclass_fields__.keys()) + + +def build_shadow_gateway_result( + *, + decision: PlanAgentV2Decision, + run_id: str, + trace_id: str, + elapsed_ms: int, + requested_specialist: str, +) -> dict[str, Any]: + return { + "run_id": str(run_id), + "reply_text": str(decision.reply_text or ""), + "trace_id": str(trace_id), + "elapsed_ms": int(elapsed_ms), + "mode": "sync_direct", + "task_id": None, + "selected_specialist": str((decision.plan_state or {}).get("owner_specialist") or requested_specialist or "generalist"), + "interaction_mode": "expert", + "dispatch_reason": f"plan_agent_v2:{decision.action}", + "manager_selected_specialist": str((decision.plan_state or {}).get("owner_specialist") or requested_specialist or "generalist"), + "requested_specialist": str(requested_specialist or "generalist"), + "dynamic_agent_used": False, + "dynamic_agent_name": "", + "relay_pointer_count": 0, + "relay_envelope_present": False, + "relay_envelope_pointer_count": 0, + "relay_ttl_turn_count": 0, + "relay_ttl_session_count": 0, + "relay_ttl_keep_count": 0, + } + + +__all__ = ["build_shadow_gateway_result", "legacy_gateway_result_keys"] + diff --git a/runtime/plan_agent_v2/gateway_adapter.py b/runtime/plan_agent_v2/gateway_adapter.py new file mode 100644 index 00000000..e5f6c3bf --- /dev/null +++ b/runtime/plan_agent_v2/gateway_adapter.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from .adapter import PlanAgentV2Decision, evaluate_for_expert_mode +from .switch import should_route_to_v2 +from oclaw.runtime.types import StandardMessage + + +@dataclass(frozen=True) +class GatewayPlanV2AdapterOutput: + used_v2: bool + decision: PlanAgentV2Decision | None + + +def evaluate_gateway_expert_turn_shadow( + *, + store: Any, + msg: StandardMessage, + lang: str, + interaction_mode: str, + requested_specialist: str, + execution_mode: str = "", + base_system_prompt: str, + force_flag: bool = False, + trace_id: str | None = None, + parent_span_id: str | None = None, +) -> GatewayPlanV2AdapterOutput: + if not should_route_to_v2(store=store, interaction_mode=interaction_mode, force_flag=force_flag): + return GatewayPlanV2AdapterOutput(used_v2=False, decision=None) + meta = msg.metadata if isinstance(msg.metadata, dict) else {} + if "plan_agent_version" in meta: + if str(meta.get("plan_agent_version") or "").strip().lower() != "v2": + return GatewayPlanV2AdapterOutput(used_v2=False, decision=None) + eff_mode = str(execution_mode or "").strip().lower() + if eff_mode not in {"agent", "plan"}: + eff_mode = "plan" if force_flag else "agent" + dec = evaluate_for_expert_mode( + store=store, + session_id=str(msg.session_id or ""), + lang=lang, + requested_specialist=requested_specialist, + user_text=str(msg.text or ""), + execution_mode=eff_mode, + base_system_prompt=base_system_prompt, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + return GatewayPlanV2AdapterOutput(used_v2=True, decision=dec) + + +__all__ = ["GatewayPlanV2AdapterOutput", "evaluate_gateway_expert_turn_shadow"] + diff --git a/runtime/plan_agent_v2/manager.py b/runtime/plan_agent_v2/manager.py new file mode 100644 index 00000000..efb0d6cf --- /dev/null +++ b/runtime/plan_agent_v2/manager.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import time +import uuid +from pathlib import Path +from typing import Any + +from .models import PLAN_MODE_NORMAL, PLAN_MODE_PLAN, PlanAgentStateV2 +from .state_store import PlanAgentStateStoreV2 + + +def _default_plan_dir() -> Path: + return Path(__file__).resolve().parents[2] / "data" / "plans" + + +def _resolve_plan_dir(store: Any) -> Path: + raw = str(store.get_setting("AIA_EXPERT_PLAN_FILE_DIR") or "").strip() + if raw: + p = Path(raw) + return p if p.is_absolute() else (Path(__file__).resolve().parents[2] / p) + return _default_plan_dir() + + +def _plan_template() -> str: + return ( + "# Plan\n\n" + "## Goal\n" + "- \n\n" + "## Scope\n" + "- \n\n" + "## Steps\n" + "1. \n" + "2. \n" + "3. \n\n" + "## Risks\n" + "- \n\n" + "## Acceptance\n" + "- \n" + ) + + +class PlanModeManagerV2: + def __init__(self, *, store: Any): + self._store = store + self._state_store = PlanAgentStateStoreV2(store) + + def load_state(self, *, session_id: str) -> PlanAgentStateV2: + return self._state_store.load(session_id=session_id) + + def enter( + self, + *, + session_id: str, + owner_specialist: str, + force_new_plan: bool = False, + ) -> PlanAgentStateV2: + prev = self._state_store.load(session_id=session_id) + if prev.mode == PLAN_MODE_PLAN and not force_new_plan: + return prev + sid = str(session_id or "").strip() + if not sid: + return prev + plan_id = uuid.uuid4().hex + plan_root = _resolve_plan_dir(self._store) / sid + plan_root.mkdir(parents=True, exist_ok=True) + plan_path = plan_root / f"{plan_id}.md" + plan_content = _plan_template() + plan_path.write_text(plan_content, encoding="utf-8") + now_ms = int(time.time() * 1000) + next_state = PlanAgentStateV2( + mode=PLAN_MODE_PLAN, + owner_specialist=str(owner_specialist or "generalist").strip().lower() or "generalist", + plan_id=plan_id, + plan_path=str(plan_path), + plan_content=plan_content, + plan_confirmed=False, + entered_at_ms=now_ms, + updated_at_ms=now_ms, + last_user_text_norm="", + plan_loop_count=0, + ) + return self._state_store.save(session_id=sid, state=next_state) + + def refresh_plan_content(self, *, session_id: str) -> PlanAgentStateV2: + st = self._state_store.load(session_id=session_id) + p = Path(str(st.plan_path or "").strip()) + if not p.exists() or not p.is_file(): + return st + content = p.read_text(encoding="utf-8", errors="replace") + return self._state_store.save( + session_id=session_id, + state=PlanAgentStateV2( + mode=st.mode, + owner_specialist=st.owner_specialist, + plan_id=st.plan_id, + plan_path=st.plan_path, + plan_content=content, + plan_confirmed=st.plan_confirmed, + entered_at_ms=st.entered_at_ms, + updated_at_ms=st.updated_at_ms, + last_user_text_norm=st.last_user_text_norm, + plan_loop_count=st.plan_loop_count, + ), + ) + + def update_loop_guard(self, *, session_id: str, user_text_norm: str) -> PlanAgentStateV2: + st = self._state_store.load(session_id=session_id) + nxt_count = int(st.plan_loop_count or 0) + 1 if user_text_norm and user_text_norm == st.last_user_text_norm else 0 + return self._state_store.save( + session_id=session_id, + state=PlanAgentStateV2( + mode=st.mode, + owner_specialist=st.owner_specialist, + plan_id=st.plan_id, + plan_path=st.plan_path, + plan_content=st.plan_content, + plan_confirmed=st.plan_confirmed, + entered_at_ms=st.entered_at_ms, + updated_at_ms=st.updated_at_ms, + last_user_text_norm=user_text_norm, + plan_loop_count=nxt_count, + ), + ) + + def confirm(self, *, session_id: str) -> PlanAgentStateV2: + st = self.refresh_plan_content(session_id=session_id) + return self._state_store.save( + session_id=session_id, + state=PlanAgentStateV2( + mode=PLAN_MODE_NORMAL, + owner_specialist=st.owner_specialist, + plan_id=st.plan_id, + plan_path=st.plan_path, + plan_content=st.plan_content, + plan_confirmed=True, + entered_at_ms=st.entered_at_ms, + updated_at_ms=st.updated_at_ms, + last_user_text_norm="", + plan_loop_count=0, + ), + ) + + def build_approved_execution_message(self, *, state: PlanAgentStateV2, lang: str) -> str: + is_en = str(lang or "").startswith("en") + plan_path = str(state.plan_path or "").strip() or "unknown" + plan_content = str(state.plan_content or "").strip() + if plan_content: + if is_en: + return ( + "User has approved your plan. You can now start implementation.\n\n" + f"Plan file: {plan_path}\n\n" + f"## Approved Plan\n{plan_content}" + ) + return ( + "用户已确认计划,你可以开始执行实现。\n\n" + f"计划文件:{plan_path}\n\n" + f"## 已确认计划\n{plan_content}" + ) + return ( + f"Plan approved. You can now start implementation. Plan file: {plan_path}" + if is_en + else f"计划已确认,你可以开始执行实现。计划文件:{plan_path}" + ) + + def exit_without_confirm(self, *, session_id: str) -> PlanAgentStateV2: + st = self._state_store.load(session_id=session_id) + return self._state_store.save( + session_id=session_id, + state=PlanAgentStateV2( + mode=PLAN_MODE_NORMAL, + owner_specialist=st.owner_specialist, + plan_id=st.plan_id, + plan_path=st.plan_path, + plan_content=st.plan_content, + plan_confirmed=False, + entered_at_ms=st.entered_at_ms, + updated_at_ms=st.updated_at_ms, + last_user_text_norm="", + plan_loop_count=0, + ), + ) + + +__all__ = ["PlanModeManagerV2"] + diff --git a/runtime/plan_agent_v2/models.py b/runtime/plan_agent_v2/models.py new file mode 100644 index 00000000..3c17545e --- /dev/null +++ b/runtime/plan_agent_v2/models.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Any + + +PLAN_MODE_NORMAL = "normal" +PLAN_MODE_PLAN = "plan" +_VALID_MODES = {PLAN_MODE_NORMAL, PLAN_MODE_PLAN} + + +@dataclass(frozen=True) +class PlanAgentStateV2: + mode: str = PLAN_MODE_NORMAL + owner_specialist: str = "generalist" + plan_id: str = "" + plan_path: str = "" + plan_content: str = "" + plan_confirmed: bool = False + entered_at_ms: int = 0 + updated_at_ms: int = 0 + last_user_text_norm: str = "" + plan_loop_count: int = 0 + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @staticmethod + def from_dict(raw: dict[str, Any] | None) -> "PlanAgentStateV2": + obj = raw if isinstance(raw, dict) else {} + mode = str(obj.get("mode") or PLAN_MODE_NORMAL).strip().lower() + if mode not in _VALID_MODES: + mode = PLAN_MODE_NORMAL + return PlanAgentStateV2( + mode=mode, + owner_specialist=str(obj.get("owner_specialist") or "generalist").strip().lower() or "generalist", + plan_id=str(obj.get("plan_id") or "").strip(), + plan_path=str(obj.get("plan_path") or "").strip(), + plan_content=str(obj.get("plan_content") or ""), + plan_confirmed=bool(obj.get("plan_confirmed")), + entered_at_ms=int(obj.get("entered_at_ms") or 0), + updated_at_ms=int(obj.get("updated_at_ms") or 0), + last_user_text_norm=str(obj.get("last_user_text_norm") or "").strip().lower(), + plan_loop_count=int(obj.get("plan_loop_count") or 0), + ) + + +__all__ = ["PLAN_MODE_NORMAL", "PLAN_MODE_PLAN", "PlanAgentStateV2"] + diff --git a/runtime/plan_agent_v2/prompt_injector.py b/runtime/plan_agent_v2/prompt_injector.py new file mode 100644 index 00000000..62c965ce --- /dev/null +++ b/runtime/plan_agent_v2/prompt_injector.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from pathlib import Path + +from .models import PLAN_MODE_PLAN, PlanAgentStateV2 + + +def _plan_file_info(state: PlanAgentStateV2) -> str: + plan_path = str(state.plan_path or "").strip() + if not plan_path: + return "No plan file path is available yet." + p = Path(plan_path) + if p.exists(): + return ( + f"A plan file already exists at {plan_path}. " + "You can read it and make incremental edits." + ) + return ( + f"No plan file exists yet. You should create your plan at {plan_path}." + ) + + +def build_plan_mode_prefix(*, state: PlanAgentStateV2, lang: str) -> str: + if state.mode != PLAN_MODE_PLAN: + return "" + is_en = str(lang or "").startswith("en") + file_info = _plan_file_info(state) + if is_en: + return ( + "Plan mode is active. The user does not want execution yet.\n" + "You MUST NOT make real project edits, run non-readonly tools, or claim implementation is done.\n" + "## Execution discipline (critical)\n" + "- Do NOT narrate as if you will run scripts, migrate files, or touch disk *in this turn*. " + "Phrases like \"I'll write the script and run it\", \"starting migration now\", or " + "\"let me execute\" mislead the user—refuse that pattern.\n" + "- If the user needs real execution, say explicitly: switch to **agent mode** in the UI, " + "then confirm; you cannot perform execution while plan mode is active.\n" + "- Do NOT repeat the same \"next I will…\" monologue across turns. On vague follow-ups, " + "edit the plan file or ask **one** concrete question—do not restate boilerplate.\n" + "- Do not ask the user to \"approve the plan\" in chat when the product expects mode switch + " + "confirm; instead tell them the handoff: agent mode → confirm to execute.\n\n" + "## Plan File Info\n" + f"{file_info}\n" + "Only the plan file is allowed to be edited while in plan mode.\n\n" + "## Plan Workflow\n" + "### Phase 1: Initial Understanding\n" + "- Understand the request and inspect relevant codepaths.\n" + "- Reuse existing functions/utilities/patterns when possible.\n\n" + "### Phase 2: Design\n" + "- Propose a concrete implementation strategy with trade-offs.\n\n" + "### Phase 3: Review\n" + "- Validate alignment with user intent and constraints.\n" + "- Clarify unresolved requirements only when necessary.\n\n" + "### Phase 4: Final Plan\n" + "- Output sections: Context, Changes, Critical files, Verification.\n" + "- Prefer one recommended approach over listing many alternatives.\n\n" + "### Phase 5: Execution Handoff\n" + "- Ask user to switch to agent mode and confirm before execution.\n" + "- Do not execute while still in plan mode.\n\n" + "## Plan mode tools (lifecycle)\n" + "- Built-in tools `enter_plan_mode_v2` and `exit_plan_mode_v2` mirror cc-mini-style " + "Enter/Exit plan mode: they bind or release plan-mode state for this session.\n" + "- Prefer updating the plan file in place while staying in plan mode; only call " + "`enter_plan_mode_v2` with `force_new_plan: true` when the user explicitly wants a new plan document.\n" + "- When the written plan is ready for review, either keep plan mode and summarize next steps for the user, " + "or call `exit_plan_mode_v2`. Use `confirm: true` only when the user has explicitly approved executing " + "this plan; use `confirm: false` to leave plan mode without marking the plan approved for execution.\n" + "- If the user sends low-content prompts such as 'continue' or 'ok', do not repeat long boilerplate; " + "revise the plan file or ask one concrete clarification." + ) + return ( + "当前处于 plan 模式,用户暂不要求执行。\n" + "你必须不做真实项目改动、不调用非只读工具,也不要声称已经实现完成。\n" + "## 执行纪律(必须遵守)\n" + "- 禁止用「我现在写脚本并执行」「开始迁移/复制」「让我跑一下」等表述,假装本回合会动磁盘或执行命令。\n" + "- 若用户需要真实执行,必须明确说明:请在界面切换到 **agent 模式**,再按产品流程确认;" + "在 plan 模式下你无法代为执行。\n" + "- 禁止多轮重复同一套「接下来我将……」的独白;用户只说「继续/好的」时,应小幅改计划文件或只提一个具体问题," + "不要复读长模板。\n" + "- 不要用闲聊式「你同意这个计划吗?」代替产品要求的 **切 agent + 确认**;应提示用户按界面切换到 agent 模式后再确认执行。\n\n" + "## 计划文件信息\n" + f"{file_info}\n" + "在 plan 模式下,只允许围绕计划文件进行编辑。\n\n" + "## 计划工作流\n" + "### 阶段1:理解问题\n" + "- 先理解需求并检查相关代码路径。\n" + "- 优先复用现有函数、工具和既有模式。\n\n" + "### 阶段2:方案设计\n" + "- 给出可落地的实现方案,并说明关键取舍。\n\n" + "### 阶段3:对齐复核\n" + "- 核对是否满足用户目标与约束。\n" + "- 仅在必要时提出澄清问题。\n\n" + "### 阶段4:最终计划\n" + "- 输出结构:背景、改动点、关键文件、验证方式。\n" + "- 推荐一个主方案,不要只堆备选项。\n\n" + "### 阶段5:执行切换\n" + "- 明确提示用户先切换到 agent 模式并确认后再执行。\n" + "- 在 plan 模式下不要执行实现。\n\n" + "## 计划模式工具(生命周期)\n" + "- 内置工具 `enter_plan_mode_v2` 与 `exit_plan_mode_v2` 对应 cc-mini 风格的进入/退出计划模式,用于绑定或释放本会话的 plan 状态。\n" + "- 优先在 plan 模式下就地更新计划文件;仅在用户明确要求新开计划文档时,才对 `enter_plan_mode_v2` 使用 `force_new_plan: true`。\n" + "- 计划文档写完后,可继续保持 plan 模式并给用户摘要;也可调用 `exit_plan_mode_v2`。仅在用户已明确同意按该计划执行时使用 " + "`confirm: true`;若只是结束规划、尚未批准执行,使用 `confirm: false`。\n" + "- 若用户输入信息量低的续写(如「继续」「好的」),不要重复大段套话,应小幅修订计划文件或提出一个具体问题。" + ) + + +def inject_plan_context(*, base_system: str, state: PlanAgentStateV2, lang: str, max_chars: int = 3000) -> str: + plan_text = str(state.plan_content or "").strip() + if not plan_text: + return base_system + if len(plan_text) > max_chars: + plan_text = plan_text[:max_chars] + "\n..." + is_en = str(lang or "").startswith("en") + header = "Approved plan context:\n" if is_en else "已确认计划上下文:\n" + return f"{header}{plan_text}\n\n{str(base_system or '').strip()}".strip() + + +__all__ = ["build_plan_mode_prefix", "inject_plan_context"] + diff --git a/runtime/plan_agent_v2/state_store.py b/runtime/plan_agent_v2/state_store.py new file mode 100644 index 00000000..37eab080 --- /dev/null +++ b/runtime/plan_agent_v2/state_store.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import json +import time +from typing import Any + +from .models import PLAN_MODE_NORMAL, PlanAgentStateV2 + + +def _state_key(session_id: str) -> str: + return f"AIA_PLAN_AGENT_V2_STATE:{str(session_id or '').strip()}" + + +class PlanAgentStateStoreV2: + def __init__(self, store: Any): + self._store = store + + def load(self, *, session_id: str) -> PlanAgentStateV2: + sid = str(session_id or "").strip() + if not sid: + return PlanAgentStateV2() + raw = str(self._store.get_setting(_state_key(sid)) or "").strip() + if not raw: + return PlanAgentStateV2() + try: + obj = json.loads(raw) + except Exception: + return PlanAgentStateV2() + return PlanAgentStateV2.from_dict(obj if isinstance(obj, dict) else None) + + def save(self, *, session_id: str, state: PlanAgentStateV2) -> PlanAgentStateV2: + sid = str(session_id or "").strip() + if not sid: + return state + now_ms = int(time.time() * 1000) + next_state = PlanAgentStateV2( + mode=state.mode, + owner_specialist=state.owner_specialist, + plan_id=state.plan_id, + plan_path=state.plan_path, + plan_content=state.plan_content, + plan_confirmed=bool(state.plan_confirmed), + entered_at_ms=int(state.entered_at_ms or 0), + updated_at_ms=now_ms, + last_user_text_norm=str(state.last_user_text_norm or "").strip().lower(), + plan_loop_count=int(state.plan_loop_count or 0), + ) + self._store.set_setting(_state_key(sid), json.dumps(next_state.to_dict(), ensure_ascii=False)) + return next_state + + def reset(self, *, session_id: str) -> PlanAgentStateV2: + sid = str(session_id or "").strip() + if not sid: + return PlanAgentStateV2() + self._store.delete_setting(_state_key(sid)) + return PlanAgentStateV2(mode=PLAN_MODE_NORMAL) + + +__all__ = ["PlanAgentStateStoreV2"] + diff --git a/runtime/plan_agent_v2/switch.py b/runtime/plan_agent_v2/switch.py new file mode 100644 index 00000000..8070064f --- /dev/null +++ b/runtime/plan_agent_v2/switch.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import os +from typing import Any + + +def _is_truthy(raw: str | None) -> bool: + return str(raw or "").strip().lower() in {"1", "true", "yes", "on"} + + +def v2_feature_enabled(*, store: Any | None = None) -> bool: + # Default off for shadow path safety. + raw = "" + try: + if store is not None: + raw = str(store.get_setting("AIA_EXPERT_PLAN_AGENT_V2_ENABLED") or "").strip() + except Exception: + raw = "" + if not raw: + raw = str(os.getenv("AIA_EXPERT_PLAN_AGENT_V2_ENABLED") or "").strip() + if not raw: + return False + return _is_truthy(raw) + + +def should_route_to_v2(*, store: Any | None, interaction_mode: str, force_flag: bool = False) -> bool: + if str(interaction_mode or "").strip().lower() != "expert": + return False + if force_flag: + return True + return v2_feature_enabled(store=store) + + +__all__ = ["should_route_to_v2", "v2_feature_enabled"] + diff --git a/runtime/plan_agent_v2/tool_policy.py b/runtime/plan_agent_v2/tool_policy.py new file mode 100644 index 00000000..33394d6a --- /dev/null +++ b/runtime/plan_agent_v2/tool_policy.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from typing import Iterable + +from .models import PLAN_MODE_PLAN +from oclaw.runtime.tools.base import ToolRegistry, ToolSpec + + +_DEFAULT_PLAN_ALLOWLIST = frozenset( + { + "read_file", + "search_files", + "glob", + "list_directory", + "list_workspace_tree", + "search_files_context", + "system_time", + # Plan-mode control tools are non-read-only by design, but must stay callable. + "enter_plan_mode_v2", + "exit_plan_mode_v2", + } +) + + +def plan_mode_allowed_tool_names(extra_allowed: Iterable[str] | None = None) -> set[str]: + out = set(_DEFAULT_PLAN_ALLOWLIST) + for x in (extra_allowed or []): + n = str(x or "").strip() + if n: + out.add(n) + return out + + +def filter_tools_for_mode( + *, + registry: ToolRegistry, + mode: str, + extra_allowed: Iterable[str] | None = None, +) -> list[ToolSpec]: + tools = list(registry.list()) + if str(mode or "").strip().lower() != PLAN_MODE_PLAN: + return tools + allow = plan_mode_allowed_tool_names(extra_allowed=extra_allowed) + out: list[ToolSpec] = [] + for t in tools: + if t.name in allow or bool(t.is_read_only()): + out.append(t) + return out + + +__all__ = ["filter_tools_for_mode", "plan_mode_allowed_tool_names"] + diff --git a/runtime/plan_agent_v2/tool_specs.py b/runtime/plan_agent_v2/tool_specs.py new file mode 100644 index 00000000..6f873645 --- /dev/null +++ b/runtime/plan_agent_v2/tool_specs.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +from typing import Any + +from .manager import PlanModeManagerV2 +from .models import PLAN_MODE_PLAN +from .trace import emit_plan_agent_v2_trace +from oclaw.runtime.tools.base import ToolSpec + +DEFAULT_SESSION_KEY = "AIA_PLAN_AGENT_V2_DEFAULT_SESSION_ID" + + +def _emit_tool_trace( + *, + store: Any, + session_id: str, + args: dict[str, Any], + event_type: str, + payload: dict[str, Any] | None = None, +) -> None: + trace_id = str(args.get("trace_id") or "").strip() + if not trace_id: + return + parent_raw = str(args.get("parent_span_id") or "").strip() + emit_plan_agent_v2_trace( + store=store, + session_id=session_id, + trace_id=trace_id, + parent_span_id=parent_raw or None, + event_type=event_type, + payload=payload, + ) + + +def _resolve_session_id(*, store: Any, args: dict[str, Any]) -> str: + sid = str(args.get("session_id") or "").strip() + if sid: + return sid + try: + return str(store.get_setting(DEFAULT_SESSION_KEY) or "").strip() + except Exception: + return "" + + +def enter_plan_mode_v2_tool(*, store: Any) -> ToolSpec: + mgr = PlanModeManagerV2(store=store) + + def _handler(args: dict[str, Any]) -> dict[str, Any]: + session_id = _resolve_session_id(store=store, args=args) + if not session_id: + return {"ok": False, "error_code": "session_id_required", "error": "session_id_required"} + specialist = str(args.get("owner_specialist") or "generalist").strip().lower() or "generalist" + force_new = bool(args.get("force_new_plan")) + st = mgr.enter(session_id=session_id, owner_specialist=specialist, force_new_plan=force_new) + _emit_tool_trace( + store=store, + session_id=session_id, + args=args, + event_type="plan_mode_tool_enter", + payload={ + "tool": "enter_plan_mode_v2", + "owner_specialist": specialist, + "force_new_plan": force_new, + "plan_id": st.plan_id, + }, + ) + return {"ok": True, "state": st.to_dict()} + + return ToolSpec( + name="enter_plan_mode_v2", + description=( + "Enter plan mode for this session (shadow v2), cc-mini-style: binds a dedicated plan file path. " + "Use when the user wants structured planning. Set force_new_plan=true only when starting a brand-new " + "plan document; otherwise reuse the existing plan when possible. Optional trace_id / parent_span_id " + "attach observability to the current trace." + ), + parameters={ + "type": "object", + "properties": { + "session_id": {"type": "string"}, + "owner_specialist": {"type": "string"}, + "force_new_plan": {"type": "boolean", "default": False}, + "trace_id": {"type": "string"}, + "parent_span_id": {"type": "string"}, + }, + "required": [], + "additionalProperties": False, + }, + handler=_handler, + tags=frozenset({"plan_mode", "shadow_v2", "read"}), + read_only=True, + risk_level="low", + ) + + +def exit_plan_mode_v2_tool(*, store: Any) -> ToolSpec: + mgr = PlanModeManagerV2(store=store) + + def _handler(args: dict[str, Any]) -> dict[str, Any]: + session_id = _resolve_session_id(store=store, args=args) + if not session_id: + return {"ok": False, "error_code": "session_id_required", "error": "session_id_required"} + confirm = bool(args.get("confirm")) + st = mgr.confirm(session_id=session_id) if confirm else mgr.exit_without_confirm(session_id=session_id) + _emit_tool_trace( + store=store, + session_id=session_id, + args=args, + event_type="plan_mode_tool_exit", + payload={ + "tool": "exit_plan_mode_v2", + "confirmed": bool(confirm), + "plan_id": st.plan_id, + "plan_confirmed": bool(st.plan_confirmed), + }, + ) + return {"ok": True, "confirmed": bool(confirm), "state": st.to_dict()} + + return ToolSpec( + name="exit_plan_mode_v2", + description=( + "Exit plan mode for this session (shadow v2). confirm=true marks the plan as approved for execution " + "(same intent as the user confirming in agent mode). confirm=false leaves plan mode without approving " + "execution—use when ending planning without a run approval yet. Optional trace_id / parent_span_id for " + "trace correlation." + ), + parameters={ + "type": "object", + "properties": { + "session_id": {"type": "string"}, + "confirm": {"type": "boolean", "default": False}, + "trace_id": {"type": "string"}, + "parent_span_id": {"type": "string"}, + }, + "required": [], + "additionalProperties": False, + }, + handler=_handler, + tags=frozenset({"plan_mode", "shadow_v2", "write"}), + read_only=False, + risk_level="high", + ) + + +def materialize_plan_mode_v2_tools(*, store: Any) -> list[ToolSpec]: + return [enter_plan_mode_v2_tool(store=store), exit_plan_mode_v2_tool(store=store)] + + +def is_plan_mode_v2_active(*, store: Any, session_id: str) -> bool: + mgr = PlanModeManagerV2(store=store) + return mgr.load_state(session_id=session_id).mode == PLAN_MODE_PLAN + + +__all__ = [ + "enter_plan_mode_v2_tool", + "exit_plan_mode_v2_tool", + "materialize_plan_mode_v2_tools", + "is_plan_mode_v2_active", + "DEFAULT_SESSION_KEY", +] + diff --git a/runtime/plan_agent_v2/trace.py b/runtime/plan_agent_v2/trace.py new file mode 100644 index 00000000..b242ed03 --- /dev/null +++ b/runtime/plan_agent_v2/trace.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import time +from typing import Any + + +def emit_plan_agent_v2_trace( + *, + store: Any, + session_id: str, + trace_id: str | None, + parent_span_id: str | None, + event_type: str, + payload: dict[str, Any] | None = None, +) -> None: + if not str(trace_id or "").strip(): + return + merged = dict(payload or {}) + merged.setdefault("pipeline", "plan_agent_v2") + merged.setdefault("ts_ms", int(time.time() * 1000)) + try: + from oclaw.runtime.orchestration.trace import new_span_id + + store.add_trace_event( + session_id=str(session_id or ""), + trace_id=str(trace_id), + span_id=new_span_id(), + parent_span_id=parent_span_id, + event_type=str(event_type or "plan_agent_v2"), + payload=merged, + ) + except Exception: + pass + + +__all__ = ["emit_plan_agent_v2_trace"] + diff --git a/runtime/plan_agent_v2_adapter.py b/runtime/plan_agent_v2_adapter.py new file mode 100644 index 00000000..59da0316 --- /dev/null +++ b/runtime/plan_agent_v2_adapter.py @@ -0,0 +1,2 @@ +from oclaw.runtime.plan_agent_v2.adapter import * # noqa: F403 + diff --git a/runtime/plan_agent_v2_compat.py b/runtime/plan_agent_v2_compat.py new file mode 100644 index 00000000..16ec4edf --- /dev/null +++ b/runtime/plan_agent_v2_compat.py @@ -0,0 +1,2 @@ +from oclaw.runtime.plan_agent_v2.compat import * # noqa: F403 + diff --git a/runtime/plan_agent_v2_gateway_adapter.py b/runtime/plan_agent_v2_gateway_adapter.py new file mode 100644 index 00000000..71b8fb45 --- /dev/null +++ b/runtime/plan_agent_v2_gateway_adapter.py @@ -0,0 +1,2 @@ +from oclaw.runtime.plan_agent_v2.gateway_adapter import * # noqa: F403 + diff --git a/runtime/plan_agent_v2_gateway_cutover.py b/runtime/plan_agent_v2_gateway_cutover.py new file mode 100644 index 00000000..5588a486 --- /dev/null +++ b/runtime/plan_agent_v2_gateway_cutover.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import time +import uuid +from dataclasses import dataclass +from typing import Any + +from oclaw.runtime.gateway import OclawGatewayResult +from oclaw.runtime.plan_agent_v2 import ( + build_shadow_gateway_result, + evaluate_gateway_expert_turn_shadow, +) +from oclaw.runtime.types import StandardMessage + + +@dataclass(frozen=True) +class GatewayCutoverDraftOutput: + handled: bool + result: OclawGatewayResult | None + system_prompt_override: str = "" + decision_action: str = "" + + +def maybe_handle_expert_turn_v2_draft( + *, + store: Any, + msg: StandardMessage, + lang: str, + interaction_mode: str, + requested_specialist: str, + base_system_prompt: str, + force_flag: bool = False, +) -> GatewayCutoverDraftOutput: + """Draft-only helper for future gateway cutover. + + Important: + - This module is intentionally NOT wired into `runtime/gateway.py`. + - It documents and validates the minimal cutover behavior in isolation. + """ + t0 = time.perf_counter() + trace_id = str(uuid.uuid4()) + run_id = str(uuid.uuid4()) + + shadow = evaluate_gateway_expert_turn_shadow( + store=store, + msg=msg, + lang=lang, + interaction_mode=interaction_mode, + requested_specialist=requested_specialist, + base_system_prompt=base_system_prompt, + force_flag=force_flag, + trace_id=trace_id, + parent_span_id=None, + ) + if not shadow.used_v2 or shadow.decision is None: + return GatewayCutoverDraftOutput(handled=False, result=None) + + action = str(shadow.decision.action or "") + elapsed_ms = int((time.perf_counter() - t0) * 1000) + if action in {"enter_plan", "stay_plan"}: + row = build_shadow_gateway_result( + decision=shadow.decision, + run_id=run_id, + trace_id=trace_id, + elapsed_ms=elapsed_ms, + requested_specialist=requested_specialist, + ) + result = OclawGatewayResult(**row) + return GatewayCutoverDraftOutput( + handled=True, + result=result, + decision_action=action, + system_prompt_override="", + ) + + # run_agent: draft suggests continuing legacy execution with injected prompt. + return GatewayCutoverDraftOutput( + handled=False, + result=None, + decision_action=action, + system_prompt_override=str(shadow.decision.system_prompt_override or ""), + ) + + +__all__ = ["GatewayCutoverDraftOutput", "maybe_handle_expert_turn_v2_draft"] + diff --git a/runtime/plan_agent_v2_manager.py b/runtime/plan_agent_v2_manager.py new file mode 100644 index 00000000..56a3b100 --- /dev/null +++ b/runtime/plan_agent_v2_manager.py @@ -0,0 +1,2 @@ +from oclaw.runtime.plan_agent_v2.manager import * # noqa: F403 + diff --git a/runtime/plan_agent_v2_models.py b/runtime/plan_agent_v2_models.py new file mode 100644 index 00000000..a8129709 --- /dev/null +++ b/runtime/plan_agent_v2_models.py @@ -0,0 +1,2 @@ +from oclaw.runtime.plan_agent_v2.models import * # noqa: F403 + diff --git a/runtime/plan_agent_v2_prompt_injector.py b/runtime/plan_agent_v2_prompt_injector.py new file mode 100644 index 00000000..a63e1ce8 --- /dev/null +++ b/runtime/plan_agent_v2_prompt_injector.py @@ -0,0 +1,2 @@ +from oclaw.runtime.plan_agent_v2.prompt_injector import * # noqa: F403 + diff --git a/runtime/plan_agent_v2_state_store.py b/runtime/plan_agent_v2_state_store.py new file mode 100644 index 00000000..af051d9a --- /dev/null +++ b/runtime/plan_agent_v2_state_store.py @@ -0,0 +1,2 @@ +from oclaw.runtime.plan_agent_v2.state_store import * # noqa: F403 + diff --git a/runtime/plan_agent_v2_switch.py b/runtime/plan_agent_v2_switch.py new file mode 100644 index 00000000..ae3dd708 --- /dev/null +++ b/runtime/plan_agent_v2_switch.py @@ -0,0 +1,2 @@ +from oclaw.runtime.plan_agent_v2.switch import * # noqa: F403 + diff --git a/runtime/plan_agent_v2_tool_policy.py b/runtime/plan_agent_v2_tool_policy.py new file mode 100644 index 00000000..020db81d --- /dev/null +++ b/runtime/plan_agent_v2_tool_policy.py @@ -0,0 +1,2 @@ +from oclaw.runtime.plan_agent_v2.tool_policy import * # noqa: F403 + diff --git a/runtime/plan_agent_v2_tool_specs.py b/runtime/plan_agent_v2_tool_specs.py new file mode 100644 index 00000000..ef948084 --- /dev/null +++ b/runtime/plan_agent_v2_tool_specs.py @@ -0,0 +1,2 @@ +from oclaw.runtime.plan_agent_v2.tool_specs import * # noqa: F403 + diff --git a/runtime/plan_agent_v2_trace.py b/runtime/plan_agent_v2_trace.py new file mode 100644 index 00000000..140b653d --- /dev/null +++ b/runtime/plan_agent_v2_trace.py @@ -0,0 +1,2 @@ +from oclaw.runtime.plan_agent_v2.trace import * # noqa: F403 + diff --git a/tests/test_admin_chat_stream_async_task.py b/tests/test_admin_chat_stream_async_task.py index 01d78cfd..f491c77d 100644 --- a/tests/test_admin_chat_stream_async_task.py +++ b/tests/test_admin_chat_stream_async_task.py @@ -172,6 +172,46 @@ class AdminChatStreamAsyncTaskTests(unittest.TestCase): payload = json.loads(str(task.payload or "{}")) self.assertEqual(str(payload.get("selected_specialist") or ""), "generalist") + def test_user_mode_plan_version_sets_v2_feature_flag_in_store(self) -> None: + """POST /user-mode mirrors plan_agent_version to AIA_EXPERT_PLAN_AGENT_V2_ENABLED (v2→1, v1→0).""" + token = self._login() + headers = { + "authorization": f"Bearer {token}", + "accept": "application/json", + "content-type": "application/json", + } + r2 = self.client.post( + "/admin/api/chat/user-mode", + headers=headers, + json={ + "interaction_mode": "expert", + "specialist": "generalist", + "confirm_strategy": "strict", + "plan_agent_version": "v2", + }, + ) + self.assertEqual(r2.status_code, 200) + body2 = r2.json() + self.assertTrue(body2.get("ok"), body2) + self.assertTrue(body2.get("plan_agent_v2_globally_enabled"), body2) + self.assertEqual(str(self.store.get_setting("AIA_EXPERT_PLAN_AGENT_V2_ENABLED") or "").strip(), "1") + + r1 = self.client.post( + "/admin/api/chat/user-mode", + headers=headers, + json={ + "interaction_mode": "expert", + "specialist": "generalist", + "confirm_strategy": "strict", + "plan_agent_version": "v1", + }, + ) + self.assertEqual(r1.status_code, 200) + body1 = r1.json() + self.assertTrue(body1.get("ok"), body1) + self.assertFalse(body1.get("plan_agent_v2_globally_enabled"), body1) + self.assertEqual(str(self.store.get_setting("AIA_EXPERT_PLAN_AGENT_V2_ENABLED") or "").strip(), "0") + def test_session_mode_setting_roundtrip(self) -> None: token = self._login() headers = { @@ -179,6 +219,16 @@ class AdminChatStreamAsyncTaskTests(unittest.TestCase): "accept": "application/json", "content-type": "application/json", } + _ = self.client.post( + "/admin/api/chat/user-mode", + headers=headers, + json={ + "interaction_mode": "expert", + "specialist": "generalist", + "confirm_strategy": "auto", + "plan_agent_version": "v1", + }, + ) resp1 = self.client.post( f"/admin/api/chat/sessions/{self.session_id}/mode", headers=headers, @@ -188,8 +238,10 @@ class AdminChatStreamAsyncTaskTests(unittest.TestCase): body1 = resp1.json() self.assertTrue(body1.get("ok"), body1) self.assertEqual(str(body1.get("interaction_mode") or ""), "expert") - self.assertEqual(str(body1.get("specialist") or ""), "ops") + # Session POST ignores interaction/specialist; user menu still has generalist. + self.assertEqual(str(body1.get("specialist") or ""), "generalist") self.assertEqual(str(body1.get("memory_mode") or ""), "store_only") + self.assertEqual(str(body1.get("confirm_strategy") or ""), "auto") resp2 = self.client.get( f"/admin/api/chat/sessions/{self.session_id}/mode", @@ -199,8 +251,9 @@ class AdminChatStreamAsyncTaskTests(unittest.TestCase): body2 = resp2.json() self.assertTrue(body2.get("ok"), body2) self.assertEqual(str(body2.get("interaction_mode") or ""), "expert") - self.assertEqual(str(body2.get("specialist") or ""), "ops") + self.assertEqual(str(body2.get("specialist") or ""), "generalist") self.assertEqual(str(body2.get("memory_mode") or ""), "store_only") + self.assertEqual(str(body2.get("confirm_strategy") or ""), "auto") def test_messages_use_session_mode_when_payload_omits_mode(self) -> None: token = self._login() @@ -209,10 +262,20 @@ class AdminChatStreamAsyncTaskTests(unittest.TestCase): "accept": "application/json", "content-type": "application/json", } + _ = self.client.post( + "/admin/api/chat/user-mode", + headers=headers, + json={ + "interaction_mode": "expert", + "specialist": "ops", + "confirm_strategy": "strict", + "plan_agent_version": "v1", + }, + ) _ = self.client.post( f"/admin/api/chat/sessions/{self.session_id}/mode", headers=headers, - json={"interaction_mode": "expert", "specialist": "ops", "memory_mode": "store_only"}, + json={"memory_mode": "store_only"}, ) resp = self.client.post( f"/admin/api/chat/sessions/{self.session_id}/messages", @@ -238,12 +301,17 @@ class AdminChatStreamAsyncTaskTests(unittest.TestCase): "accept": "application/json", "content-type": "application/json", } - set_resp = self.client.post( - f"/admin/api/chat/sessions/{self.session_id}/mode", + pref = self.client.post( + "/admin/api/chat/user-mode", headers=headers, - json={"interaction_mode": "expert", "specialist": "ops", "memory_mode": "store_only"}, + json={ + "interaction_mode": "expert", + "specialist": "ops", + "confirm_strategy": "strict", + "plan_agent_version": "v1", + }, ) - self.assertEqual(set_resp.status_code, 200) + self.assertEqual(pref.status_code, 200) create_resp = self.client.post( "/admin/api/chat/sessions", @@ -265,7 +333,12 @@ class AdminChatStreamAsyncTaskTests(unittest.TestCase): self.assertTrue(mode_body.get("ok"), mode_body) self.assertEqual(str(mode_body.get("interaction_mode") or ""), "expert") self.assertEqual(str(mode_body.get("specialist") or ""), "ops") - self.assertEqual(str(mode_body.get("memory_mode") or ""), "store_only") + self.assertEqual(str(mode_body.get("memory_mode") or ""), "default") + self.assertEqual(str(mode_body.get("execution_mode") or ""), "agent") + self.assertEqual(str(mode_body.get("confirm_strategy") or ""), "strict") + gm = mode_body.get("global_menu") if isinstance(mode_body.get("global_menu"), dict) else {} + self.assertEqual(str(gm.get("interaction_mode") or ""), "expert") + self.assertEqual(str(gm.get("specialist") or ""), "ops") def test_new_session_default_mode_is_expert_generalist(self) -> None: token = self._login() @@ -295,6 +368,7 @@ class AdminChatStreamAsyncTaskTests(unittest.TestCase): self.assertEqual(str(mode_body.get("interaction_mode") or ""), "expert") self.assertEqual(str(mode_body.get("specialist") or ""), "generalist") self.assertEqual(str(mode_body.get("memory_mode") or ""), "default") + self.assertEqual(str(mode_body.get("confirm_strategy") or ""), "strict") def test_admin_dynamic_expert_stats_endpoint(self) -> None: token = self._login() diff --git a/tests/test_bootstrap_env.py b/tests/test_bootstrap_env.py new file mode 100644 index 00000000..3d2f8e6b --- /dev/null +++ b/tests/test_bootstrap_env.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from oclaw.platform.config import bootstrap_env as be + + +@pytest.fixture(autouse=True) +def _reset_bootstrap_flag(): + be._LOADED = False + yield + be._LOADED = False + + +def test_load_system_env_reads_only_local_system_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(be, "_project_root", lambda: tmp_path) + loc = tmp_path / "_local" + loc.mkdir(parents=True, exist_ok=True) + (loc / "system.env").write_text("X=only_file\nY=z\n", encoding="utf-8") + monkeypatch.delenv("X", raising=False) + monkeypatch.delenv("Y", raising=False) + + loaded = be.load_system_env(force=True) + assert len(loaded) == 1 + assert os.environ["X"] == "only_file" + assert os.environ["Y"] == "z" + + +def test_load_system_env_does_not_override_process_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(be, "_project_root", lambda: tmp_path) + loc = tmp_path / "_local" + loc.mkdir(parents=True, exist_ok=True) + (loc / "system.env").write_text("X=from_file\n", encoding="utf-8") + monkeypatch.setenv("X", "from_shell") + + be.load_system_env(force=True) + assert os.environ["X"] == "from_shell" + + +def test_load_system_env_missing_file_noop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(be, "_project_root", lambda: tmp_path) + (tmp_path / "_local").mkdir(parents=True, exist_ok=True) + + loaded = be.load_system_env(force=True) + assert loaded == [] diff --git a/tests/test_chat_mode_execution_persistence.py b/tests/test_chat_mode_execution_persistence.py new file mode 100644 index 00000000..903352f3 --- /dev/null +++ b/tests/test_chat_mode_execution_persistence.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from oclaw.interfaces.admin.chat_api import ( + _persist_session_dialog_chat_settings, + _persist_user_menu_chat_settings, + _resolve_mode_settings, +) + + +class _DummyStore: + def __init__(self) -> None: + self._settings: dict[str, str] = {} + + def get_setting(self, key: str) -> str: + return str(self._settings.get(key) or "") + + def set_setting(self, key: str, value: str) -> None: + self._settings[str(key)] = str(value) + + +def test_mode_session_and_user_prefs_merge_for_gateway() -> None: + store = _DummyStore() + _persist_session_dialog_chat_settings( + store=store, + tenant_id="t1", + user_id="u1", + session_id="s1", + memory_mode="default", + execution_mode="plan", + ) + _persist_user_menu_chat_settings( + store=store, + tenant_id="t1", + user_id="u1", + interaction_mode="expert", + specialist="generalist", + confirm_strategy="auto", + plan_agent_version="v2", + ) + interaction_mode, specialist, memory_mode, execution_mode, confirm_strategy, plan_agent_version = ( + _resolve_mode_settings(store=store, tenant_id="t1", user_id="u1", session_id="s1") + ) + assert interaction_mode == "expert" + assert specialist == "generalist" + assert memory_mode == "default" + assert execution_mode == "plan" + assert confirm_strategy == "auto" + assert plan_agent_version == "v2" + + +def test_mode_session_defaults_when_session_keys_missing() -> None: + store = _DummyStore() + store.set_setting("chat.user.mode.t1.u1.confirm_strategy", "invalid") + interaction_mode, specialist, memory_mode, execution_mode, confirm_strategy, plan_agent_version = ( + _resolve_mode_settings(store=store, tenant_id="t1", user_id="u1", session_id="s1") + ) + assert interaction_mode == "expert" + assert specialist == "generalist" + assert memory_mode == "default" + assert execution_mode == "agent" + assert confirm_strategy == "strict" + assert plan_agent_version == "v1" + + +def test_invalid_execution_mode_on_session_dialog_falls_back_to_agent() -> None: + store = _DummyStore() + store.set_setting("chat.session.mode.t1.u1.s1.execution_mode", "invalid") + interaction_mode, specialist, memory_mode, execution_mode, confirm_strategy, plan_agent_version = ( + _resolve_mode_settings(store=store, tenant_id="t1", user_id="u1", session_id="s1") + ) + assert execution_mode == "agent" + assert interaction_mode == "expert" + + +def test_invalid_confirm_strategy_on_user_menu_falls_back_to_strict() -> None: + store = _DummyStore() + store.set_setting("chat.user.mode.t1.u1.confirm_strategy", "invalid") + interaction_mode, specialist, memory_mode, execution_mode, confirm_strategy, plan_agent_version = ( + _resolve_mode_settings(store=store, tenant_id="t1", user_id="u1", session_id="s1") + ) + assert confirm_strategy == "strict" + assert specialist == "generalist" diff --git a/tests/test_direct_loop_empty_assistant_response.py b/tests/test_direct_loop_empty_assistant_response.py index 28f81176..6aad3bea 100644 --- a/tests/test_direct_loop_empty_assistant_response.py +++ b/tests/test_direct_loop_empty_assistant_response.py @@ -4,7 +4,7 @@ from types import SimpleNamespace from oclaw.platform.persistence.sqlite_store import SqliteStore from oclaw.runtime.direct_loop import run_oclaw_direct_loop -from oclaw.runtime.tools.base import ToolRegistry +from oclaw.runtime.tools.base import ToolRegistry, ToolSpec class _Model: @@ -15,7 +15,7 @@ class _Model: return SimpleNamespace(content="", reasoning_content="", tool_calls=[]) -def test_direct_loop_persists_stub_on_empty_assistant(tmp_path) -> None: # noqa: ANN001 +def test_direct_loop_keeps_empty_assistant_without_stub(tmp_path) -> None: # noqa: ANN001 db = tmp_path / "ops.sqlite" store = SqliteStore(str(db)) sess = store.create_session("t") @@ -30,8 +30,191 @@ def test_direct_loop_persists_stub_on_empty_assistant(tmp_path) -> None: # noqa persist_user_message=True, max_tool_rounds=1, ) - assert out.final_text + assert out.final_text == "" rows = store.get_messages(session_id=sess.id, limit=10) assistant = [r for r in rows if getattr(r, "role", "") == "assistant"] - assert any("空响应" in str(getattr(r, "content", "") or "") for r in assistant) + assert not any("空响应" in str(getattr(r, "content", "") or "") for r in assistant) + + +class _ModelRetryOnce: + base_url = "" + thinking_mode_enabled = False + + def __init__(self) -> None: + self.calls = 0 + + def chat(self, msgs, tools, on_token=None): # noqa: ANN001,ARG002 + self.calls += 1 + if self.calls == 1: + return SimpleNamespace(content="", reasoning_content="", tool_calls=[]) + return SimpleNamespace(content="ok-after-retry", reasoning_content="", tool_calls=[]) + + +def test_direct_loop_retries_once_before_stub(tmp_path, monkeypatch) -> None: # noqa: ANN001 + db = tmp_path / "ops.sqlite" + store = SqliteStore(str(db)) + sess = store.create_session("t") + model = _ModelRetryOnce() + monkeypatch.setenv("AIA_EMPTY_ASSISTANT_RETRY_MAX", "1") + monkeypatch.setenv("AIA_EMPTY_ASSISTANT_RETRY_DELAY_MS", "0") + out = run_oclaw_direct_loop( + store=store, + session_id=sess.id, + lang="zh", + system_prompt="x", + model=model, + tools=ToolRegistry([]), + user_text="hi", + persist_user_message=True, + max_tool_rounds=1, + ) + assert out.final_text == "ok-after-retry" + assert model.calls == 2 + rows = store.get_messages(session_id=sess.id, limit=10) + assistant = [r for r in rows if getattr(r, "role", "") == "assistant"] + assert not any("空响应" in str(getattr(r, "content", "") or "") for r in assistant) + monkeypatch.delenv("AIA_EMPTY_ASSISTANT_RETRY_MAX", raising=False) + monkeypatch.delenv("AIA_EMPTY_ASSISTANT_RETRY_DELAY_MS", raising=False) + + +class _ModelDsmlThenText: + base_url = "" + thinking_mode_enabled = False + + def __init__(self) -> None: + self.calls = 0 + + def chat(self, msgs, tools, on_token=None): # noqa: ANN001,ARG002 + self.calls += 1 + if self.calls == 1: + return SimpleNamespace( + content='<||DSML||tool_calls><||DSML||invoke name="run_command">', + reasoning_content="", + tool_calls=[], + ) + return SimpleNamespace(content="native-tools-recovered", reasoning_content="", tool_calls=[]) + + +def test_direct_loop_retries_when_dsml_text_tool_call(tmp_path, monkeypatch) -> None: # noqa: ANN001 + db = tmp_path / "ops.sqlite" + store = SqliteStore(str(db)) + sess = store.create_session("t") + model = _ModelDsmlThenText() + monkeypatch.setenv("AIA_EMPTY_ASSISTANT_RETRY_MAX", "1") + monkeypatch.setenv("AIA_EMPTY_ASSISTANT_RETRY_DELAY_MS", "0") + dummy_tool = ToolSpec( + name="run_command", + description="dummy", + parameters={"type": "object", "properties": {}, "additionalProperties": True}, + handler=lambda args: {"ok": True, "args": args}, + read_only=True, + ) + out = run_oclaw_direct_loop( + store=store, + session_id=sess.id, + lang="zh", + system_prompt="x", + model=model, + tools=ToolRegistry([dummy_tool]), + user_text="hi", + persist_user_message=True, + max_tool_rounds=1, + ) + assert out.final_text == "native-tools-recovered" + assert model.calls == 2 + + +class _ModelAlwaysDsml: + base_url = "" + thinking_mode_enabled = False + + def chat(self, msgs, tools, on_token=None): # noqa: ANN001,ARG002 + return SimpleNamespace( + content='<||DSML||tool_calls><||DSML||invoke name="read_file">', + reasoning_content="", + tool_calls=[], + ) + + +def test_direct_loop_dsml_text_persisted_as_failed_tool_pair(tmp_path, monkeypatch) -> None: # noqa: ANN001 + db = tmp_path / "ops.sqlite" + store = SqliteStore(str(db)) + sess = store.create_session("t") + model = _ModelAlwaysDsml() + monkeypatch.setenv("AIA_EMPTY_ASSISTANT_RETRY_MAX", "1") + monkeypatch.setenv("AIA_EMPTY_ASSISTANT_RETRY_DELAY_MS", "0") + dummy_tool = ToolSpec( + name="read_file", + description="dummy", + parameters={"type": "object", "properties": {}, "additionalProperties": True}, + handler=lambda args: {"ok": True, "args": args}, + read_only=True, + ) + out = run_oclaw_direct_loop( + store=store, + session_id=sess.id, + lang="zh", + system_prompt="x", + model=model, + tools=ToolRegistry([dummy_tool]), + user_text="hi", + persist_user_message=True, + max_tool_rounds=1, + ) + assert out.final_text == "" + rows = store.get_messages(session_id=sess.id, limit=20) + tool_rows = [r for r in rows if getattr(r, "role", "") == "tool"] + assert tool_rows + assert any("model_protocol_mismatch_dsml" in str(getattr(r, "content", "") or "") for r in tool_rows) + + +class _ModelMixedTextualToolIntent: + base_url = "" + thinking_mode_enabled = False + + def chat(self, msgs, tools, on_token=None): # noqa: ANN001,ARG002 + return SimpleNamespace( + content=( + "输出没抓到。再来:\n\n" + "<||DSML||tool_calls>\n" + "<||DSML||invoke name=\"run_command\">\n" + "<||DSML||parameter name=\"command\" string=\"true\">echo test\n" + "\n" + "" + ), + reasoning_content="", + tool_calls=[], + ) + + +def test_direct_loop_mixed_text_with_tool_intent_is_blocked(tmp_path, monkeypatch) -> None: # noqa: ANN001 + db = tmp_path / "ops.sqlite" + store = SqliteStore(str(db)) + sess = store.create_session("t") + model = _ModelMixedTextualToolIntent() + monkeypatch.setenv("AIA_EMPTY_ASSISTANT_RETRY_MAX", "1") + monkeypatch.setenv("AIA_EMPTY_ASSISTANT_RETRY_DELAY_MS", "0") + dummy_tool = ToolSpec( + name="run_command", + description="dummy", + parameters={"type": "object", "properties": {}, "additionalProperties": True}, + handler=lambda args: {"ok": True, "args": args}, + read_only=True, + ) + out = run_oclaw_direct_loop( + store=store, + session_id=sess.id, + lang="zh", + system_prompt="x", + model=model, + tools=ToolRegistry([dummy_tool]), + user_text="hi", + persist_user_message=True, + max_tool_rounds=1, + ) + assert out.final_text == "" + rows = store.get_messages(session_id=sess.id, limit=20) + tool_rows = [r for r in rows if getattr(r, "role", "") == "tool"] + assert tool_rows + assert any("run_command" in str(getattr(r, "tool_calls", "") or "") for r in tool_rows) diff --git a/tests/test_oclaw_gateway_trace.py b/tests/test_oclaw_gateway_trace.py index 5021b316..9b3df49e 100644 --- a/tests/test_oclaw_gateway_trace.py +++ b/tests/test_oclaw_gateway_trace.py @@ -6,6 +6,7 @@ import pytest from oclaw.platform.llm.chat_models import LLMResponse from oclaw.runtime.gateway import OclawGateway +from oclaw.runtime.tools.base import ToolRegistry, ToolSpec from oclaw.runtime.types import StandardMessage @@ -102,6 +103,9 @@ def test_gateway_async_task_payload_preserves_relay_envelope(monkeypatch: pytest def get_setting(self, _k: str) -> str: return "" + def set_setting(self, _k: str, _v: str) -> None: + return None + def add_trace_event(self, **_kwargs: object) -> None: return None @@ -214,6 +218,179 @@ def test_gateway_expert_mode_uses_requested_specialist() -> None: assert chosen.get("sid") == "ops" +def test_gateway_expert_plan_execution_mode_runs_v2_with_plan_prompt(monkeypatch: pytest.MonkeyPatch) -> None: + class Store: + def get_setting(self, k: str) -> str: + if str(k or "") == "AIA_EXPERT_PLAN_AGENT_V2_ENABLED": + return "1" + return "" + + def set_setting(self, _k: str, _v: str) -> None: + return None + + def add_trace_event(self, **_kwargs: object) -> None: + return None + + def add_trace_events_batch(self, _rows: list[dict[str, object]]) -> None: + return None + + def set_setting(self, _k: str, _v: str) -> None: + return None + + class _Exec: + model = object() + tools = object() + system_prompt = "base-system" + + captured: dict[str, object] = {} + + def _run_agent_core_ok(**kwargs: object) -> object: + data = kwargs.get("data") + captured["system_prompt"] = str(getattr(data, "system_prompt", "") or "") + return SimpleNamespace(outcome=SimpleNamespace(final_text="plan_llm_reply", turn_uuid="turn-1")) + + monkeypatch.setattr("oclaw.runtime.gateway.run_agent_core", _run_agent_core_ok) + + gw = OclawGateway(store=Store()) + msg = StandardMessage( + session_id="sid-plan-1", + tenant_id="t1", + user_id="u1", + role="user", + channel="admin_chat", + text="先给我一个执行计划", + attachments=[], + metadata={"interaction_mode": "expert", "selected_specialist": "generalist", "execution_mode": "plan"}, + ) + out = gw.handle_turn(msg=msg, lang="zh", executor=_Exec()) + assert out.interaction_mode == "expert" + assert out.dispatch_reason == "expert_direct" + assert str(out.reply_text or "") == "plan_llm_reply" + prompt_text = str(captured.get("system_prompt") or "") + assert ("plan 模式" in prompt_text) or ("Plan mode is active" in prompt_text) + assert ("计划工作流" in prompt_text) or ("Plan Workflow" in prompt_text) + + +def test_gateway_expert_plan_mode_filters_non_readonly_tools(monkeypatch: pytest.MonkeyPatch) -> None: + class Store: + def get_setting(self, k: str) -> str: + if str(k or "") == "AIA_EXPERT_PLAN_AGENT_V2_ENABLED": + return "1" + return "" + + def set_setting(self, _k: str, _v: str) -> None: + return None + + def add_trace_event(self, **_kwargs: object) -> None: + return None + + def add_trace_events_batch(self, _rows: list[dict[str, object]]) -> None: + return None + + def _mk_tool(name: str, read_only: bool) -> ToolSpec: + return ToolSpec( + name=name, + description=name, + parameters={"type": "object", "properties": {}, "additionalProperties": True}, + handler=lambda args: {"ok": True, "args": args}, + read_only=read_only, + ) + + class _Exec: + model = object() + system_prompt = "base-system" + tools = ToolRegistry([_mk_tool("read_file", True), _mk_tool("edit_file", False)]) + + captured: dict[str, object] = {} + + def _run_agent_core_ok(**kwargs: object) -> object: + data = kwargs.get("data") + tools = getattr(data, "tools", None) + captured["tool_names"] = [t.name for t in tools.list()] if hasattr(tools, "list") else [] + return SimpleNamespace(outcome=SimpleNamespace(final_text="ok", turn_uuid="turn-1")) + + monkeypatch.setattr("oclaw.runtime.gateway.run_agent_core", _run_agent_core_ok) + + gw = OclawGateway(store=Store()) + msg = StandardMessage( + session_id="sid-plan-tools", + tenant_id="t1", + user_id="u1", + role="user", + channel="admin_chat", + text="先给我一个执行计划", + attachments=[], + metadata={"interaction_mode": "expert", "selected_specialist": "generalist", "execution_mode": "plan"}, + ) + out = gw.handle_turn(msg=msg, lang="zh", executor=_Exec()) + assert str(out.reply_text or "") == "ok" + names = list(captured.get("tool_names") or []) + assert "read_file" in names + assert "edit_file" not in names + + +def test_gateway_expert_agent_mode_injects_plan_control_tools(monkeypatch: pytest.MonkeyPatch) -> None: + class Store: + def __init__(self) -> None: + self.kv: dict[str, str] = {} + + def get_setting(self, k: str) -> str: + return str(self.kv.get(k) or "") + + def set_setting(self, k: str, v: str) -> None: + self.kv[k] = str(v or "") + + def add_trace_event(self, **_kwargs: object) -> None: + return None + + def add_trace_events_batch(self, _rows: list[dict[str, object]]) -> None: + return None + + def _mk_tool(name: str, read_only: bool) -> ToolSpec: + return ToolSpec( + name=name, + description=name, + parameters={"type": "object", "properties": {}, "additionalProperties": True}, + handler=lambda args: {"ok": True, "args": args}, + read_only=read_only, + ) + + class _Exec: + model = object() + system_prompt = "base-system" + tools = ToolRegistry([_mk_tool("read_file", True)]) + + captured: dict[str, object] = {} + + def _run_agent_core_ok(**kwargs: object) -> object: + data = kwargs.get("data") + tools = getattr(data, "tools", None) + captured["tool_names"] = [t.name for t in tools.list()] if hasattr(tools, "list") else [] + return SimpleNamespace(outcome=SimpleNamespace(final_text="ok", turn_uuid="turn-1")) + + monkeypatch.setattr("oclaw.runtime.gateway.run_agent_core", _run_agent_core_ok) + + store = Store() + store.kv["AIA_EXPERT_PLAN_AGENT_V2_ENABLED"] = "1" + gw = OclawGateway(store=store) + msg = StandardMessage( + session_id="sid-agent-tools", + tenant_id="t1", + user_id="u1", + role="user", + channel="admin_chat", + text="直接执行", + attachments=[], + metadata={"interaction_mode": "expert", "selected_specialist": "generalist", "execution_mode": "agent"}, + ) + out = gw.handle_turn(msg=msg, lang="zh", executor=_Exec()) + assert str(out.reply_text or "") == "ok" + names = list(captured.get("tool_names") or []) + assert "enter_plan_mode_v2" in names + assert "exit_plan_mode_v2" in names + assert store.get_setting("AIA_PLAN_AGENT_V2_DEFAULT_SESSION_ID") == "sid-agent-tools" + + def test_gateway_comprehensive_mode_manager_first_selects_specialist(monkeypatch: pytest.MonkeyPatch) -> None: class Store: def get_setting(self, _k: str) -> str: diff --git a/tests/test_plan_agent_v2_gateway_cutover_draft.py b/tests/test_plan_agent_v2_gateway_cutover_draft.py new file mode 100644 index 00000000..276b8800 --- /dev/null +++ b/tests/test_plan_agent_v2_gateway_cutover_draft.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from pathlib import Path + +from oclaw.platform.persistence.sqlite_store import SqliteStore +from oclaw.runtime.plan_agent_v2_gateway_cutover import maybe_handle_expert_turn_v2_draft +from oclaw.runtime.types import StandardMessage + + +def _msg(text: str) -> StandardMessage: + return StandardMessage( + session_id="cutover-s1", + tenant_id="t1", + user_id="u1", + role="user", + channel="chat", + text=text, + attachments=[], + metadata={}, + ) + + +def test_cutover_draft_off_by_default(tmp_path: Path) -> None: + store = SqliteStore(str(tmp_path / "ops.sqlite")) + out = maybe_handle_expert_turn_v2_draft( + store=store, + msg=_msg("hello"), + lang="zh", + interaction_mode="expert", + requested_specialist="generalist", + base_system_prompt="base", + force_flag=False, + ) + assert out.handled is False + assert out.result is None + + +def test_cutover_draft_plan_reply_when_forced(tmp_path: Path) -> None: + store = SqliteStore(str(tmp_path / "ops.sqlite")) + store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans")) + out = maybe_handle_expert_turn_v2_draft( + store=store, + msg=_msg("我要做改造"), + lang="zh", + interaction_mode="expert", + requested_specialist="generalist", + base_system_prompt="base", + force_flag=True, + ) + assert out.handled is False + assert out.result is None + assert out.decision_action == "run_agent" + assert "base" in str(out.system_prompt_override or "") + + +def test_cutover_draft_run_agent_returns_prompt_override(tmp_path: Path) -> None: + store = SqliteStore(str(tmp_path / "ops.sqlite")) + store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans")) + _ = maybe_handle_expert_turn_v2_draft( + store=store, + msg=_msg("先给个计划"), + lang="zh", + interaction_mode="expert", + requested_specialist="generalist", + base_system_prompt="base", + force_flag=True, + ) + out = maybe_handle_expert_turn_v2_draft( + store=store, + msg=_msg("确认"), + lang="zh", + interaction_mode="expert", + requested_specialist="generalist", + base_system_prompt="base", + force_flag=True, + ) + assert out.decision_action == "stay_plan" + assert out.handled is True + assert out.result is not None + diff --git a/tests/test_plan_agent_v2_gateway_dryrun.py b/tests/test_plan_agent_v2_gateway_dryrun.py new file mode 100644 index 00000000..27051bf7 --- /dev/null +++ b/tests/test_plan_agent_v2_gateway_dryrun.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from pathlib import Path + +from oclaw.platform.persistence.sqlite_store import SqliteStore +from oclaw.runtime.gateway import OclawGatewayResult +from oclaw.runtime.plan_agent_v2 import ( + build_shadow_gateway_result, + evaluate_gateway_expert_turn_shadow, + legacy_gateway_result_keys, +) +from oclaw.runtime.types import StandardMessage + + +def _msg(text: str) -> StandardMessage: + return StandardMessage( + session_id="sess-dryrun", + tenant_id="tenant-1", + user_id="user-1", + role="user", + channel="chat", + text=text, + attachments=[], + metadata={}, + ) + + +def test_gateway_shadow_stays_off_without_force_or_flag(tmp_path: Path) -> None: + store = SqliteStore(str(tmp_path / "ops.sqlite")) + out = evaluate_gateway_expert_turn_shadow( + store=store, + msg=_msg("实现一个功能"), + lang="zh", + interaction_mode="expert", + requested_specialist="generalist", + base_system_prompt="base-system", + force_flag=False, + ) + assert out.used_v2 is False + assert out.decision is None + + +def test_gateway_shadow_force_path_matches_legacy_shape(tmp_path: Path) -> None: + store = SqliteStore(str(tmp_path / "ops.sqlite")) + store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans")) + out = evaluate_gateway_expert_turn_shadow( + store=store, + msg=_msg("请先给计划"), + lang="zh", + interaction_mode="expert", + requested_specialist="generalist", + base_system_prompt="base-system", + force_flag=True, + ) + assert out.used_v2 is True + assert out.decision is not None + + shadow_row = build_shadow_gateway_result( + decision=out.decision, + run_id="run-1", + trace_id="trace-1", + elapsed_ms=9, + requested_specialist="generalist", + ) + assert set(shadow_row.keys()) == legacy_gateway_result_keys() + + baseline = OclawGatewayResult(run_id="run-1", reply_text="", trace_id="trace-1", elapsed_ms=9) + assert shadow_row["mode"] == baseline.mode + assert shadow_row["task_id"] == baseline.task_id + assert shadow_row["dynamic_agent_used"] == baseline.dynamic_agent_used + assert shadow_row["relay_pointer_count"] == baseline.relay_pointer_count + + +def test_gateway_shadow_confirm_path_builds_compatible_result(tmp_path: Path) -> None: + store = SqliteStore(str(tmp_path / "ops.sqlite")) + store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans")) + + # Enter plan + first = evaluate_gateway_expert_turn_shadow( + store=store, + msg=_msg("我要改造一下"), + lang="zh", + interaction_mode="expert", + requested_specialist="generalist", + base_system_prompt="base-system", + force_flag=True, + ) + assert first.used_v2 is True + assert first.decision is not None + assert first.decision.action == "run_agent" + assert "base-system" in str(first.decision.system_prompt_override or "") + + # Confirm plan + second = evaluate_gateway_expert_turn_shadow( + store=store, + msg=_msg("确认"), + lang="zh", + interaction_mode="expert", + requested_specialist="generalist", + base_system_prompt="base-system", + force_flag=True, + ) + assert second.used_v2 is True + assert second.decision is not None + assert second.decision.action == "stay_plan" + assert "切换到 agent 模式" in str(second.decision.reply_text or "") + + row = build_shadow_gateway_result( + decision=second.decision, + run_id="run-2", + trace_id="trace-2", + elapsed_ms=12, + requested_specialist="generalist", + ) + assert row["interaction_mode"] == "expert" + assert str(row["dispatch_reason"]).startswith("plan_agent_v2:") + + +def test_gateway_shadow_skips_v2_when_metadata_plan_agent_version_v1(tmp_path: Path) -> None: + store = SqliteStore(str(tmp_path / "ops.sqlite")) + store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans")) + out = evaluate_gateway_expert_turn_shadow( + store=store, + msg=StandardMessage( + session_id="sess-dryrun", + tenant_id="tenant-1", + user_id="user-1", + role="user", + channel="chat", + text="请先给计划", + attachments=[], + metadata={"plan_agent_version": "v1"}, + ), + lang="zh", + interaction_mode="expert", + requested_specialist="generalist", + base_system_prompt="base-system", + force_flag=True, + ) + assert out.used_v2 is False + assert out.decision is None + diff --git a/tests/test_plan_agent_v2_shadow.py b/tests/test_plan_agent_v2_shadow.py new file mode 100644 index 00000000..cb92a2a6 --- /dev/null +++ b/tests/test_plan_agent_v2_shadow.py @@ -0,0 +1,450 @@ +from __future__ import annotations + +from pathlib import Path + +from oclaw.platform.persistence.sqlite_store import SqliteStore +from oclaw.runtime.plan_agent_v2_adapter import evaluate_for_expert_mode +from oclaw.runtime.plan_agent_v2_compat import build_shadow_gateway_result, legacy_gateway_result_keys +from oclaw.runtime.plan_agent_v2_gateway_adapter import evaluate_gateway_expert_turn_shadow +from oclaw.runtime.plan_agent_v2_manager import PlanModeManagerV2 +from oclaw.runtime.plan_agent_v2_models import PLAN_MODE_PLAN, PlanAgentStateV2 +from oclaw.runtime.plan_agent_v2_prompt_injector import build_plan_mode_prefix +from oclaw.runtime.plan_agent_v2_state_store import PlanAgentStateStoreV2 +from oclaw.runtime.plan_agent_v2_switch import should_route_to_v2, v2_feature_enabled +from oclaw.runtime.plan_agent_v2_tool_specs import materialize_plan_mode_v2_tools +from oclaw.runtime.plan_agent_v2_tool_policy import filter_tools_for_mode +from oclaw.runtime.plan_agent_v2_trace import emit_plan_agent_v2_trace +from oclaw.runtime.plan_agent_v2 import should_route_to_v2 as should_route_to_v2_pkg +from oclaw.runtime.gateway import OclawGatewayResult +from oclaw.runtime.tools.base import ToolRegistry, ToolSpec +from oclaw.runtime.types import StandardMessage + + +def _dummy_tool(name: str, read_only: bool) -> ToolSpec: + def _handler(args): + return {"ok": True, "echo": args} + + return ToolSpec( + name=name, + description=name, + parameters={"type": "object", "properties": {}, "additionalProperties": True}, + handler=_handler, + read_only=read_only, + ) + + +def test_state_store_roundtrip(tmp_path: Path) -> None: + store = SqliteStore(str(tmp_path / "ops.sqlite")) + ss = PlanAgentStateStoreV2(store) + st = ss.load(session_id="s1") + assert st.mode == "normal" + saved = ss.save(session_id="s1", state=st) + loaded = ss.load(session_id="s1") + assert loaded.mode == saved.mode + + +def test_manager_enter_and_confirm(tmp_path: Path) -> None: + store = SqliteStore(str(tmp_path / "ops.sqlite")) + plan_root = tmp_path / "plans" + store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(plan_root)) + mgr = PlanModeManagerV2(store=store) + st1 = mgr.enter(session_id="sess-1", owner_specialist="generalist") + assert st1.mode == PLAN_MODE_PLAN + assert st1.plan_path + assert Path(st1.plan_path).exists() + st2 = mgr.confirm(session_id="sess-1") + assert st2.mode == "normal" + assert st2.plan_confirmed is True + assert "## Goal" in str(st2.plan_content or "") + + +def test_tool_policy_filters_non_readonly_in_plan_mode() -> None: + reg = ToolRegistry([_dummy_tool("read_a", True), _dummy_tool("write_a", False)]) + out = filter_tools_for_mode(registry=reg, mode="plan") + names = {t.name for t in out} + assert "read_a" in names + assert "write_a" not in names + + +def test_tool_policy_keeps_plan_mode_control_tools() -> None: + reg = ToolRegistry([_dummy_tool("exit_plan_mode_v2", False), _dummy_tool("write_a", False)]) + out = filter_tools_for_mode(registry=reg, mode="plan") + names = {t.name for t in out} + assert "exit_plan_mode_v2" in names + assert "write_a" not in names + + +def test_adapter_agent_mode_repeated_user_injects_stall_guard(tmp_path: Path) -> None: + store = SqliteStore(str(tmp_path / "ops.sqlite")) + store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans")) + sid = store.create_session("stall-test").id + duplicate_line = "please handle this request" + store.add_message(session_id=sid, role="user", content=duplicate_line, event_type="user_text") + store.add_message(session_id=sid, role="assistant", content="I will analyze first…", event_type="assistant_text") + dec = evaluate_for_expert_mode( + store=store, + session_id=sid, + lang="en", + requested_specialist="generalist", + user_text=duplicate_line, + execution_mode="agent", + base_system_prompt="base", + ) + assert dec.action == "run_agent" + assert "Conversation stall guard" in str(dec.system_prompt_override or "") + assert "base" in str(dec.system_prompt_override or "") + + +def test_adapter_plan_flow(tmp_path: Path) -> None: + store = SqliteStore(str(tmp_path / "ops.sqlite")) + store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans")) + + d1 = evaluate_for_expert_mode( + store=store, + session_id="s1", + lang="zh", + requested_specialist="generalist", + user_text="帮我做一个功能", + execution_mode="plan", + base_system_prompt="base", + ) + assert d1.action == "run_agent" + assert isinstance(d1.plan_state, dict) + assert str(d1.plan_state.get("mode") or "") == "plan" + assert "base" in str(d1.system_prompt_override or "") + + d2 = evaluate_for_expert_mode( + store=store, + session_id="s1", + lang="zh", + requested_specialist="generalist", + user_text="确认", + execution_mode="agent", + base_system_prompt="base", + ) + assert d2.action == "run_agent" + assert "base" in str(d2.system_prompt_override or "") + + +def test_adapter_confirm_blocked_until_agent_mode(tmp_path: Path) -> None: + store = SqliteStore(str(tmp_path / "ops.sqlite")) + store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans")) + + _ = evaluate_for_expert_mode( + store=store, + session_id="s2", + lang="zh", + requested_specialist="generalist", + user_text="先给计划", + execution_mode="plan", + base_system_prompt="base", + ) + d2 = evaluate_for_expert_mode( + store=store, + session_id="s2", + lang="zh", + requested_specialist="generalist", + user_text="确认", + execution_mode="plan", + base_system_prompt="base", + ) + assert d2.action == "stay_plan" + assert "切换到 agent 模式" in str(d2.reply_text or "") + + +def test_adapter_confirm_strategy_auto_allows_confirm_in_plan_mode(tmp_path: Path) -> None: + store = SqliteStore(str(tmp_path / "ops.sqlite")) + store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans")) + store.set_setting("AIA_EXPERT_PLAN_CONFIRM_STRATEGY", "auto") + _ = evaluate_for_expert_mode( + store=store, + session_id="s-auto", + lang="zh", + requested_specialist="generalist", + user_text="先给计划", + execution_mode="plan", + base_system_prompt="base", + ) + d2 = evaluate_for_expert_mode( + store=store, + session_id="s-auto", + lang="zh", + requested_specialist="generalist", + user_text="确认", + execution_mode="plan", + base_system_prompt="base", + ) + assert d2.action == "run_agent" + assert "已确认计划" in str(d2.reply_text or "") + + +def test_adapter_confirm_strategy_off_allows_confirm_in_plan_mode(tmp_path: Path) -> None: + store = SqliteStore(str(tmp_path / "ops.sqlite")) + store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans")) + store.set_setting("AIA_EXPERT_PLAN_CONFIRM_STRATEGY", "off") + _ = evaluate_for_expert_mode( + store=store, + session_id="s-off", + lang="zh", + requested_specialist="generalist", + user_text="先给计划", + execution_mode="plan", + base_system_prompt="base", + ) + d2 = evaluate_for_expert_mode( + store=store, + session_id="s-off", + lang="zh", + requested_specialist="generalist", + user_text="确认", + execution_mode="plan", + base_system_prompt="base", + ) + assert d2.action == "run_agent" + assert "已确认计划" in str(d2.reply_text or "") + + +def test_adapter_plan_loop_guard_blocks_repeated_input(tmp_path: Path) -> None: + store = SqliteStore(str(tmp_path / "ops.sqlite")) + store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans")) + common = dict( + store=store, + session_id="s-loop", + lang="zh", + requested_specialist="generalist", + execution_mode="plan", + base_system_prompt="base", + ) + _ = evaluate_for_expert_mode(user_text="继续", **common) + d2 = evaluate_for_expert_mode(user_text="继续", **common) + assert d2.action == "stay_plan" + assert "低信息续写" in str(d2.reply_text or "") + + +def test_adapter_plan_low_signal_continue_short_circuit(tmp_path: Path) -> None: + store = SqliteStore(str(tmp_path / "ops.sqlite")) + store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans")) + _ = evaluate_for_expert_mode( + store=store, + session_id="s-low", + lang="zh", + requested_specialist="generalist", + user_text="先给我一版计划", + execution_mode="plan", + base_system_prompt="base", + ) + d2 = evaluate_for_expert_mode( + store=store, + session_id="s-low", + lang="zh", + requested_specialist="generalist", + user_text="继续", + execution_mode="plan", + base_system_prompt="base", + ) + assert d2.action == "stay_plan" + assert "低信息续写" in str(d2.reply_text or "") + + +def test_prompt_prefix_uses_ccmini_like_phases(tmp_path: Path) -> None: + plan_file = tmp_path / "plan.md" + plan_file.write_text("# Plan\n", encoding="utf-8") + st = PlanAgentStateV2(mode="plan", plan_path=str(plan_file)) + zh = build_plan_mode_prefix(state=st, lang="zh") + en = build_plan_mode_prefix(state=st, lang="en") + assert "阶段1:理解问题" in zh + assert "计划工作流" in zh + assert "计划模式工具" in zh + assert "执行纪律" in zh + assert "enter_plan_mode_v2" in zh + assert "Phase 1: Initial Understanding" in en + assert "Plan Workflow" in en + assert "Plan mode tools" in en + assert "Execution discipline" in en + assert "enter_plan_mode_v2" in en + + +def test_shadow_tool_specs_work(tmp_path: Path) -> None: + store = SqliteStore(str(tmp_path / "ops.sqlite")) + store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans")) + tools = materialize_plan_mode_v2_tools(store=store) + assert len(tools) == 2 + enter = next(t for t in tools if t.name == "enter_plan_mode_v2") + exit_tool = next(t for t in tools if t.name == "exit_plan_mode_v2") + out1 = enter.handler({"session_id": "s-1", "owner_specialist": "generalist"}) + assert out1.get("ok") is True + out2 = exit_tool.handler({"session_id": "s-1", "confirm": True}) + assert out2.get("ok") is True + assert bool((out2.get("state") or {}).get("plan_confirmed")) is True + + +def test_shadow_plan_tools_emit_trace_when_trace_id(tmp_path: Path) -> None: + store = SqliteStore(str(tmp_path / "ops.sqlite")) + store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans")) + tools = materialize_plan_mode_v2_tools(store=store) + enter = next(t for t in tools if t.name == "enter_plan_mode_v2") + exit_tool = next(t for t in tools if t.name == "exit_plan_mode_v2") + enter.handler( + { + "session_id": "s-tr", + "owner_specialist": "generalist", + "trace_id": "tid-1", + "parent_span_id": "ps-9", + } + ) + exit_tool.handler({"session_id": "s-tr", "confirm": False, "trace_id": "tid-1"}) + rows = store.list_trace_events_for_trace(session_id="s-tr", trace_id="tid-1") + types = [r.get("event_type") for r in rows] + assert "plan_mode_tool_enter" in types + assert "plan_mode_tool_exit" in types + enter_ev = next(r for r in rows if r.get("event_type") == "plan_mode_tool_enter") + exit_ev = next(r for r in rows if r.get("event_type") == "plan_mode_tool_exit") + assert (enter_ev.get("payload") or {}).get("tool") == "enter_plan_mode_v2" + assert (exit_ev.get("payload") or {}).get("tool") == "exit_plan_mode_v2" + assert (exit_ev.get("payload") or {}).get("confirmed") is False + + +def test_shadow_tool_specs_can_use_default_session_key(tmp_path: Path) -> None: + store = SqliteStore(str(tmp_path / "ops.sqlite")) + store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans")) + store.set_setting("AIA_PLAN_AGENT_V2_DEFAULT_SESSION_ID", "s-default") + tools = materialize_plan_mode_v2_tools(store=store) + enter = next(t for t in tools if t.name == "enter_plan_mode_v2") + out = enter.handler({}) + assert out.get("ok") is True + st = out.get("state") or {} + assert str(st.get("mode") or "") == "plan" + + +def test_switch_default_off_and_opt_in(tmp_path: Path) -> None: + store = SqliteStore(str(tmp_path / "ops.sqlite")) + assert v2_feature_enabled(store=store) is False + assert should_route_to_v2(store=store, interaction_mode="expert") is False + assert should_route_to_v2(store=store, interaction_mode="expert", force_flag=True) is True + store.set_setting("AIA_EXPERT_PLAN_AGENT_V2_ENABLED", "1") + assert v2_feature_enabled(store=store) is True + assert should_route_to_v2(store=store, interaction_mode="expert") is True + assert should_route_to_v2(store=store, interaction_mode="comprehensive") is False + + +def test_gateway_adapter_shadow_force_flag(tmp_path: Path) -> None: + store = SqliteStore(str(tmp_path / "ops.sqlite")) + store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans")) + msg = StandardMessage( + session_id="s1", + tenant_id="t1", + user_id="u1", + role="user", + channel="chat", + text="帮我实现一个功能", + attachments=[], + metadata={}, + ) + out = evaluate_gateway_expert_turn_shadow( + store=store, + msg=msg, + lang="zh", + interaction_mode="expert", + requested_specialist="generalist", + base_system_prompt="base", + force_flag=True, + ) + assert out.used_v2 is True + assert out.decision is not None + assert out.decision.action == "run_agent" + + +def test_shadow_compat_result_shape(tmp_path: Path) -> None: + store = SqliteStore(str(tmp_path / "ops.sqlite")) + store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans")) + dec = evaluate_for_expert_mode( + store=store, + session_id="s1", + lang="zh", + requested_specialist="generalist", + user_text="我要改造", + base_system_prompt="base", + ) + row = build_shadow_gateway_result( + decision=dec, + run_id="r1", + trace_id="t1", + elapsed_ms=12, + requested_specialist="generalist", + ) + assert set(row.keys()) == legacy_gateway_result_keys() + + +def test_trace_helper_no_crash() -> None: + events = [] + + class _S: + def add_trace_event(self, **kwargs): + events.append(kwargs) + + emit_plan_agent_v2_trace( + store=_S(), + session_id="s1", + trace_id="t1", + parent_span_id=None, + event_type="plan_mode_entered", + payload={"x": 1}, + ) + assert len(events) == 1 + assert events[0].get("event_type") == "plan_mode_entered" + + +def test_shadow_gateway_result_defaults_align_legacy_baseline(tmp_path: Path) -> None: + store = SqliteStore(str(tmp_path / "ops.sqlite")) + store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans")) + dec = evaluate_for_expert_mode( + store=store, + session_id="s1", + lang="zh", + requested_specialist="generalist", + user_text="继续", + base_system_prompt="base", + ) + row = build_shadow_gateway_result( + decision=dec, + run_id="r1", + trace_id="t1", + elapsed_ms=1, + requested_specialist="generalist", + ) + # Baseline invariant fields expected by legacy result dataclass. + baseline = OclawGatewayResult(run_id="r1", reply_text="", trace_id="t1", elapsed_ms=1) + assert row["mode"] == baseline.mode + assert row["task_id"] == baseline.task_id + assert row["dynamic_agent_used"] == baseline.dynamic_agent_used + assert row["dynamic_agent_name"] == baseline.dynamic_agent_name + assert row["relay_pointer_count"] == baseline.relay_pointer_count + assert row["relay_envelope_present"] == baseline.relay_envelope_present + assert row["relay_envelope_pointer_count"] == baseline.relay_envelope_pointer_count + assert row["relay_ttl_turn_count"] == baseline.relay_ttl_turn_count + assert row["relay_ttl_session_count"] == baseline.relay_ttl_session_count + assert row["relay_ttl_keep_count"] == baseline.relay_ttl_keep_count + # Package export should be wired and callable. + assert callable(should_route_to_v2_pkg) + + +def test_package_exports_stable_symbols() -> None: + import oclaw.runtime.plan_agent_v2 as p + + required = [ + "PlanAgentStateV2", + "PlanAgentStateStoreV2", + "PlanModeManagerV2", + "PlanAgentV2Decision", + "GatewayPlanV2AdapterOutput", + "evaluate_for_expert_mode", + "evaluate_gateway_expert_turn_shadow", + "should_route_to_v2", + "v2_feature_enabled", + "emit_plan_agent_v2_trace", + "build_shadow_gateway_result", + "legacy_gateway_result_keys", + ] + for name in required: + assert hasattr(p, name), name + diff --git a/tests/test_ws_gateway.py b/tests/test_ws_gateway.py index b463c3bc..009934d3 100644 --- a/tests/test_ws_gateway.py +++ b/tests/test_ws_gateway.py @@ -295,7 +295,12 @@ class WsGatewayTests(unittest.TestCase): "type": "req", "id": "cs1", "method": "chat.send", - "params": {"sessionKey": "sess-chat", "message": "hi", "idempotencyKey": "idem-chat-1"}, + "params": { + "sessionKey": "sess-chat", + "message": "hi", + "idempotencyKey": "idem-chat-1", + "execution_mode": "plan", + }, } ) ack = None @@ -309,6 +314,7 @@ class WsGatewayTests(unittest.TestCase): assert ack.get("id") == "cs1" assert ack.get("ok") is True assert str((ack.get("payload") or {}).get("status") or "") == "started" + assert str((ack.get("payload") or {}).get("executionMode") or "") == "plan" run_id = str((ack.get("payload") or {}).get("runId") or "") assert run_id.strip() != ""