mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 00:40:45 +08:00
feat: plan agent v2, global chat mode, and user-mode v2 flag sync
- Add runtime/plan_agent_v2 package and shims; gateway/direct_loop/WS wiring - Admin chat: interaction mode and specialist only in user menu; session API stores memory_mode and execution_mode only - POST /admin/api/chat/user-mode mirrors plan_agent_version to AIA_EXPERT_PLAN_AGENT_V2_ENABLED (v2 to 1, v1 to 0) - Composer cleanup (hidden mode select, no reasoning toggle in meta bar); tests and _local/system.env.example Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
ae44cbcad5
commit
37522f492a
49 changed files with 4146 additions and 238 deletions
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
353
_local/system.env.example
Normal file
353
_local/system.env.example
Normal file
|
|
@ -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 设置表单中有对应勾选或输入框。
|
||||
36
docs/plan_agent_v2_gateway_cutover_patch.md
Normal file
36
docs/plan_agent_v2_gateway_cutover_patch.md
Normal file
|
|
@ -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.
|
||||
|
||||
103
docs/plan_agent_v2_shadow.md
Normal file
103
docs/plan_agent_v2_shadow.md
Normal file
|
|
@ -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:<session_id>`
|
||||
- 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.
|
||||
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -563,7 +563,7 @@
|
|||
<!-- Cache-bust for desktop webview: avoid stale chat.js -->
|
||||
<script
|
||||
defer
|
||||
src="/admin/assets/chat.js?v=20260428-2"
|
||||
src="/admin/assets/chat.js?v=20260501-1"
|
||||
onerror="(function(){var s=document.getElementById('chat-boot-splash');if(s){s.querySelector('.chat-boot-splash__title span:last-child').textContent='无法加载 chat.js';s.querySelector('.chat-boot-splash__muted').textContent='请确认网关已启动且 /admin/assets/chat.js 可访问。';}})()"
|
||||
></script>
|
||||
</head>
|
||||
|
|
|
|||
|
|
@ -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 ? `<div style="margin-top:8px;"><span class="muted">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 ? `<div style="margin-top:8px;"><span class="muted">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 ? `<div style="margin-top:8px;"><span class="muted">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 ? `<div style="margin-top:8px;"><span class="muted">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 ? `<div style="margin-top:8px;"><span class="muted">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;
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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 ""),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
90
platform/config/bootstrap_env.py
Normal file
90
platform/config/bootstrap_env.py
Normal file
|
|
@ -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"]
|
||||
|
|
@ -17,3 +17,4 @@ pytest>=8.0.0
|
|||
cryptography>=42.0.0
|
||||
anthropic
|
||||
PyYAML>=6.0.0
|
||||
python-dotenv>=1.0.0
|
||||
|
|
|
|||
|
|
@ -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*</\1>\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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
42
runtime/plan_agent_v2/__init__.py
Normal file
42
runtime/plan_agent_v2/__init__.py
Normal file
|
|
@ -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",
|
||||
]
|
||||
|
||||
281
runtime/plan_agent_v2/adapter.py
Normal file
281
runtime/plan_agent_v2/adapter.py
Normal file
|
|
@ -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"]
|
||||
|
||||
45
runtime/plan_agent_v2/compat.py
Normal file
45
runtime/plan_agent_v2/compat.py
Normal file
|
|
@ -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"]
|
||||
|
||||
54
runtime/plan_agent_v2/gateway_adapter.py
Normal file
54
runtime/plan_agent_v2/gateway_adapter.py
Normal file
|
|
@ -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"]
|
||||
|
||||
185
runtime/plan_agent_v2/manager.py
Normal file
185
runtime/plan_agent_v2/manager.py
Normal file
|
|
@ -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"]
|
||||
|
||||
49
runtime/plan_agent_v2/models.py
Normal file
49
runtime/plan_agent_v2/models.py
Normal file
|
|
@ -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"]
|
||||
|
||||
120
runtime/plan_agent_v2/prompt_injector.py
Normal file
120
runtime/plan_agent_v2/prompt_injector.py
Normal file
|
|
@ -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...<plan_truncated>"
|
||||
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"]
|
||||
|
||||
60
runtime/plan_agent_v2/state_store.py
Normal file
60
runtime/plan_agent_v2/state_store.py
Normal file
|
|
@ -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"]
|
||||
|
||||
35
runtime/plan_agent_v2/switch.py
Normal file
35
runtime/plan_agent_v2/switch.py
Normal file
|
|
@ -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"]
|
||||
|
||||
52
runtime/plan_agent_v2/tool_policy.py
Normal file
52
runtime/plan_agent_v2/tool_policy.py
Normal file
|
|
@ -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"]
|
||||
|
||||
161
runtime/plan_agent_v2/tool_specs.py
Normal file
161
runtime/plan_agent_v2/tool_specs.py
Normal file
|
|
@ -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",
|
||||
]
|
||||
|
||||
37
runtime/plan_agent_v2/trace.py
Normal file
37
runtime/plan_agent_v2/trace.py
Normal file
|
|
@ -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"]
|
||||
|
||||
2
runtime/plan_agent_v2_adapter.py
Normal file
2
runtime/plan_agent_v2_adapter.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from oclaw.runtime.plan_agent_v2.adapter import * # noqa: F403
|
||||
|
||||
2
runtime/plan_agent_v2_compat.py
Normal file
2
runtime/plan_agent_v2_compat.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from oclaw.runtime.plan_agent_v2.compat import * # noqa: F403
|
||||
|
||||
2
runtime/plan_agent_v2_gateway_adapter.py
Normal file
2
runtime/plan_agent_v2_gateway_adapter.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from oclaw.runtime.plan_agent_v2.gateway_adapter import * # noqa: F403
|
||||
|
||||
86
runtime/plan_agent_v2_gateway_cutover.py
Normal file
86
runtime/plan_agent_v2_gateway_cutover.py
Normal file
|
|
@ -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"]
|
||||
|
||||
2
runtime/plan_agent_v2_manager.py
Normal file
2
runtime/plan_agent_v2_manager.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from oclaw.runtime.plan_agent_v2.manager import * # noqa: F403
|
||||
|
||||
2
runtime/plan_agent_v2_models.py
Normal file
2
runtime/plan_agent_v2_models.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from oclaw.runtime.plan_agent_v2.models import * # noqa: F403
|
||||
|
||||
2
runtime/plan_agent_v2_prompt_injector.py
Normal file
2
runtime/plan_agent_v2_prompt_injector.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from oclaw.runtime.plan_agent_v2.prompt_injector import * # noqa: F403
|
||||
|
||||
2
runtime/plan_agent_v2_state_store.py
Normal file
2
runtime/plan_agent_v2_state_store.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from oclaw.runtime.plan_agent_v2.state_store import * # noqa: F403
|
||||
|
||||
2
runtime/plan_agent_v2_switch.py
Normal file
2
runtime/plan_agent_v2_switch.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from oclaw.runtime.plan_agent_v2.switch import * # noqa: F403
|
||||
|
||||
2
runtime/plan_agent_v2_tool_policy.py
Normal file
2
runtime/plan_agent_v2_tool_policy.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from oclaw.runtime.plan_agent_v2.tool_policy import * # noqa: F403
|
||||
|
||||
2
runtime/plan_agent_v2_tool_specs.py
Normal file
2
runtime/plan_agent_v2_tool_specs.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from oclaw.runtime.plan_agent_v2.tool_specs import * # noqa: F403
|
||||
|
||||
2
runtime/plan_agent_v2_trace.py
Normal file
2
runtime/plan_agent_v2_trace.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from oclaw.runtime.plan_agent_v2.trace import * # noqa: F403
|
||||
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
48
tests/test_bootstrap_env.py
Normal file
48
tests/test_bootstrap_env.py
Normal file
|
|
@ -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 == []
|
||||
82
tests/test_chat_mode_execution_persistence.py
Normal file
82
tests/test_chat_mode_execution_persistence.py
Normal file
|
|
@ -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"
|
||||
|
|
@ -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"></||DSML||invoke></||DSML||tool_calls>',
|
||||
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"></||DSML||invoke></||DSML||tool_calls>',
|
||||
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</||DSML||parameter>\n"
|
||||
"</||DSML||invoke>\n"
|
||||
"</||DSML||tool_calls>"
|
||||
),
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
80
tests/test_plan_agent_v2_gateway_cutover_draft.py
Normal file
80
tests/test_plan_agent_v2_gateway_cutover_draft.py
Normal file
|
|
@ -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
|
||||
|
||||
142
tests/test_plan_agent_v2_gateway_dryrun.py
Normal file
142
tests/test_plan_agent_v2_gateway_dryrun.py
Normal file
|
|
@ -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
|
||||
|
||||
450
tests/test_plan_agent_v2_shadow.py
Normal file
450
tests/test_plan_agent_v2_shadow.py
Normal file
|
|
@ -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
|
||||
|
||||
|
|
@ -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() != ""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue