diff --git a/CHANGELOG.md b/CHANGELOG.md index 948072117..fc83d5b7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,12 +19,16 @@ ### 新增 -- 知识库支持下载原文、按原排版预览,聊天内可直接预览引用 -- 技能可在技能包与专家工作区之间复制 -- 记忆树支持手动新建与修正;Token 统计支持日期筛选与 Excel 导出 -- 按用户限制存储根目录与 Token 配额;创建专家可带默认知识库与连接器 -- 聊天支持 @ 子专家、技能斜杠插入,以及 ask_agent 独立对话线程 -- 滴滴连接器;可选分段历史归档;便携运行时升级并支持 SQLite 备份 +- 知识库支持下载上传原文;PDF / DOCX / PPTX / XLSX 在预览弹窗中按原排版查看;文档区支持拖拽上传(#591) +- 知识库 / 工作区 PDF 预览改用 PDF.js(react-pdf),替代浏览器内置 iframe 查看器 +- 技能包支持将选定技能一次性复制到专家工作区,工作区技能也可推送到当前用户有写权限的技能包(#618)。 +- 对话权限模式 Ask / Plan / Craft:turn 级 `conversation_mode`、`ConversationModeMiddleware`(工具过滤 + 系统提示 + denylist 硬拒;Ask 额外禁用 `write_todos`;Ask/Plan 拦截 `task` / `acp_runner` / `ask_agent`)、agent `default_conversation_mode` 与 `default_knowledge_base_ids`;Composer 模式菜单(默认 / 计划 / 问答);IM `/mode`(及 `/ask` `/plan` `/craft`)线程粘性覆盖(#616) +- 计划模式回合结束后展示「按计划执行 / 继续改计划」;确认后切到默认模式,计划 brief 经 `plan_brief` 由网关注入 system(不落对话气泡)(#616) +- 记忆树支持手动新建主题/记忆,以及编辑已有记忆(replace/supersede,保留变更历史)。 +- Token 统计支持日期范围筛选(RangePicker 预设 + 自定义起止)并导出 Excel 用量明细(#154) +- Token 用量 Excel 含专家名称、中英列表头,以及按天/专家/模型汇总 sheet 与图表 +- Token 用量 Excel 按报表规范打磨:冻结表头、千分位、合计公式行、筛选与仪表盘配色柱图 +- 知识库卡片「更多」菜单可一键添加到当前会话 ### 修复 diff --git a/dashboard/src/context/AgentContext.tsx b/dashboard/src/context/AgentContext.tsx index d1a28c476..fe4f60b24 100644 --- a/dashboard/src/context/AgentContext.tsx +++ b/dashboard/src/context/AgentContext.tsx @@ -37,6 +37,10 @@ export interface OctopAgent { description: string | null; persona_mbti: string | null; default_model: string | null; + /** Composer default: ask | plan | craft (#616). Missing → craft. */ + default_conversation_mode?: "ask" | "plan" | "craft" | null; + /** Composer / turn default knowledge bases when the turn omits an explicit list. */ + default_knowledge_base_ids?: string[] | null; system_prompt: string | null; template_name: string | null; state: "running" | "stopped" | "failed" | "starting" | "stopping" | string; diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index e68073df6..8a91fb61d 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -824,6 +824,11 @@ "patchFailed": "Failed to save changes", "defaultModelLabel": "Default Model", "defaultModelPlaceholder": "Select a model", + "defaultConversationModeLabel": "Default conversation mode", + "defaultConversationModeHint": "Initial permission mode for new chats; change anytime in the composer.", + "defaultKnowledgeBasesLabel": "Default knowledge bases", + "defaultKnowledgeBasesHint": "Auto-attached when a new chat does not pick bases; still adjustable in the composer.", + "defaultKnowledgeBasesPlaceholder": "Select knowledge bases", "skillPackagesLabel": "Optional Skill Packages", "skillPackagesHint": "Mount packages at create time (local storage with root /).", "skillPackagesUnsupportedHint": "This sandbox cannot mount skill-package directories directly. You can still use Copy skills to create independent workspace copies.", @@ -1128,6 +1133,17 @@ "reasoningDisabled": "Off", "reasoningEffort": "Thinking effort", "reasoningAlways": "Always reasoning", + "conversationMode": "Mode", + "conversationModeAsk": "Ask", + "conversationModePlan": "Plan", + "conversationModeCraft": "Default", + "conversationModeStatusCraft": "Default mode — execute efficiently and finish the task.", + "conversationModeStatusPlan": "Plan mode — outline the approach first, then execute.", + "conversationModeStatusAsk": "Ask mode — answer and analyze without changing the environment.", + "planReadyTitle": "Ready to build?", + "planReadyHint": "Switch to Default mode and continue with this plan, or keep refining it.", + "planReadyExecute": "Execute plan", + "planReadyContinue": "Keep planning", "errorOccurred": "An error occurred", "imageLoadFailed": "Image failed to load", "mediaLoadFailed": "Unable to load media", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 13ab16228..ac77b8dc7 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -824,6 +824,11 @@ "agentDisable": "停用", "defaultModelLabel": "默认模型", "defaultModelPlaceholder": "选择一个模型", + "defaultConversationModeLabel": "默认对话模式", + "defaultConversationModeHint": "新对话的初始权限模式;可在聊天输入栏随时切换。", + "defaultKnowledgeBasesLabel": "默认知识库", + "defaultKnowledgeBasesHint": "新对话未手动选择知识库时自动附带;仍可在输入栏增减。", + "defaultKnowledgeBasesPlaceholder": "选择知识库", "skillPackagesLabel": "可选技能包", "skillPackagesHint": "创建时挂载选中的技能包(需本地存储,且根目录为 /)。", "skillPackagesUnsupportedHint": "当前沙箱无法直接挂载技能包目录,但仍可使用“复制技能”在工作区创建独立副本。", @@ -1128,6 +1133,17 @@ "reasoningDisabled": "关闭", "reasoningEffort": "思考强度", "reasoningAlways": "始终推理", + "conversationMode": "模式", + "conversationModeAsk": "问答", + "conversationModePlan": "计划", + "conversationModeCraft": "默认", + "conversationModeStatusCraft": "当前为默认模式,可高效执行并完成任务。", + "conversationModeStatusPlan": "当前为计划模式,先梳理方案与步骤,再动手执行。", + "conversationModeStatusAsk": "当前为问答模式,只回答与分析,不改动环境。", + "planReadyTitle": "按计划执行?", + "planReadyHint": "确认后将切换到默认模式并带着这份计划继续执行;也可继续改计划。", + "planReadyExecute": "按计划执行", + "planReadyContinue": "继续改计划", "errorOccurred": "出现错误", "imageLoadFailed": "图片加载失败", "mediaLoadFailed": "无法加载媒体", diff --git a/dashboard/src/pages/Chat/chatInputCore.partial.less b/dashboard/src/pages/Chat/chatInputCore.partial.less index 17c7e2e35..2cdafe968 100644 --- a/dashboard/src/pages/Chat/chatInputCore.partial.less +++ b/dashboard/src/pages/Chat/chatInputCore.partial.less @@ -28,6 +28,76 @@ } } +.planReadyCard { + max-width: var(--chat-column-max, 960px); + width: calc(100% - 48px); + margin: 0 auto 10px; + padding: 12px 14px; + border: 1px solid var(--fn-border-secondary, rgba(0, 0, 0, 0.08)); + border-radius: 12px; + background: var(--fn-bg-elevated, #fff); + + @media (max-width: 767px) { + width: calc(100% - 24px); + } +} + +.planReadyTitle { + font-size: 14px; + font-weight: 600; + color: var(--fn-text-primary); + margin-bottom: 4px; +} + +.planReadyHint { + margin: 0 0 10px; + font-size: 12px; + line-height: 1.45; + color: var(--fn-text-secondary); +} + +.planReadyBrief { + margin: 0 0 12px; + padding: 8px 10px; + max-height: 160px; + overflow: auto; + border-radius: 8px; + border: 1px solid var(--fn-border-secondary, rgba(0, 0, 0, 0.06)); + background: var(--fn-bg-layout, #f5f5f5); + font-size: 12px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; + color: var(--fn-text-secondary); + font-family: inherit; +} + +.planReadyActions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.planReadyPrimary, +.planReadySecondary { + border-radius: 8px; + padding: 6px 12px; + font-size: 13px; + cursor: pointer; +} + +.planReadyPrimary { + border: none; + background: var(--fn-brand, #1677ff); + color: #fff; +} + +.planReadySecondary { + border: 1px solid var(--fn-border-secondary, rgba(0, 0, 0, 0.12)); + background: transparent; + color: var(--fn-text-primary); +} + .askQuestionDockInner { width: 100%; max-width: var(--chat-column-max, 960px); diff --git a/dashboard/src/pages/Chat/chatInputPickers.partial.less b/dashboard/src/pages/Chat/chatInputPickers.partial.less index 4721da58a..020a667b5 100644 --- a/dashboard/src/pages/Chat/chatInputPickers.partial.less +++ b/dashboard/src/pages/Chat/chatInputPickers.partial.less @@ -169,6 +169,35 @@ padding-left: 6px; } +.conversationModeMenuPanel { + width: 260px; + min-width: 260px; + max-height: none; +} + +.conversationModeStatus { + padding: 8px 10px 6px; + color: var(--fn-text-tertiary); + font-size: 12px; + line-height: 1.45; +} + +.conversationModeChoiceLabel { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + + svg { + display: block; + flex-shrink: 0; + } + + span { + line-height: 1; + } +} + .reasoningMenuHeader { display: flex; align-items: center; diff --git a/dashboard/src/pages/Chat/components/ChatInput.tsx b/dashboard/src/pages/Chat/components/ChatInput.tsx index fcd03143c..479fee88a 100644 --- a/dashboard/src/pages/Chat/components/ChatInput.tsx +++ b/dashboard/src/pages/Chat/components/ChatInput.tsx @@ -19,6 +19,7 @@ import type { KnowledgeBase } from "../../../api/modules/knowledgeBases"; import type { SkillSpec } from "../../Agent/Skills/useSkills"; import type { ChatAgentOption } from "./ExpertAgentAvatar"; import type { AgentSubagentSummary } from "../../../api/modules/subagents"; +import type { ConversationMode } from "../utils/conversationMode"; import MentionPickerMenu from "./MentionPickerMenu"; import ChatInputPreviewBar from "./ChatInputPreviewBar"; import ChatInputActionsRow from "./ChatInputActionsRow"; @@ -86,6 +87,8 @@ interface ChatInputProps { mode: "auto" | "enabled" | "disabled", effort: string | null, ) => void; + conversationMode?: ConversationMode; + onConversationModeChange?: (mode: ConversationMode) => void; availableConnectors?: { mcp_server_name: string; label: string; @@ -140,6 +143,8 @@ const ChatInput = forwardRef( reasoningMode = "auto", reasoningEffort = null, onReasoningChange, + conversationMode = "craft", + onConversationModeChange, availableConnectors, selectedConnectors = [], onConnectorsChange, @@ -809,6 +814,8 @@ const ChatInput = forwardRef( reasoningMode={reasoningMode} reasoningEffort={reasoningEffort} onReasoningChange={onReasoningChange} + conversationMode={conversationMode} + onConversationModeChange={onConversationModeChange} defaultModel={defaultModel} availableConnectors={availableConnectors} selectedConnectors={selectedConnectors} diff --git a/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx b/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx index ed684f9cf..3f94aa1d9 100644 --- a/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx +++ b/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx @@ -1,4 +1,5 @@ -import { fireEvent, render, waitFor } from "@testing-library/react"; +import { fireEvent, render, waitFor, screen } from "@testing-library/react"; +import type { ReactElement } from "react"; import { MemoryRouter } from "react-router-dom"; import { describe, expect, it, vi } from "vitest"; import type { ResolvedModel } from "../../../api/types"; @@ -19,32 +20,38 @@ const models: ResolvedModel[] = [ }, ]; +const baseProps = { + isMobile: false, + isStreaming: false, + canSend: false, + text: "", + polishing: false, + uploading: false, + recording: false, + transcribing: false, + slashPickerGroups: null as null, + slashMenuItems: [] as [], + onSlashShortcutSelect: vi.fn(), + onFileSelect: vi.fn(), + onNewChat: vi.fn(), + onPolish: vi.fn(), + onToggleVoice: vi.fn(), + onCancel: vi.fn(), + onSubmit: vi.fn(), +}; + +function renderRow(ui: ReactElement) { + return render({ui}); +} + describe("ChatInputActionsRow compact pickers", () => { it("uses a popover instead of a full-width drawer on narrow desktop", async () => { - const { container } = render( - - - , + const { container } = renderRow( + , ); const modelButton = container @@ -60,3 +67,32 @@ describe("ChatInputActionsRow compact pickers", () => { expect(document.querySelector(".ant-drawer-content")).toBeNull(); }); }); + +describe("ChatInputActionsRow conversation mode (#616 M13)", () => { + it("shows status + Default/Plan/Ask labels and notifies on Ask select", async () => { + const onConversationModeChange = vi.fn(); + renderRow( + , + ); + + fireEvent.click(screen.getByTestId("conversation-mode-trigger")); + + await waitFor(() => { + expect( + screen.getAllByTestId("conversation-mode-menu").length, + ).toBeGreaterThan(0); + }); + const menu = screen.getAllByTestId("conversation-mode-menu")[0]!; + expect(menu).toHaveTextContent("当前为默认模式"); + expect(menu).toHaveTextContent("默认"); + expect(menu).toHaveTextContent("计划"); + expect(menu).toHaveTextContent("问答"); + + fireEvent.click(screen.getAllByTestId("conversation-mode-option-ask")[0]!); + expect(onConversationModeChange).toHaveBeenCalledWith("ask"); + }); +}); diff --git a/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx b/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx index 3e2834a91..9bf847b55 100644 --- a/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx +++ b/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx @@ -10,6 +10,7 @@ import { Link2, Sparkles, Wand2, + ListTodo, Mic, CircleDot, Play, @@ -47,6 +48,10 @@ import type { SlashMenuItem } from "../hooks/useSlashMentionInput"; import { SHORTCUT_ICON_TONE_CLASS } from "../utils/slashShortcutStyles"; import { isSttAvailable } from "../../../hooks/useVoiceInput"; import { resolveTurnModelOverride } from "../utils/chatMessages"; +import { + COMPOSER_CONVERSATION_MODES, + type ConversationMode, +} from "../utils/conversationMode"; import { mentionedExpertIds, mentionedSubagentSlugs, @@ -96,6 +101,8 @@ interface ChatInputActionsRowProps { mode: "auto" | "enabled" | "disabled", effort: string | null, ) => void; + conversationMode?: ConversationMode; + onConversationModeChange?: (mode: ConversationMode) => void; availableConnectors?: { mcp_server_name: string; label: string; @@ -150,6 +157,8 @@ export default function ChatInputActionsRow({ reasoningMode = "auto", reasoningEffort = null, onReasoningChange, + conversationMode = "craft", + onConversationModeChange, availableConnectors, selectedConnectors = [], onConnectorsChange, @@ -181,6 +190,7 @@ export default function ChatInputActionsRow({ const [subagentPickerOpen, setSubagentPickerOpen] = useState(false); const [connectorPickerOpen, setConnectorPickerOpen] = useState(false); const [knowledgePickerOpen, setKnowledgePickerOpen] = useState(false); + const [conversationModeOpen, setConversationModeOpen] = useState(false); const [shortcutOpen, setShortcutOpen] = useState(false); const [modelPickerOpen, setModelPickerOpen] = useState(false); const [reasoningModelRef, setReasoningModelRef] = useState( @@ -322,6 +332,72 @@ export default function ChatInputActionsRow({ setReasoningModelRef(modelRef); }; + const conversationModeLabel = (mode: ConversationMode) => { + if (mode === "ask") return t("chat.conversationModeAsk", "问答"); + if (mode === "plan") return t("chat.conversationModePlan", "计划"); + return t("chat.conversationModeCraft", "默认"); + }; + + const conversationModeStatus = (mode: ConversationMode) => { + if (mode === "ask") { + return t( + "chat.conversationModeStatusAsk", + "当前为问答模式,只回答与分析,不改动环境。", + ); + } + if (mode === "plan") { + return t( + "chat.conversationModeStatusPlan", + "当前为计划模式,先梳理方案与步骤,再动手执行。", + ); + } + return t( + "chat.conversationModeStatusCraft", + "当前为默认模式,可高效执行并完成任务。", + ); + }; + + const conversationModeIcon = (mode: ConversationMode) => { + if (mode === "ask") return ; + if (mode === "plan") return ; + return ; + }; + + const conversationModeMenu = ( +
+
+ {conversationModeStatus(conversationMode)} +
+
+ {COMPOSER_CONVERSATION_MODES.map((mode) => ( + + ))} +
+ ); + const reasoningMenu = reasoningModelCapability ? (
@@ -1228,6 +1304,35 @@ export default function ChatInputActionsRow({ )} + {onConversationModeChange && ( + + + + + + )} {isStreaming ? ( canSend ? ( diff --git a/dashboard/src/pages/Chat/components/MessageBubble.tsx b/dashboard/src/pages/Chat/components/MessageBubble.tsx index 99b1130f6..42f9a0b3d 100644 --- a/dashboard/src/pages/Chat/components/MessageBubble.tsx +++ b/dashboard/src/pages/Chat/components/MessageBubble.tsx @@ -21,6 +21,7 @@ import UserMessageComposerTags, { type ComposerTagLookups, } from "./UserMessageComposerTags"; import { deriveMessageContent } from "../utils/messageContent"; +import { stripConversationModeUiInstructions } from "../utils/planArtifact"; import { inferKindFromNameAndMime } from "../utils/chatAttachments"; import { ChatMediaPlayer } from "./ChatMediaPlayer"; import { useAuthImageSrc } from "../../../hooks/useAuthImageSrc"; @@ -594,10 +595,16 @@ function MessageBubble({ [message.errorInfo], ); - const { textContent } = useMemo( + const { textContent: rawTextContent } = useMemo( () => deriveMessageContent(message), [message], ); + const textContent = useMemo(() => { + if (message.role === "user" || message.toolData || message.hitlData) { + return rawTextContent; + } + return stripConversationModeUiInstructions(rawTextContent); + }, [message.role, message.toolData, message.hitlData, rawTextContent]); const speechText = useMemo( () => prepareSpeechText(textContent), [textContent], diff --git a/dashboard/src/pages/Chat/components/PlanReadyCard.test.tsx b/dashboard/src/pages/Chat/components/PlanReadyCard.test.tsx new file mode 100644 index 000000000..87bb7d9f8 --- /dev/null +++ b/dashboard/src/pages/Chat/components/PlanReadyCard.test.tsx @@ -0,0 +1,28 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import PlanReadyCard from "./PlanReadyCard"; + +describe("PlanReadyCard (#616 P6–P8)", () => { + it("shows execute and continue actions plus brief preview", () => { + const onExecute = vi.fn(); + const onContinue = vi.fn(); + render( + , + ); + expect(screen.getByTestId("plan-ready-card")).toBeInTheDocument(); + expect(screen.getByTestId("plan-ready-brief")).toHaveTextContent("Do it."); + expect(screen.getByTestId("plan-ready-brief")).toHaveTextContent( + "Write file", + ); + fireEvent.click(screen.getByTestId("plan-ready-execute")); + expect(onExecute).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByTestId("plan-ready-continue")); + expect(onContinue).toHaveBeenCalledTimes(1); + }); +}); diff --git a/dashboard/src/pages/Chat/components/PlanReadyCard.tsx b/dashboard/src/pages/Chat/components/PlanReadyCard.tsx new file mode 100644 index 000000000..a340b8ca4 --- /dev/null +++ b/dashboard/src/pages/Chat/components/PlanReadyCard.tsx @@ -0,0 +1,55 @@ +import { useTranslation } from "react-i18next"; +import styles from "../index.module.less"; +import { planBriefPreview } from "../utils/planArtifact"; + +interface PlanReadyCardProps { + brief: string; + onExecute: () => void; + onContinue: () => void; +} + +/** Cursor/WorkBuddy-style Plan → Craft checkpoint (#616 P6). */ +export default function PlanReadyCard({ + brief, + onExecute, + onContinue, +}: PlanReadyCardProps) { + const { t } = useTranslation(); + const preview = planBriefPreview(brief); + return ( +
+
+ {t("chat.planReadyTitle", "按计划执行?")} +
+

+ {t( + "chat.planReadyHint", + "确认后将切换到默认模式并带着这份计划继续执行;也可继续改计划。", + )} +

+ {preview ? ( +
+          {preview}
+        
+ ) : null} +
+ + +
+
+ ); +} diff --git a/dashboard/src/pages/Chat/hooks/chatStore.ts b/dashboard/src/pages/Chat/hooks/chatStore.ts index 48cea7db1..e1d250500 100644 --- a/dashboard/src/pages/Chat/hooks/chatStore.ts +++ b/dashboard/src/pages/Chat/hooks/chatStore.ts @@ -18,6 +18,7 @@ import type { SessionSnapshot, SessionStreamState, } from "./sseHelpers"; +import type { ConversationMode } from "../utils/conversationMode"; import { parseHarnessChunk, type HarnessChunk, @@ -1851,6 +1852,8 @@ async function sendTurnWebSocket( onStreamEnd?: () => void, reasoningMode?: "auto" | "enabled" | "disabled", reasoningEffort?: string | null, + conversationMode?: ConversationMode, + planBrief?: string, ): Promise { const state = getOrCreate(sessionId); const resolvedThreadId = (threadId || sessionId).trim(); @@ -1928,6 +1931,8 @@ async function sendTurnWebSocket( } if (reasoningMode) payload.reasoning_mode = reasoningMode; if (reasoningEffort) payload.reasoning_effort = reasoningEffort; + if (conversationMode) payload.conversation_mode = conversationMode; + if (planBrief?.trim()) payload.plan_brief = planBrief.trim(); ws.send(JSON.stringify(payload)); }; @@ -2052,6 +2057,8 @@ export async function sendTurn( targetAgentIds?: string[] | null, reasoningMode?: "auto" | "enabled" | "disabled", reasoningEffort?: string | null, + conversationMode?: ConversationMode, + planBrief?: string, ): Promise { const state = getOrCreate(sessionId); @@ -2123,6 +2130,8 @@ export async function sendTurn( onStreamEnd, reasoningMode, reasoningEffort, + conversationMode, + planBrief, ); if (!wsOk) { state.messages = [ diff --git a/dashboard/src/pages/Chat/hooks/sseHelpers.ts b/dashboard/src/pages/Chat/hooks/sseHelpers.ts index 9933981a9..799d8fe63 100644 --- a/dashboard/src/pages/Chat/hooks/sseHelpers.ts +++ b/dashboard/src/pages/Chat/hooks/sseHelpers.ts @@ -11,6 +11,7 @@ import type { TokenUsage, } from "../../../api/types"; import type { ContentBlockItem } from "../../../utils/messageParser"; +import type { ConversationMode } from "../utils/conversationMode"; export interface ToolCallData { name?: string; @@ -53,6 +54,7 @@ export interface UserComposerContext { model?: string; reasoningMode?: "auto" | "enabled" | "disabled"; reasoningEffort?: string | null; + conversationMode?: ConversationMode; } export interface ChatMessage { @@ -71,6 +73,8 @@ export interface ChatMessage { errorInfo?: ProcessErrorInfo; status?: "streaming" | "done" | "error"; timestamp: number; + /** Plan→Craft silent handoff — omit from chat transcript UI. */ + uiHidden?: boolean; } /** Per-session state held in the chat store's module-scoped Map. */ diff --git a/dashboard/src/pages/Chat/hooks/useChat.ts b/dashboard/src/pages/Chat/hooks/useChat.ts index 259682744..30e40a44b 100644 --- a/dashboard/src/pages/Chat/hooks/useChat.ts +++ b/dashboard/src/pages/Chat/hooks/useChat.ts @@ -24,6 +24,8 @@ import { type ContentBlock, } from "../../../utils/messageParser"; import { normalizeComposerContext } from "../utils/chatMessages"; +import { PLAN_EXECUTE_USER_TRIGGER } from "../utils/planArtifact"; +import type { ConversationMode } from "../utils/conversationMode"; import { resolveMessageTimestampMs } from "../../../utils/formatMessageTime"; import { inferKindFromNameAndMime } from "../utils/chatAttachments"; import { @@ -442,6 +444,12 @@ function convertCallEntries(entries: CallEntry[]): ChatMessage[] { ?.composer_context, ) : undefined, + uiHidden: + entry.role === "user" && + Boolean( + (entry.metadata as Record | null | undefined) + ?.ui_hidden, + ), toolData: tool?.data, usage: normalizeTokenUsage(entry.usage ?? undefined) ?? undefined, metadata: normalizeMessageMetadata(entry.metadata ?? undefined), @@ -637,6 +645,15 @@ function toHistoryContentBlocks(content: unknown): unknown[] { } function isDisplayableHistoryMessage(message: ChatMessage): boolean { + if (message.uiHidden) return false; + // Silent Plan→Craft trigger (in case older servers omitted ui_hidden). + if ( + message.role === "user" && + message.content.trim() === PLAN_EXECUTE_USER_TRIGGER && + !(message.attachments && message.attachments.length > 0) + ) { + return false; + } if (message.hitlData) return true; if (message.toolData) return true; if (message.attachments && message.attachments.length > 0) return true; @@ -653,6 +670,7 @@ export function convertHistoryMessages( timestamp?: number; composer_context?: unknown; inbound_attachments?: unknown; + ui_hidden?: unknown; status?: string; error_code?: string; }>, @@ -666,6 +684,9 @@ export function convertHistoryMessages( if (message.inbound_attachments) { meta.inbound_attachments = message.inbound_attachments; } + if (message.ui_hidden) { + meta.ui_hidden = true; + } return { message_id: message.id, entry_index: `hist-${index}`, @@ -831,20 +852,25 @@ export function useChat( composerContext?: UserComposerContext, reasoningMode?: "auto" | "enabled" | "disabled", reasoningEffort?: string | null, + conversationMode?: ConversationMode, + planBrief?: string, + hideUserMessage?: boolean, ) => { const key = storeKey || stableSessionId; - const userMsg: ChatMessage = { - id: generateId(), - role: "user", - content: text, - attachments: - attachments && attachments.length > 0 ? attachments : undefined, - composerContext, - status: "done", - timestamp: Date.now(), - }; - chatStore.appendUserMessage(key, userMsg); + if (!hideUserMessage) { + const userMsg: ChatMessage = { + id: generateId(), + role: "user", + content: text, + attachments: + attachments && attachments.length > 0 ? attachments : undefined, + composerContext, + status: "done", + timestamp: Date.now(), + }; + chatStore.appendUserMessage(key, userMsg); + } const threadIdForApi = storeKey || @@ -863,6 +889,8 @@ export function useChat( targetAgentIds, reasoningMode, reasoningEffort, + conversationMode, + planBrief, ); }, [stableSessionId], diff --git a/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts b/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts index 6d172ca71..0ae4c8cfe 100644 --- a/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts +++ b/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts @@ -24,7 +24,13 @@ import { peekPendingAttachKnowledgeBaseId, } from "../utils/pendingAttachKnowledgeBase"; import { withDefaultOpenKnowledgeBases } from "../utils/withDefaultOpenKnowledgeBases"; +import { PENDING_THREAD_ID } from "../constants"; import { isPendingThread } from "./useSessions"; +import { + parseConversationMode, + type ConversationMode, +} from "../utils/conversationMode"; +import { resolveSeedConversationMode } from "../utils/seedConversationMode"; export function useChatComposerResources( resolvedAgentId: string | null | undefined, @@ -32,15 +38,15 @@ export function useChatComposerResources( stickyModel?: string | null, stickyReasoningMode?: "auto" | "enabled" | "disabled" | null, stickyReasoningEffort?: string | null, + defaultConversationMode?: ConversationMode | string | null, + defaultKnowledgeBaseIds?: string[] | null, ) { const user = useCurrentUser(); const currentUserId = user?.id ?? null; const { agents } = useAgent(); const expert = agents.find((item) => item.agent_id === resolvedAgentId); const expertMcpServers = expert?.mcp_servers; - const expertKnowledgeBaseIds = expert?.knowledge_base_ids; const expertMcpKey = (expertMcpServers ?? []).join("\0"); - const expertKbKey = (expertKnowledgeBaseIds ?? []).join("\0"); const isNewSession = !activeThreadId || isPendingThread(activeThreadId); const composerTouchedRef = useRef(false); const [selectedConnectors, setSelectedConnectors] = useState([]); @@ -72,6 +78,9 @@ export function useChatComposerResources( "auto" | "enabled" | "disabled" >("auto"); const [reasoningEffort, setReasoningEffort] = useState(null); + const [conversationMode, setConversationMode] = useState( + () => parseConversationMode(defaultConversationMode), + ); const [conversationOverrides, setConversationOverrides] = useState< Record< string, @@ -79,17 +88,80 @@ export function useChatComposerResources( model: string | null; mode: "auto" | "enabled" | "disabled"; effort: string | null; + conversationMode?: ConversationMode; } > >({}); - useEffect(() => { - composerTouchedRef.current = false; - }, [resolvedAgentId]); + // Keep refs so thread-id transitions can stamp the composer mode the user + // already picked on an empty / pending chat (PlanReady P0). + const conversationModeRef = useRef(conversationMode); + conversationModeRef.current = conversationMode; + const selectedModelRef = useRef(selectedModel); + selectedModelRef.current = selectedModel; + const reasoningModeRef = useRef(reasoningMode); + reasoningModeRef.current = reasoningMode; + const reasoningEffortRef = useRef(reasoningEffort); + reasoningEffortRef.current = reasoningEffort; + const prevThreadIdRef = useRef(activeThreadId); + // Seed from agent default, then restore any per-thread override (#616 OCR). + // When a new chat first gets a thread id (empty → pending → real), preserve + // the composer selection instead of wiping it back to the agent default — + // otherwise Plan turns never show PlanReady on the first send. useEffect(() => { - if (isNewSession) composerTouchedRef.current = false; - }, [isNewSession]); + const prevThreadId = prevThreadIdRef.current; + prevThreadIdRef.current = activeThreadId; + const decision = resolveSeedConversationMode({ + activeThreadId, + previousThreadId: prevThreadId, + override: activeThreadId + ? conversationOverrides[activeThreadId]?.conversationMode + : undefined, + agentDefault: defaultConversationMode, + currentComposerMode: conversationModeRef.current, + }); + + if (decision.action === "set") { + setConversationMode(decision.mode); + // Drop the reusable pending bucket so the next new-chat first-send does + // not revive a previous Plan/Ask selection. + if (!activeThreadId) { + setConversationOverrides((current) => { + if (!(PENDING_THREAD_ID in current)) return current; + const next = { ...current }; + delete next[PENDING_THREAD_ID]; + return next; + }); + } + return; + } + + if (decision.action === "stamp-override" && activeThreadId) { + const mode = decision.mode; + setConversationMode(mode); + setConversationOverrides((current) => { + const prev = current[activeThreadId]; + if (prev?.conversationMode === mode) { + return current; + } + return { + ...current, + [activeThreadId]: { + model: prev?.model ?? selectedModelRef.current, + mode: prev?.mode ?? reasoningModeRef.current, + effort: prev?.effort ?? reasoningEffortRef.current, + conversationMode: mode, + }, + }; + }); + } + }, [ + resolvedAgentId, + activeThreadId, + defaultConversationMode, + conversationOverrides, + ]); // Auto = omit turn model; backend applies the expert default. useEffect(() => { @@ -211,6 +283,9 @@ export function useChatComposerResources( return knowledgeBasesApi.list().then((bases) => { if (cancelled) return; setChatKnowledgeBases(bases); + const agentDefaults = (defaultKnowledgeBaseIds ?? []).filter((id) => + bases.some((base) => base.id === id), + ); const ownedDefaults = bases .filter( (base) => @@ -219,34 +294,16 @@ export function useChatComposerResources( base.owner_user_id === currentUserId, ) .map((base) => base.id); - const allowed = new Set(bases.map((base) => base.id)); - const defaults = withDefaultOpenKnowledgeBases( - ownedDefaults, - (expertKnowledgeBaseIds ?? []).filter((id) => allowed.has(id)), - ); - setSelectedKnowledgeBaseIds((previous) => { - if (composerTouchedRef.current) { - return pendingId && - allowed.has(pendingId) && - !previous.includes(pendingId) - ? [...previous, pendingId] - : previous; - } - if (isNewSession) { - return withDefaultOpenKnowledgeBases( - pendingId && allowed.has(pendingId) ? [pendingId] : [], - defaults, - ); - } - return withDefaultOpenKnowledgeBases( - pendingId && - allowed.has(pendingId) && - !previous.includes(pendingId) + const seedDefaults = + agentDefaults.length > 0 ? agentDefaults : ownedDefaults; + setSelectedKnowledgeBaseIds((previous) => + withDefaultOpenKnowledgeBases( + pendingId && !previous.includes(pendingId) ? [...previous, pendingId] : previous, - ownedDefaults, - ); - }); + seedDefaults, + ), + ); if (pendingId) consumePendingAttachKnowledgeBaseId(); }); }) @@ -256,7 +313,7 @@ export function useChatComposerResources( return () => { cancelled = true; }; - }, [resolvedAgentId, currentUserId, isNewSession, expertKbKey]); + }, [resolvedAgentId, currentUserId, defaultKnowledgeBaseIds]); useEffect(() => { let cancelled = false; @@ -330,6 +387,26 @@ export function useChatComposerResources( setSelectedKnowledgeBaseIds(ids); }, []); + const handleConversationModeChange = useCallback( + (mode: ConversationMode) => { + setConversationMode(mode); + if (!activeThreadId) return; + setConversationOverrides((current) => { + const prev = current[activeThreadId]; + return { + ...current, + [activeThreadId]: { + model: prev?.model ?? selectedModel, + mode: prev?.mode ?? reasoningMode, + effort: prev?.effort ?? reasoningEffort, + conversationMode: mode, + }, + }; + }); + }, + [activeThreadId, selectedModel, reasoningMode, reasoningEffort], + ); + const handleModelChange = useCallback( (model: string | null) => { setSelectedModel(model); @@ -342,14 +419,18 @@ export function useChatComposerResources( setReasoningMode(nextMode); setReasoningEffort(nextEffort); if (activeThreadId) { - setConversationOverrides((current) => ({ - ...current, - [activeThreadId]: { - model, - mode: nextMode, - effort: nextEffort, - }, - })); + setConversationOverrides((current) => { + const prev = current[activeThreadId]; + return { + ...current, + [activeThreadId]: { + model, + mode: nextMode, + effort: nextEffort, + conversationMode: prev?.conversationMode, + }, + }; + }); } if ( resolvedAgentId && @@ -371,14 +452,18 @@ export function useChatComposerResources( setReasoningMode(mode); setReasoningEffort(effort); if (activeThreadId) { - setConversationOverrides((current) => ({ - ...current, - [activeThreadId]: { - model: selectedModel, - mode, - effort, - }, - })); + setConversationOverrides((current) => { + const prev = current[activeThreadId]; + return { + ...current, + [activeThreadId]: { + model: selectedModel, + mode, + effort, + conversationMode: prev?.conversationMode, + }, + }; + }); } if ( resolvedAgentId && @@ -400,6 +485,8 @@ export function useChatComposerResources( reasoningMode, reasoningEffort, handleReasoningChange, + conversationMode, + handleConversationModeChange, selectedConnectors, selectedKnowledgeBaseIds, chatConnectors, diff --git a/dashboard/src/pages/Chat/hooks/useChatSend.ts b/dashboard/src/pages/Chat/hooks/useChatSend.ts index 9273e6b1a..d3a8ece25 100644 --- a/dashboard/src/pages/Chat/hooks/useChatSend.ts +++ b/dashboard/src/pages/Chat/hooks/useChatSend.ts @@ -13,6 +13,7 @@ import { buildUserMessage, resolveTurnModelRef, } from "../utils/chatMessages"; +import type { ConversationMode } from "../utils/conversationMode"; interface UseChatSendParams { resolvedAgentId: string | null | undefined; @@ -25,6 +26,7 @@ interface UseChatSendParams { selectedTargetAgents?: string[]; reasoningMode: "auto" | "enabled" | "disabled"; reasoningEffort: string | null; + conversationMode: ConversationMode; defaultModel?: string | null; sendMessage: ( text: string, @@ -39,6 +41,9 @@ interface UseChatSendParams { composerContext?: UserComposerContext, reasoningMode?: "auto" | "enabled" | "disabled", reasoningEffort?: string | null, + conversationMode?: ConversationMode, + planBrief?: string, + hideUserMessage?: boolean, ) => void; createSession: () => { session: Session; resolvedId: Promise }; renameSession: (id: string, name: string) => void; @@ -59,6 +64,11 @@ export type ChatSendOverrides = { selectedTargetAgents?: string[]; composerContext?: UserComposerContext; modelRef?: string | null; + conversationMode?: ConversationMode; + /** Internal plan brief for Craft handoff — not shown as chat text. */ + planBrief?: string; + /** Do not append a local user bubble (PlanReady execute). */ + hideUserMessage?: boolean; /** Send to this thread instead of the active one (queued flush). */ threadId?: string | null; /** Send as this agent instead of the active one (queued flush). */ @@ -76,6 +86,7 @@ export function useChatSend({ selectedTargetAgents = [], reasoningMode, reasoningEffort, + conversationMode, defaultModel, sendMessage, createSession, @@ -99,9 +110,14 @@ export function useChatSend({ } const trimmed = text.trim(); - if (!trimmed && !(attachments && attachments.length > 0)) return false; + const planBrief = overrides?.planBrief?.trim() || ""; + const hideUserMessage = Boolean(overrides?.hideUserMessage); + if (!trimmed && !(attachments && attachments.length > 0) && !planBrief) { + return false; + } const maybeRenameNewThread = (tid: string, hadMessages: boolean) => { + if (!trimmed) return; const current = sessions.find((s) => s.id === tid); if (current?.name === "New Chat" && !hadMessages) { renameSession(tid, deriveThreadTitle(trimmed)); @@ -129,6 +145,10 @@ export function useChatSend({ overrides?.composerContext?.reasoningMode ?? reasoningMode, reasoningEffort: overrides?.composerContext?.reasoningEffort ?? reasoningEffort, + conversationMode: + overrides?.composerContext?.conversationMode ?? + overrides?.conversationMode ?? + conversationMode, }); const modelOverride = @@ -151,6 +171,9 @@ export function useChatSend({ composerContext, composerContext?.reasoningMode ?? reasoningMode, composerContext?.reasoningEffort ?? reasoningEffort, + composerContext?.conversationMode ?? conversationMode, + overrides?.planBrief, + hideUserMessage, ); }; @@ -170,6 +193,11 @@ export function useChatSend({ return true; } + // Silent plan execute requires an existing thread (no empty-bubble draft). + if (hideUserMessage || planBrief) { + return false; + } + const userMsg = buildUserMessage(trimmed, attachments, composerContext); chatStore.appendUserMessage(EMPTY_CHAT_SESSION_KEY, userMsg); @@ -210,6 +238,8 @@ export function useChatSend({ targetAgents, composerContext?.reasoningMode ?? reasoningMode, composerContext?.reasoningEffort ?? reasoningEffort, + composerContext?.conversationMode ?? conversationMode, + overrides?.planBrief, ); navigate(`/chat/${agent}/${tid}`, { replace: true }); }); @@ -230,6 +260,7 @@ export function useChatSend({ selectedTargetAgents, reasoningMode, reasoningEffort, + conversationMode, defaultModel, t, ], diff --git a/dashboard/src/pages/Chat/index.tsx b/dashboard/src/pages/Chat/index.tsx index 836d419fb..758d6d1c5 100644 --- a/dashboard/src/pages/Chat/index.tsx +++ b/dashboard/src/pages/Chat/index.tsx @@ -81,6 +81,12 @@ import ChatSidebarPanel from "./components/ChatSidebarPanel"; import ChatTitleBar from "./components/ChatTitleBar"; import ChatComposerChrome from "./components/ChatComposerChrome"; import AskQuestionCard from "./components/AskQuestionCard"; +import PlanReadyCard from "./components/PlanReadyCard"; +import { + buildPlanBriefFromMessages, + planContinueHandoff, + planExecuteHandoff, +} from "./utils/planArtifact"; import { extractAskQuestions, isAskHitl } from "../../api/types/hitl"; import { isAgentChatReady } from "../../utils/agentError"; import { useMemoryMaintenance } from "./hooks/useMemoryMaintenance"; @@ -441,6 +447,8 @@ function ChatPageInner() { reasoningMode, reasoningEffort, handleReasoningChange, + conversationMode, + handleConversationModeChange, handleConnectorsChange, handleKnowledgeBaseIdsChange, } = useChatComposerResources( @@ -449,6 +457,8 @@ function ChatPageInner() { composerSession?.modelRef, composerSession?.reasoningMode, composerSession?.reasoningEffort, + activeAgent?.default_conversation_mode, + activeAgent?.default_knowledge_base_ids, ); const { contextMaxTokens, contextUsedTokens } = useChatContextWindow( @@ -546,6 +556,7 @@ function ChatPageInner() { selectedKnowledgeBaseIds, reasoningMode, reasoningEffort, + conversationMode, defaultModel: activeAgent?.default_model ?? null, sendMessage, createSession, @@ -569,7 +580,13 @@ function ChatPageInner() { setBrowserLastRecordingId, }); - // Wrap handleSend to intercept skill recording workflow keywords + const [planReadyBrief, setPlanReadyBrief] = useState(null); + const wasStreamingRef = useRef(false); + const planModeTurnRef = useRef(false); + + // Wrap handleSend to intercept skill recording workflow keywords. + // Also latch plan-mode at send time so PlanReady still appears if the + // composer reseeds while the new thread id is assigned mid-turn. const wrappedHandleSend = useCallback( ( text: string, @@ -580,11 +597,56 @@ function ChatPageInner() { // The workflow intercepted the message — don't send it to the agent return; } + const modeForTurn = + overrides?.conversationMode ?? + overrides?.composerContext?.conversationMode ?? + conversationMode; + if (modeForTurn === "plan") { + planModeTurnRef.current = true; + } handleSend(text, attachments, overrides); }, - [interceptUserMessage, handleSend], + [interceptUserMessage, handleSend, conversationMode], ); + useEffect(() => { + if (conversationMode === "plan" && isStreaming) { + planModeTurnRef.current = true; + } + }, [conversationMode, isStreaming]); + + useEffect(() => { + const wasStreaming = wasStreamingRef.current; + wasStreamingRef.current = isStreaming; + if (!wasStreaming || isStreaming) return; + if (!planModeTurnRef.current) return; + planModeTurnRef.current = false; + const brief = buildPlanBriefFromMessages(messages); + if (brief) setPlanReadyBrief(brief); + }, [isStreaming, messages]); + + useEffect(() => { + setPlanReadyBrief(null); + }, [activeThreadId, resolvedAgentId]); + + const handlePlanExecute = useCallback(() => { + if (!planReadyBrief) return; + const brief = planReadyBrief; + setPlanReadyBrief(null); + const handoff = planExecuteHandoff(brief); + handleConversationModeChange(handoff.conversationMode); + wrappedHandleSend(handoff.text, undefined, { + conversationMode: handoff.conversationMode, + planBrief: handoff.planBrief, + hideUserMessage: handoff.hideUserMessage, + }); + }, [planReadyBrief, handleConversationModeChange, wrappedHandleSend]); + + const handlePlanContinue = useCallback(() => { + setPlanReadyBrief(null); + handleConversationModeChange(planContinueHandoff().conversationMode); + }, [handleConversationModeChange]); + const flushQueuedItem = useCallback( (item: QueuedChatItem, ctx: ChatQueueFlushContext): boolean => { if (!ctx.threadId) { @@ -1351,6 +1413,13 @@ function ChatPageInner() {
) : null} + {planReadyBrief && !pendingAsk && !isStreaming ? ( + + ) : null} { + it("includes conversationMode in composer context (M15 payload seed)", () => { + const ctx = buildComposerContext({ + conversationMode: "ask", + }); + expect(ctx).toEqual({ conversationMode: "ask" }); + }); + + it("omits conversationMode when unset", () => { + const ctx = buildComposerContext({ skills: ["a"] }); + expect(ctx?.conversationMode).toBeUndefined(); + }); +}); + +describe("normalizeComposerContext conversationMode", () => { + it("keeps conversationMode from history payloads", () => { + const ctx = normalizeComposerContext({ + skills: ["a"], + conversationMode: "plan", + }); + expect(ctx).toEqual({ skills: ["a"], conversationMode: "plan" }); + }); +}); diff --git a/dashboard/src/pages/Chat/utils/chatMessages.ts b/dashboard/src/pages/Chat/utils/chatMessages.ts index 552362cbd..2f5d638f9 100644 --- a/dashboard/src/pages/Chat/utils/chatMessages.ts +++ b/dashboard/src/pages/Chat/utils/chatMessages.ts @@ -5,6 +5,7 @@ import type { ChatMessage, UserComposerContext, } from "../hooks/sseHelpers"; +import type { ConversationMode } from "./conversationMode"; export function normalizeComposerContext( value: unknown, @@ -48,6 +49,14 @@ export function normalizeComposerContext( ctx.reasoningEffort = raw.reasoningEffort.trim(); has = true; } + if ( + raw.conversationMode === "ask" || + raw.conversationMode === "plan" || + raw.conversationMode === "craft" + ) { + ctx.conversationMode = raw.conversationMode; + has = true; + } return has ? ctx : undefined; } @@ -88,6 +97,7 @@ export function buildComposerContext(params: { selectedModel?: string | null; reasoningMode?: "auto" | "enabled" | "disabled"; reasoningEffort?: string | null; + conversationMode?: ConversationMode; }): UserComposerContext | undefined { const ctx: UserComposerContext = {}; let has = false; @@ -122,6 +132,10 @@ export function buildComposerContext(params: { ctx.reasoningEffort = params.reasoningEffort; has = true; } + if (params.conversationMode) { + ctx.conversationMode = params.conversationMode; + has = true; + } return has ? ctx : undefined; } diff --git a/dashboard/src/pages/Chat/utils/conversationMode.test.ts b/dashboard/src/pages/Chat/utils/conversationMode.test.ts new file mode 100644 index 000000000..f3619e02f --- /dev/null +++ b/dashboard/src/pages/Chat/utils/conversationMode.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_CONVERSATION_MODE, + parseConversationMode, +} from "./conversationMode"; + +describe("parseConversationMode", () => { + it("accepts ask / plan / craft", () => { + expect(parseConversationMode("ask")).toBe("ask"); + expect(parseConversationMode("plan")).toBe("plan"); + expect(parseConversationMode("craft")).toBe("craft"); + }); + + it("defaults unknown / null to craft", () => { + expect(parseConversationMode(null)).toBe(DEFAULT_CONVERSATION_MODE); + expect(parseConversationMode(undefined)).toBe("craft"); + expect(parseConversationMode("agent")).toBe("craft"); + }); +}); diff --git a/dashboard/src/pages/Chat/utils/conversationMode.ts b/dashboard/src/pages/Chat/utils/conversationMode.ts new file mode 100644 index 000000000..54cd3bb77 --- /dev/null +++ b/dashboard/src/pages/Chat/utils/conversationMode.ts @@ -0,0 +1,46 @@ +/** Conversation permission modes (#616): Ask / Plan / Craft. */ + +import type { TFunction } from "i18next"; + +export type ConversationMode = "ask" | "plan" | "craft"; + +export const DEFAULT_CONVERSATION_MODE: ConversationMode = "craft"; + +const VALID: ReadonlySet = new Set(["ask", "plan", "craft"]); + +/** Composer options — default-first (WorkBuddy-style menu). */ +export const COMPOSER_CONVERSATION_MODES: readonly ConversationMode[] = [ + "craft", + "plan", + "ask", +] as const; + +export function parseConversationMode( + value: unknown, + fallback: ConversationMode = DEFAULT_CONVERSATION_MODE, +): ConversationMode { + if (typeof value === "string" && VALID.has(value)) { + return value as ConversationMode; + } + return fallback; +} + +/** Ant Design Select options shared by Experts create/edit drawers. */ +export function conversationModeSelectOptions( + t: TFunction, +): Array<{ value: ConversationMode; label: string }> { + return [ + { + value: "craft", + label: t("chat.conversationModeCraft", "默认"), + }, + { + value: "plan", + label: t("chat.conversationModePlan", "计划"), + }, + { + value: "ask", + label: t("chat.conversationModeAsk", "问答"), + }, + ]; +} diff --git a/dashboard/src/pages/Chat/utils/planArtifact.test.ts b/dashboard/src/pages/Chat/utils/planArtifact.test.ts new file mode 100644 index 000000000..c81bb7ef1 --- /dev/null +++ b/dashboard/src/pages/Chat/utils/planArtifact.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from "vitest"; +import type { ChatMessage } from "../hooks/useChat"; +import { + buildPlanBriefFromMessages, + formatPlanBrief, + planBriefPreview, + planContinueHandoff, + planExecuteHandoff, + stripConversationModeUiInstructions, +} from "./planArtifact"; + +describe("planArtifact (#616 P5/P7)", () => { + it("formats brief with summary and todos", () => { + const brief = formatPlanBrief({ + summary: "Ship modes.", + todos: [{ id: "1", content: "Ask overlay", status: "completed" }], + }); + expect(brief).toContain("Approved plan"); + expect(brief).toContain("Ship modes."); + expect(brief).toContain("1. Ask overlay (completed)"); + expect(brief).toContain("Execute this plan now."); + }); + + it("planBriefPreview drops headers and truncates", () => { + const brief = formatPlanBrief({ + summary: "Do the thing.", + todos: [{ id: "1", content: "Step one", status: "pending" }], + }); + const preview = planBriefPreview(brief); + expect(preview).toContain("Do the thing."); + expect(preview).toContain("Step one"); + expect(preview).not.toContain("Approved plan"); + expect(preview).not.toMatch(/Execute this plan now/i); + }); + + it("planExecuteHandoff switches to craft with silent planBrief metadata", () => { + const handoff = planExecuteHandoff(formatPlanBrief({ summary: "Go." })); + expect(handoff.conversationMode).toBe("craft"); + expect(handoff.hideUserMessage).toBe(true); + expect(handoff.text).toBe("Execute the approved plan now."); + expect(handoff.planBrief).toContain("Go."); + expect(handoff.planBrief).toContain("Approved plan"); + }); + + it("planContinueHandoff keeps plan mode (P8)", () => { + expect(planContinueHandoff()).toEqual({ conversationMode: "plan" }); + }); + + it("strips mode-switch UI instructions from assistant copy", () => { + const raw = [ + "当前仍在计划模式,edit_file 等修改类工具不可用。", + "", + "需要你手动切换到默认(Craft / 做一做)模式后,我才能直接修 bug 和跑测试。", + "", + "切换入口:输入框旁边的模式选择按钮,从「计划」切回默认即可。", + "", + "脚本将用 awk 采集 CPU。", + ].join("\n"); + const cleaned = stripConversationModeUiInstructions(raw); + expect(cleaned).toContain("awk"); + expect(cleaned).not.toMatch(/手动切换/); + expect(cleaned).not.toMatch(/模式选择按钮/); + expect( + stripConversationModeUiInstructions( + [ + "当前仍在计划模式,edit_file 等修改类工具不可用。", + "", + "需要你手动切换到默认(Craft / 做一做)模式。", + "", + "切换入口:输入框旁边的模式选择按钮。", + ].join("\n"), + ), + ).toBe(""); + }); + + it("keeps ordinary mentions of chat input", () => { + expect( + stripConversationModeUiInstructions( + "You can paste content into the chat input area.", + ), + ).toContain("chat input"); + }); + + it("buildPlanBriefFromMessages merges assistant text and write_todos", () => { + const messages: ChatMessage[] = [ + { + id: "1", + role: "user", + content: "plan", + status: "done", + timestamp: 1, + }, + { + id: "2", + role: "assistant", + content: "", + toolData: { + name: "write_todos", + arguments: JSON.stringify({ + todos: [{ id: "1", content: "Write tests", status: "pending" }], + }), + }, + status: "done", + timestamp: 2, + }, + { + id: "3", + role: "assistant", + content: "Here is the plan.", + status: "done", + timestamp: 3, + }, + ]; + const brief = buildPlanBriefFromMessages(messages); + expect(brief).toContain("Here is the plan."); + expect(brief).toContain("Write tests"); + }); + + it("buildPlanBriefFromMessages ignores short replies without todos", () => { + const messages: ChatMessage[] = [ + { + id: "1", + role: "user", + content: "plan", + status: "done", + timestamp: 1, + }, + { + id: "2", + role: "assistant", + content: "好的,我先想想。", + status: "done", + timestamp: 2, + }, + ]; + expect(buildPlanBriefFromMessages(messages)).toBeNull(); + }); + + it("buildPlanBriefFromMessages ignores stale write_todos from earlier turns", () => { + const messages: ChatMessage[] = [ + { + id: "1", + role: "user", + content: "plan a file", + status: "done", + timestamp: 1, + }, + { + id: "2", + role: "assistant", + content: "", + toolData: { + name: "write_todos", + arguments: JSON.stringify({ + todos: [{ id: "1", content: "Write tests", status: "pending" }], + }), + }, + status: "done", + timestamp: 2, + }, + { + id: "3", + role: "assistant", + content: + "Here is a detailed plan with plenty of substance for the card.", + status: "done", + timestamp: 3, + }, + { + id: "4", + role: "user", + content: "嗯", + status: "done", + timestamp: 4, + }, + { + id: "5", + role: "assistant", + content: "好的", + status: "done", + timestamp: 5, + }, + ]; + expect(buildPlanBriefFromMessages(messages)).toBeNull(); + }); +}); diff --git a/dashboard/src/pages/Chat/utils/planArtifact.ts b/dashboard/src/pages/Chat/utils/planArtifact.ts new file mode 100644 index 000000000..1ec8d4422 --- /dev/null +++ b/dashboard/src/pages/Chat/utils/planArtifact.ts @@ -0,0 +1,157 @@ +import type { TodoListItem } from "../../../utils/parseWriteTodos"; +import { + collectWriteTodosFromMessages, + type WriteTodosMessageSource, +} from "../../../utils/parseWriteTodos"; +import type { ChatMessage } from "../hooks/useChat"; +import { deriveMessageContent } from "./messageContent"; + +/** + * Lines that tell the user how to operate Plan/Craft UI — never show in chat. + * Keep patterns mode-specific so ordinary assistant prose is not stripped. + */ +const MODE_UI_INSTRUCTION_RE = + /手动切换|模式选择按钮|输入框旁边|切换入口|切换到\s*默认\s*模式|切换到\s*做一做|切回默认|请.{0,24}切换.{0,24}模式|switch to (?:default|craft)\s+mode|mode (?:selection )?button|(?:near|beside|next to) (?:the )?chat input|做一做[))]?\s*模式|Craft\s*[//]\s*做一做|修改类工具不可用|仍在计划模式/i; + +/** + * Remove assistant copy that instructs the user to flip the composer mode + * control. Product UI (PlanReadyCard) owns that handoff. + */ +export function stripConversationModeUiInstructions(text: string): string { + const raw = text.replace(/\r\n/g, "\n").trim(); + if (!raw) return ""; + + const blocks = raw.split(/\n{2,}/); + const kept = blocks.filter((block) => { + const compact = block.replace(/\s+/g, ""); + if (!compact) return false; + if (MODE_UI_INSTRUCTION_RE.test(block)) return false; + // Whole block is only a mode-name reminder. + if ( + /^(当前)?(仍?在)?计划|仅问答|问答|Ask|Plan|Craft/.test(compact) && + compact.length < 40 + ) { + return !/模式|mode|不可用|unavailable/i.test(block); + } + return true; + }); + + // Also drop leftover single lines that match. + const lines = kept.join("\n\n").split("\n"); + const cleaned = lines + .filter((line) => !MODE_UI_INSTRUCTION_RE.test(line)) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + return cleaned; +} + +export function formatPlanBrief(params: { + summary?: string; + todos?: TodoListItem[]; +}): string { + const lines: string[] = ["## Approved plan", ""]; + const summary = stripConversationModeUiInstructions( + params.summary || "", + ).trim(); + if (summary) { + lines.push(summary, ""); + } + const todos = params.todos || []; + if (todos.length > 0) { + lines.push("### Steps"); + todos.forEach((todo, index) => { + const status = todo.status ? ` (${todo.status})` : ""; + lines.push(`${index + 1}. ${todo.content}${status}`); + }); + lines.push(""); + } + lines.push("Execute this plan now."); + return `${lines.join("\n").trim()}\n`; +} + +/** Short preview of an approved brief for PlanReadyCard (drop headers / footer). */ +export function planBriefPreview(brief: string, maxLen = 320): string { + const lines = brief + .replace(/\r\n/g, "\n") + .split("\n") + .map((line) => line.trim()) + .filter( + (line) => + Boolean(line) && + !/^#{1,6}\s/.test(line) && + !/^Execute this plan now\.?$/i.test(line), + ); + const text = lines.join("\n").trim(); + if (!text) return ""; + if (text.length <= maxLen) return text; + return `${text.slice(0, maxLen).trimEnd()}…`; +} + +/** Collect plan brief from the latest assistant turn (text + write_todos). */ +export const PLAN_READY_MIN_SUMMARY_CHARS = 80; + +/** True when a built brief is substantial enough to show PlanReadyCard. */ +export function isPlanReadyBrief(brief: string | null | undefined): boolean { + if (!brief?.trim()) return false; + const hasSteps = /^### Steps$/m.test(brief) && /^\d+\. /m.test(brief); + if (hasSteps) return true; + const preview = planBriefPreview(brief, Number.MAX_SAFE_INTEGER); + return preview.trim().length >= PLAN_READY_MIN_SUMMARY_CHARS; +} + +/** Collect plan brief from the latest assistant turn (text + write_todos). */ +export function buildPlanBriefFromMessages( + messages: readonly ChatMessage[], +): string | null { + // Only the latest user→assistant turn. Older write_todos must not resurrect + // PlanReady when the model later replies with a short ack ("好的"). + let lastUserIdx = -1; + for (let i = messages.length - 1; i >= 0; i -= 1) { + if (messages[i]?.role === "user") { + lastUserIdx = i; + break; + } + } + const turn = messages.slice(lastUserIdx + 1); + const todos = collectWriteTodosFromMessages( + turn as unknown as WriteTodosMessageSource[], + ); + let summary = ""; + for (let i = turn.length - 1; i >= 0; i -= 1) { + const msg = turn[i]; + if (!msg || msg.role !== "assistant" || msg.toolData) continue; + summary = stripConversationModeUiInstructions( + deriveMessageContent(msg).textContent, + ).trim(); + if (summary) break; + } + if (!summary && todos.length === 0) return null; + const brief = formatPlanBrief({ summary, todos }); + return isPlanReadyBrief(brief) ? brief : null; +} + +/** Minimal user-turn text for silent Plan→Craft (matched on history reload). */ +export const PLAN_EXECUTE_USER_TRIGGER = "Execute the approved plan now."; + +/** P7: Execute plan → Craft mode; brief goes via metadata (not chat text). */ +export function planExecuteHandoff(brief: string): { + conversationMode: "craft"; + /** Silent send — no visible user bubble; planBrief drives the turn. */ + hideUserMessage: true; + /** Non-empty WS text so servers that only check text still accept the turn. */ + text: string; + planBrief: string; +} { + return { + conversationMode: "craft", + hideUserMessage: true, + text: PLAN_EXECUTE_USER_TRIGGER, + planBrief: brief.trim(), + }; +} + +/** P8: Keep planning → stay in Plan mode. */ +export function planContinueHandoff(): { conversationMode: "plan" } { + return { conversationMode: "plan" }; +} diff --git a/dashboard/src/pages/Chat/utils/seedConversationMode.test.ts b/dashboard/src/pages/Chat/utils/seedConversationMode.test.ts new file mode 100644 index 000000000..5a6c3924f --- /dev/null +++ b/dashboard/src/pages/Chat/utils/seedConversationMode.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { PENDING_THREAD_ID } from "../constants"; +import { resolveSeedConversationMode } from "./seedConversationMode"; + +describe("resolveSeedConversationMode", () => { + it("reseeds agent default on empty new-chat composer", () => { + expect( + resolveSeedConversationMode({ + activeThreadId: null, + previousThreadId: "thr_old", + agentDefault: "craft", + currentComposerMode: "plan", + }), + ).toEqual({ action: "set", mode: "craft" }); + }); + + it("restores per-thread override when present", () => { + expect( + resolveSeedConversationMode({ + activeThreadId: "thr_a", + previousThreadId: "thr_b", + override: "ask", + agentDefault: "craft", + currentComposerMode: "plan", + }), + ).toEqual({ action: "set", mode: "ask" }); + }); + + it("stamps composer mode when empty chat gets a real thread id", () => { + expect( + resolveSeedConversationMode({ + activeThreadId: "thr_new", + previousThreadId: null, + agentDefault: "ask", + currentComposerMode: "plan", + }), + ).toEqual({ action: "stamp-override", mode: "plan" }); + }); + + it("stamps composer mode when pending chat resolves to a real thread id", () => { + expect( + resolveSeedConversationMode({ + activeThreadId: "thr_new", + previousThreadId: PENDING_THREAD_ID, + agentDefault: "craft", + currentComposerMode: "plan", + }), + ).toEqual({ action: "stamp-override", mode: "plan" }); + }); + + it("ignores stale pending override and stamps current composer mode", () => { + expect( + resolveSeedConversationMode({ + activeThreadId: PENDING_THREAD_ID, + previousThreadId: null, + override: "plan", + agentDefault: "craft", + currentComposerMode: "ask", + }), + ).toEqual({ action: "stamp-override", mode: "ask" }); + }); + + it("reseeds agent default when switching between existing threads", () => { + expect( + resolveSeedConversationMode({ + activeThreadId: "thr_b", + previousThreadId: "thr_a", + agentDefault: "craft", + currentComposerMode: "plan", + }), + ).toEqual({ action: "set", mode: "craft" }); + }); +}); diff --git a/dashboard/src/pages/Chat/utils/seedConversationMode.ts b/dashboard/src/pages/Chat/utils/seedConversationMode.ts new file mode 100644 index 000000000..070bc6e88 --- /dev/null +++ b/dashboard/src/pages/Chat/utils/seedConversationMode.ts @@ -0,0 +1,56 @@ +import { isPendingThread } from "../hooks/useSessions"; +import { + parseConversationMode, + type ConversationMode, +} from "./conversationMode"; + +export type SeedConversationModeResult = + | { action: "set"; mode: ConversationMode } + | { action: "stamp-override"; mode: ConversationMode } + | { action: "keep" }; + +/** + * Decide how the composer conversation mode should react when the active + * thread id (or agent default) changes. + * + * Critical path: empty / pending chat → first real thread id must **stamp** + * the mode the user already picked, not wipe back to the agent default. + * Otherwise a Plan first-send never shows PlanReady. + */ +export function resolveSeedConversationMode(params: { + activeThreadId?: string | null; + previousThreadId?: string | null; + override?: ConversationMode; + agentDefault?: ConversationMode | string | null; + currentComposerMode: ConversationMode; +}): SeedConversationModeResult { + const { + activeThreadId, + previousThreadId, + override, + agentDefault, + currentComposerMode, + } = params; + + if (!activeThreadId) { + return { action: "set", mode: parseConversationMode(agentDefault) }; + } + + // `__pending__` is reused for every new chat. Never restore a stale override + // from a previous first-send — always stamp the composer mode the user has + // now. Same when empty composer first receives a real thread id. + const fromEmptyComposer = + previousThreadId == null || + previousThreadId === "" || + isPendingThread(previousThreadId) || + isPendingThread(activeThreadId); + if (fromEmptyComposer) { + return { action: "stamp-override", mode: currentComposerMode }; + } + + if (override !== undefined) { + return { action: "set", mode: override }; + } + + return { action: "set", mode: parseConversationMode(agentDefault) }; +} diff --git a/dashboard/src/pages/Experts/components/CreateFromExpertDrawer.tsx b/dashboard/src/pages/Experts/components/CreateFromExpertDrawer.tsx index fae7d0fb3..bc0cca170 100644 --- a/dashboard/src/pages/Experts/components/CreateFromExpertDrawer.tsx +++ b/dashboard/src/pages/Experts/components/CreateFromExpertDrawer.tsx @@ -59,6 +59,8 @@ import AgentBackendFields from "./AgentBackendFields"; import ExpertAvatarPicker from "./ExpertAvatarPicker"; import ExpertComposerDefaultsFields from "./ExpertComposerDefaultsFields"; import styles from "../index.module.less"; +import { conversationModeSelectOptions } from "../../Chat/utils/conversationMode"; +import { knowledgeBasesApi } from "../../../api/modules/knowledgeBases"; type FileContent = NamedFileContent; @@ -158,6 +160,8 @@ export default function CreateFromExpertDrawer({ agent_id?: string; welcome_message?: string; default_model: string; + default_conversation_mode: "ask" | "plan" | "craft"; + default_knowledge_base_ids?: string[]; backend_choice: string; composite_default: string; root_dir?: string; @@ -181,6 +185,9 @@ export default function CreateFromExpertDrawer({ const [colorPalette, setColorPalette] = useState("rose"); const [avatarFile, setAvatarFile] = useState(null); const [avatarPreview, setAvatarPreview] = useState(null); + const [knowledgeBaseOptions, setKnowledgeBaseOptions] = useState< + Array<{ value: string; label: string }> + >([]); const backendChoice = Form.useWatch("backend_choice", form) ?? DEFAULT_BACKEND; @@ -190,6 +197,28 @@ export default function CreateFromExpertDrawer({ rootDir: watchedRootDir, }); + useEffect(() => { + if (!open) return; + let cancelled = false; + void knowledgeBasesApi + .list() + .then((bases) => { + if (cancelled) return; + setKnowledgeBaseOptions( + bases.map((base) => ({ + value: base.id, + label: base.name?.trim() || base.id, + })), + ); + }) + .catch(() => { + if (!cancelled) setKnowledgeBaseOptions([]); + }); + return () => { + cancelled = true; + }; + }, [open]); + useEffect(() => { if (skillPackagesSupported) return; form.setFieldsValue({ skill_package_ids: [] }); @@ -220,6 +249,8 @@ export default function CreateFromExpertDrawer({ agent_id: undefined, welcome_message: defaults.welcome_message, default_model: MODEL_AUTO_VALUE, + default_conversation_mode: "craft", + default_knowledge_base_ids: [], backend_choice: DEFAULT_BACKEND, composite_default: DEFAULT_BACKEND, skill_package_ids: [], @@ -343,6 +374,8 @@ export default function CreateFromExpertDrawer({ ...(welcomeText ? { welcome_message: welcomeText } : {}), ...buildAgentRuntimeRequest(values), enable_trajectory: values.enable_trajectory === true, + default_conversation_mode: values.default_conversation_mode, + default_knowledge_base_ids: values.default_knowledge_base_ids ?? [], }; let body: { agent_id: string; name: string }; @@ -573,6 +606,36 @@ export default function CreateFromExpertDrawer({ /> + + + + ([]); const [agentConfig, setAgentConfig] = useState>({}); + const [knowledgeBaseOptions, setKnowledgeBaseOptions] = useState< + Array<{ value: string; label: string }> + >([]); const [colorPalette, setColorPalette] = useState( () => parseStoredColor(agent.color) ?? DEFAULT_PALETTE, ); @@ -268,6 +279,27 @@ function EditAgentDrawerBody({ workspaceDir: workspaceDirFromConfig, }); + useEffect(() => { + let cancelled = false; + void knowledgeBasesApi + .list() + .then((bases) => { + if (cancelled) return; + setKnowledgeBaseOptions( + bases.map((base) => ({ + value: base.id, + label: base.name?.trim() || base.id, + })), + ); + }) + .catch(() => { + if (!cancelled) setKnowledgeBaseOptions([]); + }); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { let cancelled = false; setLoading(true); @@ -303,6 +335,23 @@ function EditAgentDrawerBody({ typeof ag.welcome_message === "string" ? ag.welcome_message : "", is_shared: agent.is_shared ?? false, default_model: defaultModelToForm(ag.default_model), + default_conversation_mode: + ag.default_conversation_mode === "ask" || + ag.default_conversation_mode === "plan" || + ag.default_conversation_mode === "craft" + ? ag.default_conversation_mode + : "craft", + default_knowledge_base_ids: Array.isArray( + ag.default_knowledge_base_ids, + ) + ? ag.default_knowledge_base_ids.filter( + (id): id is string => typeof id === "string" && Boolean(id), + ) + : Array.isArray(cfg.default_knowledge_base_ids) + ? (cfg.default_knowledge_base_ids as unknown[]).filter( + (id): id is string => typeof id === "string" && Boolean(id), + ) + : [], backend_choice: parsedBackend.backendChoice, composite_default: parsedBackend.compositeDefault, root_dir: parsedBackend.rootDir, @@ -408,6 +457,8 @@ function EditAgentDrawerBody({ ...agentConfig, backend: backendSpec, enable_trajectory: values.enable_trajectory === true, + default_conversation_mode: values.default_conversation_mode, + default_knowledge_base_ids: values.default_knowledge_base_ids ?? [], }); delete nextConfig.color; delete nextConfig.icon_name; @@ -483,6 +534,8 @@ function EditAgentDrawerBody({ name: values.name, description: values.description || null, default_model: defaultModel, + default_conversation_mode: values.default_conversation_mode, + default_knowledge_base_ids: values.default_knowledge_base_ids ?? [], is_shared: values.is_shared ?? false, color: nextColor, icon_url: iconUrl, @@ -799,6 +852,38 @@ function EditAgentDrawerBody({ } /> + + + ` | owner | Primary dashboard turn endpoint. Send `{"type":"user_turn", ...}` frames; server replies with harness stream chunks ending in `{"type":"done"}` or `{"type":"error","message":"..."}`. `{"type":"ping"}` → `{"type":"pong"}`. `{"type":"subscribe","thread_id"}` → `{"type":"turn_status","thread_id","active"}` (attach to an in-flight turn without cancelling on disconnect). `{"type":"cancel","thread_id"}` stops the active turn (explicit stop; disconnect alone does **not** cancel). | +| `WS /agents/{id}/chat/ws?token=` | owner | Primary dashboard turn endpoint. Send `{"type":"user_turn", …, "conversation_mode"?: "ask"|"plan"|"craft", "plan_brief"?: string}` frames; server replies with harness stream chunks ending in `{"type":"done"}` or `{"type":"error","message":"..."}`. Omit `conversation_mode` → agent `default_conversation_mode` (else craft); IM `/mode` sticky override also applies when meta omits mode. `plan_brief` is injected into the turn system prompt (Craft handoff) and is not required in chat text. `{"type":"ping"}` → `{"type":"pong"}`. `{"type":"subscribe","thread_id"}` → `{"type":"turn_status","thread_id","active"}` (attach to an in-flight turn without cancelling on disconnect). `{"type":"cancel","thread_id"}` stops the active turn (explicit stop; disconnect alone does **not** cancel). | | `GET /agents/{id}/chat/welcome` | agent access | `{welcome_message, quick_prompts, task_examples}`; `task_examples` is `null` when the workspace field is absent | | `POST /agents/{id}/chat/polish` | owner | body `{text, default_model?}` → `{text}` (one-shot prompt refinement) | | `POST /agents/{id}/chat/hitl/resume` | owner | body `{thread_id, decisions: [...]}` → SSE chunk stream; finishes with `{"type":"done"}` | diff --git a/src/octop/api/routers/agents.py b/src/octop/api/routers/agents.py index c74625a8b..ff43f3fdc 100644 --- a/src/octop/api/routers/agents.py +++ b/src/octop/api/routers/agents.py @@ -21,6 +21,10 @@ read_workspace_avatar, write_workspace_avatar, ) +from octop.infra.agents.conversation_mode import ( + default_conversation_mode_from_config, + normalize_config_default_conversation_mode, +) from octop.infra.agents.profile import ( id_list_from_row, parse_config_json, @@ -33,6 +37,10 @@ agent_runtime_values, ) from octop.infra.errors import ErrorCode, OctopError +from octop.infra.knowledge.default_open import ( + default_knowledge_base_ids_from_config, + normalize_config_default_knowledge_base_ids, +) from octop.infra.users.permissions import user_has_permission logger = logging.getLogger(__name__) @@ -40,6 +48,17 @@ router = APIRouter() +def _validated_agent_config(config: dict[str, Any] | None) -> dict[str, Any] | None: + """Normalize conversation-mode / KB default fields; raise OctopError on invalid values.""" + if config is None: + return None + try: + out = normalize_config_default_conversation_mode(config) + return normalize_config_default_knowledge_base_ids(out) + except ValueError as exc: + raise OctopError(ErrorCode.INTERNAL_ERROR, str(exc), status=400) from exc + + class AgentCreateBody(AgentRuntimeFields): name: str description: str | None = None @@ -150,6 +169,8 @@ def _row_dict( "description": row.description, "persona_mbti": row.persona_mbti, "default_model": row.default_model, + "default_conversation_mode": default_conversation_mode_from_config(cfg), + "default_knowledge_base_ids": default_knowledge_base_ids_from_config(cfg), "system_prompt": row.system_prompt, "state": row.last_state or "unknown", "last_error": row.last_error, @@ -258,12 +279,14 @@ async def create_agent( from octop.infra.agents.manager import AgentCreateSpec # noqa: PLC0415 assert server.app_runtime is not None + config = body.config if isinstance(body.config, dict) else {} if isinstance(body.config, dict): assert_user_backend_root_dirs( user, body.config.get("backend"), policy_repo=server.services.user_policy_repo, ) + config = _validated_agent_config(body.config) or {} knowledge_ids = ( server.app_runtime.agent_registry.validate_knowledge_base_ids( user.id, body.knowledge_base_ids @@ -283,7 +306,7 @@ async def create_agent( persona_mbti=body.persona_mbti, default_model=body.default_model, system_prompt=body.system_prompt, - config=body.config, + config=config, runtime_config=runtime_field_updates(body, exclude_unset=True), icon=body.icon, template_name=body.template_name, @@ -376,7 +399,8 @@ async def patch_agent( } } if body.config is not None: - updates["config_json"] = json.dumps(body.config) + validated = _validated_agent_config(body.config if isinstance(body.config, dict) else {}) + updates["config_json"] = json.dumps(validated) if body.welcome_message is not None: updates["welcome_message"] = body.welcome_message if updates: diff --git a/src/octop/api/routers/chat/models.py b/src/octop/api/routers/chat/models.py index 54f6a5804..06a93ef39 100644 --- a/src/octop/api/routers/chat/models.py +++ b/src/octop/api/routers/chat/models.py @@ -46,6 +46,20 @@ class ChatTurnBody(BaseModel): ) reasoning_mode: Literal["auto", "enabled", "disabled"] | None = None reasoning_effort: str | None = None + conversation_mode: Literal["ask", "plan", "craft"] | None = Field( + default=None, + description=( + "Turn permission mode: ask (read-only), plan (plan then confirm), " + "craft (full tools). Omit to default to craft." + ), + ) + plan_brief: str | None = Field( + default=None, + description=( + "Optional approved plan text for a Craft handoff turn. " + "Injected into the system prompt by the gateway — not required in chat text." + ), + ) target_agent_ids: list[str] | None = Field( default=None, description="Optional agent ids to involve via @mention (same user only).", @@ -87,6 +101,14 @@ def from_ws_payload(cls, payload: dict[str, Any]) -> ChatTurnBody: if isinstance(payload.get("reasoning_effort"), str) and str(payload["reasoning_effort"]).strip() else None, + conversation_mode=payload.get("conversation_mode") + if payload.get("conversation_mode") in ("ask", "plan", "craft") + else None, + plan_brief=( + str(payload["plan_brief"]).strip() + if isinstance(payload.get("plan_brief"), str) and str(payload["plan_brief"]).strip() + else None + ), target_agent_ids=( [str(x) for x in payload["target_agent_ids"]] if isinstance(payload.get("target_agent_ids"), list) @@ -107,6 +129,8 @@ class UserTurnWsFrame(BaseModel): default_model: str | None = None reasoning_mode: Literal["auto", "enabled", "disabled"] | None = None reasoning_effort: str | None = None + conversation_mode: Literal["ask", "plan", "craft"] | None = None + plan_brief: str | None = None mcp_servers: list[str] | None = None knowledge_base_ids: list[str] | None = None skills: list[str] | None = None diff --git a/src/octop/api/routers/chat/serialize.py b/src/octop/api/routers/chat/serialize.py index 21f1e9711..3d873a65e 100644 --- a/src/octop/api/routers/chat/serialize.py +++ b/src/octop/api/routers/chat/serialize.py @@ -23,6 +23,7 @@ INBOUND_ATTACHMENTS_KEY, STREAM_ERROR_CODE_KEY, STREAM_ERROR_FLAG, + UI_HIDDEN_KEY, parse_checkpoint_ts_ms, ) from octop.infra.utils.llm_text import strip_thinking as _strip_thinking @@ -1000,6 +1001,8 @@ def _serialize_history_message( entry["composer_context"] = raw_ctx if has_user_attachments: entry["inbound_attachments"] = raw_att + if additional_kwargs.get(UI_HIDDEN_KEY): + entry["ui_hidden"] = True if additional_kwargs.get(STREAM_ERROR_FLAG): entry["status"] = "error" code = additional_kwargs.get(STREAM_ERROR_CODE_KEY) diff --git a/src/octop/api/routers/chat/turn.py b/src/octop/api/routers/chat/turn.py index f8b0d14c6..a46f908dc 100644 --- a/src/octop/api/routers/chat/turn.py +++ b/src/octop/api/routers/chat/turn.py @@ -124,9 +124,11 @@ def _message_content_nonempty(content: Any) -> bool: def turn_has_content(turn: ChatTurnBody) -> bool: - """True when the turn has user text or structured message blocks.""" + """True when the turn has user text, attachments, or a plan_brief handoff.""" if _turn_plain_text(turn): return True + if isinstance(turn.plan_brief, str) and turn.plan_brief.strip(): + return True for msg in reversed(turn.messages): if str(msg.get("role") or "").lower() != "user": continue @@ -172,54 +174,58 @@ def content_parts_from_dashboard_turn(turn: ChatTurnBody) -> list[ContentPart]: user_content = msg.get("content") break - if not isinstance(user_content, list): - return parts - - seen_text = bool(text) - for block in user_content: - if not isinstance(block, dict): - continue - btype = str(block.get("type") or "") - if btype == "text": - t = str(block.get("text") or "").strip() - if t and not seen_text: - parts.append(TextContent(text=t)) - seen_text = True - continue - - if btype not in ("image", "file", "image_url"): - continue - - path = _workspace_path_from_block(block) - if not path: - # Upload always returns workspace_path; ignore preview-only blocks. - continue - - media_type = _mime_from_block(block) - filename = str( - block.get("filename") or block.get("name") or Path(path).name or "attachment" - ) - use_vision = is_vision_attachment( - kind="image" if btype in ("image", "image_url") else btype, - media_type=media_type, - path=path, - ) - if use_vision: - parts.append( - ImageContent( - local_path=path, - mime_type=media_type if media_type.startswith("image/") else "image/png", - alt_text=filename, - ) + if isinstance(user_content, list): + seen_text = bool(text) + for block in user_content: + if not isinstance(block, dict): + continue + btype = str(block.get("type") or "") + if btype == "text": + t = str(block.get("text") or "").strip() + if t and not seen_text: + parts.append(TextContent(text=t)) + seen_text = True + continue + + if btype not in ("image", "file", "image_url"): + continue + + path = _workspace_path_from_block(block) + if not path: + # Upload always returns workspace_path; ignore preview-only blocks. + continue + + media_type = _mime_from_block(block) + filename = str( + block.get("filename") or block.get("name") or Path(path).name or "attachment" ) - else: - parts.append( - FileContent( - local_path=path, - filename=filename, - mime_type=media_type or "application/octet-stream", - ) + use_vision = is_vision_attachment( + kind="image" if btype in ("image", "image_url") else btype, + media_type=media_type, + path=path, ) + if use_vision: + parts.append( + ImageContent( + local_path=path, + mime_type=media_type if media_type.startswith("image/") else "image/png", + alt_text=filename, + ) + ) + else: + parts.append( + FileContent( + local_path=path, + filename=filename, + mime_type=media_type or "application/octet-stream", + ) + ) + + # Silent Plan→Craft: plan lives in metadata / system hint; still need a HumanMessage. + if not parts and isinstance(turn.plan_brief, str) and turn.plan_brief.strip(): + from octop.infra.agents.plan_artifact import PLAN_EXECUTE_USER_TRIGGER # noqa: PLC0415 + + parts.append(TextContent(text=PLAN_EXECUTE_USER_TRIGGER)) return parts @@ -332,6 +338,10 @@ def build_dashboard_inbound( metadata["reasoning_mode"] = turn.reasoning_mode if turn.reasoning_effort: metadata["reasoning_effort"] = turn.reasoning_effort + if turn.conversation_mode is not None: + metadata["conversation_mode"] = turn.conversation_mode + if turn.plan_brief: + metadata["plan_brief"] = turn.plan_brief if prepared.composer_context: metadata[COMPOSER_CTX_KEY] = prepared.composer_context if prepared.inbound_attachments: diff --git a/src/octop/api/routers/experts.py b/src/octop/api/routers/experts.py index 6da5a12ff..442667aba 100644 --- a/src/octop/api/routers/experts.py +++ b/src/octop/api/routers/experts.py @@ -12,7 +12,7 @@ from __future__ import annotations import asyncio -from typing import Any +from typing import Any, Literal from fastapi import APIRouter, Depends, Response from pydantic import BaseModel, Field @@ -25,6 +25,7 @@ display_published_expert_icon_url, read_snapshot_avatar, ) +from octop.infra.agents.conversation_mode import apply_default_conversation_mode from octop.infra.agents.experts.catalog import ( MANIFEST_FILENAME, build_create_spec_from_expert, @@ -63,6 +64,7 @@ fetch_skillset, ) from octop.infra.errors import ErrorCode, OctopError +from octop.infra.knowledge.default_open import apply_default_knowledge_base_ids from octop.infra.trajectory.settings import apply_enable_trajectory from octop.infra.utils.locale import resolve_user_locale @@ -116,6 +118,17 @@ class FromExpertBody(AgentRuntimeFields): ) welcome_message: str | None = None enable_trajectory: bool = True + default_conversation_mode: Literal["ask", "plan", "craft"] | None = Field( + default=None, + description=( + "Initial chat permission mode for new threads " + "(ask / plan / craft). Omit to default to craft." + ), + ) + default_knowledge_base_ids: list[str] | None = Field( + default=None, + description="Knowledge bases auto-attached when a turn omits an explicit list.", + ) class PublishExpertBody(BaseModel): @@ -152,6 +165,17 @@ class InstallPublishedExpertBody(AgentRuntimeFields): ) welcome_message: str | None = None enable_trajectory: bool = True + default_conversation_mode: Literal["ask", "plan", "craft"] | None = Field( + default=None, + description=( + "Initial chat permission mode for new threads " + "(ask / plan / craft). Omit to default to craft." + ), + ) + default_knowledge_base_ids: list[str] | None = Field( + default=None, + description="Knowledge bases auto-attached when a turn omits an explicit list.", + ) class LocalizedTextResponse(BaseModel): @@ -537,6 +561,8 @@ async def install_published_expert( welcome_message=body.welcome_message, runtime_config=runtime_field_updates(body, exclude_unset=True), enable_trajectory=body.enable_trajectory, + default_conversation_mode=body.default_conversation_mode, + default_knowledge_base_ids=body.default_knowledge_base_ids, ), ) @@ -640,6 +666,8 @@ async def install_expert_hub_item( knowledge_base_ids=kb_ids, mcp_servers=servers, enable_trajectory=body.enable_trajectory, + default_conversation_mode=body.default_conversation_mode, + default_knowledge_base_ids=body.default_knowledge_base_ids, **runtime_field_updates(body, exclude_unset=False), ), ) @@ -720,6 +748,8 @@ async def create_agent_from_expert( if body.backend: config_extra["backend"] = body.backend apply_enable_trajectory(config_extra, body.enable_trajectory) + apply_default_conversation_mode(config_extra, body.default_conversation_mode) + apply_default_knowledge_base_ids(config_extra, body.default_knowledge_base_ids) locale = resolve_user_locale( user_repo=server.services.user_repo, diff --git a/src/octop/i18n/domains/conversation.py b/src/octop/i18n/domains/conversation.py new file mode 100644 index 000000000..96916eb3c --- /dev/null +++ b/src/octop/i18n/domains/conversation.py @@ -0,0 +1,47 @@ +"""``conversation.*`` — Ask / Plan / Craft mode system hints (#616).""" + +from __future__ import annotations + +from octop.i18n.loader import tr +from octop.infra.agents.conversation_mode import ConversationMode +from octop.infra.utils.locale import Locale + +__all__ = [ + "conversation_mode_plan_brief_block", + "conversation_mode_system_hint", + "conversation_mode_tool_blocked", +] + + +def conversation_mode_system_hint( + mode: ConversationMode, + locale: str | Locale = "en", +) -> str: + """Localized system hint for the turn's conversation mode.""" + return tr(f"conversation.mode.{mode}_system_hint", locale) + + +def conversation_mode_plan_brief_block( + brief: str, + locale: str | Locale = "en", +) -> str: + """Wrap an approved plan brief for system-prompt injection (Craft handoff).""" + cleaned = brief.strip() + if not cleaned: + return "" + prefix = tr("conversation.mode.plan_brief_prefix", locale) + return f"{prefix}\n\n{cleaned}" + + +def conversation_mode_tool_blocked( + tool_name: str, + mode: ConversationMode, + locale: str | Locale = "en", +) -> str: + """Error text when a denylisted tool is invoked under Ask/Plan.""" + return tr( + "conversation.mode.tool_blocked", + locale, + tool_name=tool_name, + mode=mode, + ) diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index d9f1b5b0a..e87e0ace8 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -111,6 +111,13 @@ "cleared": "Model override cleared.", "set": "Model for this thread → {model}." }, + "mode": { + "override": "Conversation mode override: {mode}", + "current_default": "Conversation mode: {mode} (agent default)", + "usage": "Usage: /mode ask|plan|craft|reset (aliases: /ask /plan /craft)", + "cleared": "Conversation mode override cleared.", + "set": "Conversation mode for this thread → {mode}." + }, "compact": { "done": "Model context compacted (thread `{short}`): summarized {count} older message(s), kept the recent tail.\n\nChat history is unchanged; from the next turn the model only sees the summary plus recent messages.", "done_offload": "Model context compacted (thread `{short}`): summarized {count} older message(s), kept the recent tail.\n\n- Saved: `{path}`\n- Chat history is unchanged; from the next turn the model only sees the summary plus recent messages.", @@ -214,6 +221,10 @@ "label": "Model", "description": "Show, set, or reset the model override for this thread" }, + "mode": { + "label": "Conversation mode", + "description": "Show, set, or reset Ask / Plan / Craft for this thread" + }, "new": { "label": "New chat", "description": "Clear context and start a new conversation thread" @@ -682,5 +693,14 @@ "row_total": "Total", "axis_tokens": "Tokens", "filename_prefix": "token-usage" + }, + "conversation": { + "mode": { + "ask_system_hint": "You are in Ask mode (问答): answer with read-only tools (knowledge search, file read, web fetch). Do not create, edit, delete, execute, or write todos/plans. Do not send files, run browser/desktop/mobile actions, or delegate via task / ACP / ask_agent. If the user needs changes, say they can use Plan or Default mode — do not tell them how to operate the UI.", + "plan_system_hint": "You are in Plan mode (计划): explore with read-only tools and produce a clear step-by-step plan/spec only. Prefer write_todos and structured plan text. Do not execute, edit files, or delegate via task / ACP / ask_agent. When the plan is ready, stop — the product UI will offer confirmation to execute. Do not ask the user to change mode controls or paste internal prompts.", + "craft_system_hint": "You are in Default / Craft mode (默认): you may use the full set of allowed tools to carry out the user's request, subject to existing safety and HITL rules.", + "plan_brief_prefix": "The user confirmed the following approved plan. Execute it now in Default mode. Do not repeat the plan verbatim unless needed for clarification:", + "tool_blocked": "Tool `{tool_name}` is not available in {mode} mode. Switch to Default (Craft) to run mutating or delegated tools." + } } } diff --git a/src/octop/i18n/zh.json b/src/octop/i18n/zh.json index ba28405f8..13f71765c 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -111,6 +111,13 @@ "cleared": "已清除模型覆盖。", "set": "此话题模型 → {model}。" }, + "mode": { + "override": "对话模式覆盖:{mode}", + "current_default": "对话模式:{mode}(Agent 默认)", + "usage": "用法:/mode ask|plan|craft|reset(别名:/ask /plan /craft)", + "cleared": "已清除对话模式覆盖。", + "set": "此话题对话模式 → {mode}。" + }, "compact": { "done": "已压缩模型上下文(话题 `{short}`):较早消息 {count} 条已总结,保留最近对话。\n\n聊天记录仍完整保留;下一轮起模型只使用摘要 + 近期对话。", "done_offload": "已压缩模型上下文(话题 `{short}`):较早消息 {count} 条已总结,保留最近对话。\n\n- 原文:`{path}`\n- 聊天记录仍完整保留;下一轮起模型只使用摘要 + 近期对话。", @@ -214,6 +221,10 @@ "label": "切换模型", "description": "查看、设置或重置当前话题的模型覆盖" }, + "mode": { + "label": "对话模式", + "description": "查看、设置或重置当前话题的问答 / 计划 / 默认模式" + }, "new": { "label": "新对话", "description": "清空上下文并开始新的对话线程" @@ -682,5 +693,14 @@ "row_total": "合计", "axis_tokens": "Token 数", "filename_prefix": "Token用量" + }, + "conversation": { + "mode": { + "ask_system_hint": "当前为问答(Ask)模式:只用只读能力(知识库检索、读文件、网页抓取等)回答与解释。不要创建、编辑、删除、执行,也不要写 todos/计划清单;不要发送文件或操作浏览器/桌面/手机,也不要通过 task / ACP / ask_agent 委派执行。若用户需要实际改动,可说明可使用计划或默认模式——不要指导用户如何操作界面控件。", + "plan_system_hint": "当前为计划(Plan)模式:用只读工具探索,只产出清晰的分步计划/规格说明。优先用 write_todos 与结构化计划文本。不要执行、改文件,也不要通过 task / ACP / ask_agent 委派。计划就绪后停止即可,产品界面会提供确认执行入口。不要让用户去切换模式按钮,也不要输出内部切换提示词。", + "craft_system_hint": "当前为默认(Craft)模式:在现有安全与 HITL 规则下,可使用已启用的完整工具集执行用户请求。", + "plan_brief_prefix": "用户已确认以下计划。请在默认模式下立即执行。除非澄清需要,否则不要原样复述整份计划:", + "tool_blocked": "当前为 {mode} 模式,工具 `{tool_name}` 不可用。如需执行改动或委派类操作,请切换到默认(Craft)模式。" + } } } diff --git a/src/octop/infra/agents/conversation_mode.py b/src/octop/infra/agents/conversation_mode.py new file mode 100644 index 000000000..7ba625f22 --- /dev/null +++ b/src/octop/infra/agents/conversation_mode.py @@ -0,0 +1,145 @@ +"""Conversation modes (Ask / Plan / Craft) — turn-scoped tool overlays (#616). + +Ask / Plan denylists cover **builtin** Octop / harness tool names only. MCP and +custom connector tools are not name-denylisted here; mutating MCP tools may still +appear unless blocked by agent ``tools_disabled`` or connector policy. Prefer an +allowlist / mutating tag on MCP tools for stricter Ask if needed. +""" + +from __future__ import annotations + +from typing import Literal + +from octop.infra.agents.tool_catalog import CRITICAL_TOOLS + +ConversationMode = Literal["ask", "plan", "craft"] + +DEFAULT_CONVERSATION_MODE: ConversationMode = "craft" + +_VALID_MODES: frozenset[str] = frozenset({"ask", "plan", "craft"}) + +# Mutating / side-effect tools blocked in Ask and Plan. Must not intersect CRITICAL_TOOLS. +_ASK_PLAN_TOOLS_DISABLED: frozenset[str] = frozenset( + { + "write_file", + "edit_file", + "execute", + "write_env_file", + "send_file_to_user", + "browser_use", + "desktop_screenshot", + "generate_image", + "generate_video", + "mobile_screenshot", + "mobile_tap", + "mobile_swipe", + "mobile_launch_app", + "mobile_ui_dump", + "mobile_handoff_to_user", + # cron mutations + "cronjob_create", + "cronjob_update", + "cronjob_delete", + "cronjob_run_now", + } +) + +# Ask-only: no structured plan todos (Plan keeps write_todos). +_ASK_ONLY_TOOLS_DISABLED: frozenset[str] = frozenset({"write_todos"}) + +# Delegation / ACP / team tools that would escape Ask/Plan into a full-autonomy child. +# ``task`` is CRITICAL for agent settings (cannot be permanently disabled) but MUST be +# turn-blocked in Ask/Plan — same idea as Cursor Plan: explore + write the plan only. +_ORCHESTRATION_ESCAPE_TOOLS: frozenset[str] = frozenset( + { + "task", + "acp_runner", + "ask_agent", + } +) + +assert _ASK_PLAN_TOOLS_DISABLED.isdisjoint(CRITICAL_TOOLS) +# write_todos is CRITICAL (cannot be permanently disabled in agent settings) but Ask +# still turn-blocks it via middleware — same pattern as ``task``. + + +def parse_conversation_mode(value: object | None) -> ConversationMode: + """Return a valid mode. ``None`` → craft; unknown non-null → ``ValueError``.""" + if value is None: + return DEFAULT_CONVERSATION_MODE + if not isinstance(value, str) or value not in _VALID_MODES: + raise ValueError(f"invalid conversation_mode: {value!r}") + return value # type: ignore[return-value] + + +def conversation_mode_tools_disabled(mode: ConversationMode) -> frozenset[str]: + """Turn-scoped builtin denylist. + + ``craft`` → empty; Plan → mutating + no orchestration escape; Ask → Plan set + plus ``write_todos``. Does **not** cover MCP/custom tool names (see module doc). + """ + if mode == "craft": + return frozenset() + base = _ASK_PLAN_TOOLS_DISABLED | _ORCHESTRATION_ESCAPE_TOOLS + if mode == "ask": + return base | _ASK_ONLY_TOOLS_DISABLED + return base + + +def resolve_conversation_mode( + *, + explicit: object | None = None, + thread_override: ConversationMode | None = None, + agent_default: ConversationMode | None = None, +) -> ConversationMode: + """Resolve turn mode: explicit → thread sticky → agent default → craft. + + Unknown non-null *explicit* values resolve to craft (compat) and do not fall + through to sticky/agent defaults. + """ + if isinstance(explicit, str) and explicit in _VALID_MODES: + return explicit # type: ignore[return-value] + if explicit is not None: + return DEFAULT_CONVERSATION_MODE + if thread_override is not None: + return thread_override + if agent_default is not None: + return agent_default + return DEFAULT_CONVERSATION_MODE + + +DEFAULT_CONVERSATION_MODE_CONFIG_KEY = "default_conversation_mode" + + +def default_conversation_mode_from_config(cfg: object | None) -> ConversationMode: + """Read agent default mode from config; missing/null → craft.""" + if not isinstance(cfg, dict): + return DEFAULT_CONVERSATION_MODE + raw = cfg.get(DEFAULT_CONVERSATION_MODE_CONFIG_KEY) + if raw is None: + return DEFAULT_CONVERSATION_MODE + return parse_conversation_mode(raw) + + +def normalize_config_default_conversation_mode(cfg: dict[str, object]) -> dict[str, object]: + """Validate/normalize ``default_conversation_mode`` in-place copy. Raises ``ValueError``.""" + if DEFAULT_CONVERSATION_MODE_CONFIG_KEY not in cfg: + return dict(cfg) + out = dict(cfg) + raw = out.get(DEFAULT_CONVERSATION_MODE_CONFIG_KEY) + if raw is None: + out.pop(DEFAULT_CONVERSATION_MODE_CONFIG_KEY, None) + return out + out[DEFAULT_CONVERSATION_MODE_CONFIG_KEY] = parse_conversation_mode(raw) + return out + + +def apply_default_conversation_mode( + config: dict[str, object], + mode: object | None, +) -> dict[str, object]: + """Write ``default_conversation_mode`` when *mode* is set. Mutates and returns *config*.""" + if mode is None: + return config + config[DEFAULT_CONVERSATION_MODE_CONFIG_KEY] = parse_conversation_mode(mode) + return config diff --git a/src/octop/infra/agents/experts/market_creation.py b/src/octop/infra/agents/experts/market_creation.py index a23c8907d..583148a4e 100644 --- a/src/octop/infra/agents/experts/market_creation.py +++ b/src/octop/infra/agents/experts/market_creation.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from typing import Any, Literal +from octop.infra.agents.conversation_mode import apply_default_conversation_mode from octop.infra.agents.experts.catalog import ( WORKSPACE_MANIFEST_PATH, build_create_spec_from_expert, @@ -20,6 +21,7 @@ install_skillset_template, ) from octop.infra.errors import ErrorCode, OctopError +from octop.infra.knowledge.default_open import apply_default_knowledge_base_ids from octop.infra.trajectory.settings import apply_enable_trajectory from octop.infra.utils.locale import resolve_user_locale @@ -58,6 +60,8 @@ class SkillHubMarketAgentCreateOptions: top_p: float | None = None max_tokens: int | None = None enable_trajectory: bool = True + default_conversation_mode: str | None = None + default_knowledge_base_ids: list[str] | None = None @dataclass(frozen=True) @@ -287,6 +291,8 @@ async def create_agent_from_skillhub_skillset( if options.backend: config_extra["backend"] = options.backend apply_enable_trajectory(config_extra, options.enable_trajectory) + apply_default_conversation_mode(config_extra, options.default_conversation_mode) + apply_default_knowledge_base_ids(config_extra, options.default_knowledge_base_ids) locale = resolve_user_locale( user_repo=server.services.user_repo, diff --git a/src/octop/infra/agents/experts/published_creation.py b/src/octop/infra/agents/experts/published_creation.py index ddfa9bcc9..45428ce42 100644 --- a/src/octop/infra/agents/experts/published_creation.py +++ b/src/octop/infra/agents/experts/published_creation.py @@ -13,6 +13,7 @@ from psycopg import IntegrityError as PsycopgIntegrityError from octop.infra.agents.avatar import bind_workspace_avatar_icon_url +from octop.infra.agents.conversation_mode import apply_default_conversation_mode from octop.infra.agents.experts.catalog import ( MANIFEST_FILENAME, parse_task_examples, @@ -29,6 +30,7 @@ from octop.infra.agents.manager import AgentCreateSpec from octop.infra.db.repos.published_experts import PublishedExpertRow from octop.infra.errors import ErrorCode, OctopError +from octop.infra.knowledge.default_open import apply_default_knowledge_base_ids from octop.infra.trajectory.settings import apply_enable_trajectory from octop.infra.users.identity import User from octop.infra.utils.ulid import new_ulid @@ -50,6 +52,8 @@ class PublishedExpertInstallOptions: welcome_message: str | None = None runtime_config: dict[str, Any] | None = None enable_trajectory: bool = True + default_conversation_mode: str | None = None + default_knowledge_base_ids: list[str] | None = None def _snapshot_dir(services: Any, expert_id: str) -> Path: @@ -342,6 +346,8 @@ async def install_published_expert( if options.backend: config_extra["backend"] = options.backend apply_enable_trajectory(config_extra, options.enable_trajectory) + apply_default_conversation_mode(config_extra, options.default_conversation_mode) + apply_default_knowledge_base_ids(config_extra, options.default_knowledge_base_ids) async def seed_snapshot(created_row: Any, workspace: Any) -> None: await seed_expert_directory(expert_dir=snapshot_dir, workspace=workspace) diff --git a/src/octop/infra/agents/manager.py b/src/octop/infra/agents/manager.py index 7c73bcdfb..4604e82f0 100644 --- a/src/octop/infra/agents/manager.py +++ b/src/octop/infra/agents/manager.py @@ -381,6 +381,8 @@ def __init__( self._mcp_tool_cache_guard = asyncio.Lock() # Sanitized plugin tool name → original label (per agent, rebuilt on reload). self._plugin_tool_labels: dict[str, dict[str, str]] = {} + # Sticky /mode overrides for IM (and slash) — not persisted across process restart. + self._thread_conversation_modes: dict[tuple[str, str], str] = {} def replace_persistence(self, repos: RepoBundle, config: OctopConfig) -> None: """Retarget repos/config and rebuild settings stores after control-plane rebind.""" @@ -1053,6 +1055,19 @@ def clear_thread_model(self, agent_id: str, thread_id: str) -> None: if self._harness_manager is not None: self._harness_manager.clear_thread_model(agent_id, thread_id) + def get_thread_conversation_mode(self, agent_id: str, thread_id: str) -> str | None: + """Sticky IM/slash conversation mode for *(agent_id, thread_id)*, if any.""" + return self._thread_conversation_modes.get((agent_id, thread_id)) + + def set_thread_conversation_mode(self, agent_id: str, thread_id: str, mode: str) -> None: + from octop.infra.agents.conversation_mode import parse_conversation_mode + + parsed = parse_conversation_mode(mode) + self._thread_conversation_modes[(agent_id, thread_id)] = parsed + + def clear_thread_conversation_mode(self, agent_id: str, thread_id: str) -> None: + self._thread_conversation_modes.pop((agent_id, thread_id), None) + def resolve_fallback_model_ref(self) -> str | None: """Settings active model when usable, else the first enabled catalog model.""" name, model_id = self._repos.settings_repo.get_active_model() @@ -2701,6 +2716,7 @@ def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig: from octop.infra.agents.middleware.binary_read_guard import BinaryReadGuardMiddleware from octop.infra.agents.middleware.browser_profile import BrowserProfileMiddleware + from octop.infra.agents.middleware.conversation_mode import ConversationModeMiddleware from octop.infra.agents.middleware.reasoning import ReasoningRequestMiddleware from octop.infra.agents.middleware.thread_artifacts import ThreadArtifactsMiddleware from octop.infra.agents.middleware.token_quota import TokenQuotaMiddleware @@ -2713,6 +2729,7 @@ def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig: # BinaryReadGuard stays Octop-specific (inbound/attachment product policy). # ThreadArtifacts writes workspace paths onto threads after successful tools. # WorkspaceImageMaterialize expands path-only vision refs at model-call time. + # ConversationMode applies turn Ask/Plan denylist + system hint from configurable. agent_middleware: list[Any] = [ *plugin_middleware, TokenQuotaMiddleware( @@ -2721,6 +2738,7 @@ def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig: ), ReasoningRequestMiddleware(), KnowledgeSearchHintMiddleware(), + ConversationModeMiddleware(), BrowserProfileMiddleware(), BinaryReadGuardMiddleware(), WorkspaceImageMaterializeMiddleware(workspace=ws), diff --git a/src/octop/infra/agents/middleware/conversation_mode.py b/src/octop/infra/agents/middleware/conversation_mode.py new file mode 100644 index 000000000..562128cfa --- /dev/null +++ b/src/octop/infra/agents/middleware/conversation_mode.py @@ -0,0 +1,198 @@ +"""Turn-scoped Ask / Plan / Craft: filter tools, inject system hint, block denylist calls. + +Denylist covers builtin tool names only — see ``conversation_mode`` module docs for MCP limits. +""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable +from typing import Any + +from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse +from langchain_core.messages import SystemMessage, ToolMessage +from langchain_core.tools.base import BaseTool +from langgraph.config import get_config +from langgraph.prebuilt.tool_node import ToolCallRequest +from langgraph.types import Command + +from octop.infra.agents.conversation_mode import ( + DEFAULT_CONVERSATION_MODE, + ConversationMode, + conversation_mode_tools_disabled, + parse_conversation_mode, +) + +logger = logging.getLogger(__name__) + +CONFIG_MODE_KEY = "conversation_mode" +CONFIG_HINT_KEY = "conversation_mode_hint" + + +def _tool_name(tool: BaseTool | dict[str, Any]) -> str: + if isinstance(tool, dict): + fn = tool.get("function") + if isinstance(fn, dict) and fn.get("name"): + return str(fn["name"]) + if tool.get("name"): + return str(tool["name"]) + return "" + return str(getattr(tool, "name", "") or "") + + +def mode_from_configurable(configurable: dict[str, Any] | None) -> ConversationMode: + """Resolve turn mode from LangGraph configurable; unknown → craft.""" + raw = (configurable or {}).get(CONFIG_MODE_KEY) + if raw is None: + return DEFAULT_CONVERSATION_MODE + try: + return parse_conversation_mode(raw) + except ValueError: + return DEFAULT_CONVERSATION_MODE + + +def _current_mode() -> ConversationMode: + cfg = get_config().get("configurable") or {} + if not isinstance(cfg, dict): + return DEFAULT_CONVERSATION_MODE + return mode_from_configurable(cfg) + + +def _current_hint() -> str: + cfg = get_config().get("configurable") or {} + if not isinstance(cfg, dict): + return "" + raw = cfg.get(CONFIG_HINT_KEY) + return raw.strip() if isinstance(raw, str) else "" + + +def _hint_already_present(content: str | list[Any] | Any, hint: str) -> bool: + if isinstance(content, str): + return hint in content + if isinstance(content, list): + for block in content: + if isinstance(block, str) and hint in block: + return True + if isinstance(block, dict): + text = block.get("text") + if isinstance(text, str) and hint in text: + return True + return False + return hint in str(content) + + +def _append_system_hint(request: ModelRequest[Any], hint: str) -> ModelRequest[Any]: + cleaned = hint.strip() + if not cleaned: + return request + existing = request.system_message + if existing is None: + return request.override(system_message=SystemMessage(content=cleaned)) + content = existing.content + if _hint_already_present(content, cleaned): + return request + if isinstance(content, str): + merged: str | list[Any] = f"{content.rstrip()}\n\n{cleaned}" + elif isinstance(content, list): + merged = [*content, {"type": "text", "text": cleaned}] + else: + merged = f"{content}\n\n{cleaned}" + return request.override(system_message=SystemMessage(content=merged)) + + +def _filter_tools(request: ModelRequest[Any], disabled: frozenset[str]) -> ModelRequest[Any]: + if not disabled: + return request + tools_in = list(request.tools or []) + if not tools_in: + return request + filtered = [t for t in tools_in if _tool_name(t) not in disabled] + if len(filtered) == len(tools_in): + return request + return request.override(tools=filtered) + + +def apply_conversation_mode_to_request(request: ModelRequest[Any]) -> ModelRequest[Any]: + """Filter Ask/Plan denylist tools and append the stamped system hint.""" + mode = _current_mode() + out = _filter_tools(request, conversation_mode_tools_disabled(mode)) + return _append_system_hint(out, _current_hint()) + + +def _blocked_tool_message( + *, tool_name: str, mode: ConversationMode, tool_call_id: str +) -> ToolMessage: + from octop.i18n.domains.conversation import conversation_mode_tool_blocked + + cfg = get_config().get("configurable") or {} + locale = "en" + if isinstance(cfg, dict): + raw_locale = cfg.get("locale") + if isinstance(raw_locale, str) and raw_locale.strip(): + locale = raw_locale.strip() + return ToolMessage( + content=conversation_mode_tool_blocked(tool_name, mode, locale), + tool_call_id=tool_call_id, + status="error", + ) + + +class ConversationModeMiddleware(AgentMiddleware[Any, Any]): + """Apply turn ``conversation_mode`` from configurable — no agent-global mutation.""" + + def wrap_model_call( + self, + request: ModelRequest[Any], + handler: Callable[[ModelRequest[Any]], ModelResponse[Any]], + ) -> ModelResponse[Any]: + return handler(apply_conversation_mode_to_request(request)) + + async def awrap_model_call( + self, + request: ModelRequest[Any], + handler: Callable[[ModelRequest[Any]], Awaitable[ModelResponse[Any]]], + ) -> ModelResponse[Any]: + return await handler(apply_conversation_mode_to_request(request)) + + def wrap_tool_call( + self, + request: ToolCallRequest, + handler: Callable[[ToolCallRequest], ToolMessage | Command[Any]], + ) -> ToolMessage | Command[Any]: + mode = _current_mode() + disabled = conversation_mode_tools_disabled(mode) + tool_name = str(request.tool_call.get("name") or "") + if tool_name and tool_name in disabled: + logger.info("ConversationMode blocked tool %s (mode=%s)", tool_name, mode) + return _blocked_tool_message( + tool_name=tool_name, + mode=mode, + tool_call_id=str(request.tool_call.get("id") or ""), + ) + return handler(request) + + async def awrap_tool_call( + self, + request: ToolCallRequest, + handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]], + ) -> ToolMessage | Command[Any]: + mode = _current_mode() + disabled = conversation_mode_tools_disabled(mode) + tool_name = str(request.tool_call.get("name") or "") + if tool_name and tool_name in disabled: + logger.info("ConversationMode blocked tool %s (mode=%s)", tool_name, mode) + return _blocked_tool_message( + tool_name=tool_name, + mode=mode, + tool_call_id=str(request.tool_call.get("id") or ""), + ) + return await handler(request) + + +__all__ = [ + "CONFIG_HINT_KEY", + "CONFIG_MODE_KEY", + "ConversationModeMiddleware", + "apply_conversation_mode_to_request", + "mode_from_configurable", +] diff --git a/src/octop/infra/agents/plan_artifact.py b/src/octop/infra/agents/plan_artifact.py new file mode 100644 index 000000000..d9e0852c6 --- /dev/null +++ b/src/octop/infra/agents/plan_artifact.py @@ -0,0 +1,53 @@ +"""Plan brief artifact for Plan → Craft handoff (#616 P5).""" + +from __future__ import annotations + +from typing import Any + +# Minimal HumanMessage text when the dashboard sends plan_brief with no chat bubble. +# Full plan lives in the system hint; this only gives the model a user-turn trigger. +PLAN_EXECUTE_USER_TRIGGER = "Execute the approved plan now." + + +def format_plan_brief(*, summary: str = "", todos: list[dict[str, Any]] | None = None) -> str: + """Serialize an approved plan into a Craft-turn brief (markdown).""" + lines: list[str] = ["## Approved plan", ""] + text = (summary or "").strip() + if text: + lines.extend([text, ""]) + items = todos or [] + if items: + lines.append("### Steps") + step_num = 0 + for raw in items: + if isinstance(raw, str): + content = raw.strip() + elif isinstance(raw, dict): + content = str( + raw.get("content") or raw.get("text") or raw.get("title") or "" + ).strip() + else: + content = "" + if not content: + continue + status = "" + if isinstance(raw, dict) and raw.get("status"): + status = f" ({raw['status']})" + step_num += 1 + lines.append(f"{step_num}. {content}{status}") + lines.append("") + lines.append("Execute this plan now.") + return "\n".join(lines).strip() + "\n" + + +def parse_plan_brief_steps(brief: str) -> list[str]: + """Extract numbered step lines from a brief produced by :func:`format_plan_brief`.""" + steps: list[str] = [] + for line in (brief or "").splitlines(): + stripped = line.strip() + if len(stripped) >= 3 and stripped[0].isdigit() and ". " in stripped[:6]: + _, _, rest = stripped.partition(". ") + content = rest.strip() + if content: + steps.append(content) + return steps diff --git a/src/octop/infra/gateway/process/message_keys.py b/src/octop/infra/gateway/process/message_keys.py index de4f55b56..1bcb17e2d 100644 --- a/src/octop/infra/gateway/process/message_keys.py +++ b/src/octop/infra/gateway/process/message_keys.py @@ -11,6 +11,8 @@ # Persisted on HumanMessage.additional_kwargs for dashboard history UI. COMPOSER_CTX_KEY = "octop_composer_context" INBOUND_ATTACHMENTS_KEY = "octop_inbound_attachments" +# Plan→Craft silent handoff: history should not render this user turn as a bubble. +UI_HIDDEN_KEY = "octop_ui_hidden" # Must match harness_agent.messages.CHECKPOINT_TS_KEY (epoch-ms). CHECKPOINT_TS_KEY = "checkpoint_ts" # Persisted on AIMessage.additional_kwargs when a stream fails mid-turn. diff --git a/src/octop/infra/gateway/process/processor.py b/src/octop/infra/gateway/process/processor.py index 358a5fe86..670b93c02 100644 --- a/src/octop/infra/gateway/process/processor.py +++ b/src/octop/infra/gateway/process/processor.py @@ -21,6 +21,9 @@ from langchain_core.messages import AIMessage, HumanMessage from octop.i18n.domains.stream import format_stream_error +from octop.infra.agents.conversation_mode import ( + ConversationMode, +) from octop.infra.agents.profile import parse_config_json from octop.infra.agents.providers.reasoning import reasoning_request_parameters from octop.infra.errors import OctopError @@ -59,7 +62,6 @@ from octop.infra.gateway.slash.ctx import SlashCtx, build_slash_ctx from octop.infra.gateway.slash.parser import parse_slash from octop.infra.gateway.slash.runner import try_handle_slash -from octop.infra.knowledge.default_open import stamp_turn_knowledge_config from octop.infra.trajectory.settings import agent_trajectory_enabled from octop.infra.users.preferences import ( get_model_reasoning_from_json, @@ -851,6 +853,13 @@ async def __call__(self, msg: InboundMessage) -> AsyncIterator[MessageEvent]: locale=locale, agent_id=agent_id, ) + self._attach_conversation_mode_config( + request, + msg.metadata, + locale=locale, + agent_id=agent_id, + thread_id=thread_id, + ) if mcp_servers: request["mcp_servers"] = mcp_servers @@ -1199,6 +1208,7 @@ async def _build_dashboard_request( from octop.infra.gateway.process.message_keys import ( # noqa: PLC0415 COMPOSER_CTX_KEY, INBOUND_ATTACHMENTS_KEY, + UI_HIDDEN_KEY, ) media_backend = media_backend_for_agent(self._agent_manager, agent_id) @@ -1235,6 +1245,10 @@ async def _build_dashboard_request( attachments = meta.get(INBOUND_ATTACHMENTS_KEY) if isinstance(attachments, list) and attachments: message_kwargs[INBOUND_ATTACHMENTS_KEY] = attachments + raw_brief = meta.get("plan_brief") + if isinstance(raw_brief, str) and raw_brief.strip(): + # Dashboard PlanReady CTA — do not render the trigger as a user bubble. + message_kwargs[UI_HIDDEN_KEY] = True explicit_mcp = meta.get("mcp_servers") # Dashboard always sends mcp_servers (possibly []); trust that list so @@ -1294,6 +1308,13 @@ async def _build_dashboard_request( locale=locale, agent_id=agent_id, ) + self._attach_conversation_mode_config( + request, + meta, + locale=locale, + agent_id=agent_id, + thread_id=thread_id, + ) if mcp_servers: request["mcp_servers"] = mcp_servers @@ -1319,16 +1340,88 @@ def _attach_turn_knowledge_config( if is_admin else self._knowledge_services.knowledge_repo.list_visible(user_id) ) - extra_ids = self._agent_manager.default_knowledge_base_ids(agent_id) if agent_id else None - stamp_turn_knowledge_config( - request, - visible_bases=bases, - explicit_ids=explicit_ids, + agent_defaults: list[str] | None = None + if agent_id and self._agent_manager is not None: + from octop.infra.knowledge.default_open import ( # noqa: PLC0415 + default_knowledge_base_ids_from_config, + merge_knowledge_base_ids, + ) + from octop.infra.knowledge.hint import catalog_for_selected_bases # noqa: PLC0415 + + agent_defaults = ( + default_knowledge_base_ids_from_config(self._agent_manager.get_config(agent_id)) + or None + ) + selected_ids = merge_knowledge_base_ids( + bases, + explicit_ids, owner_user_id=user_id, - extra_ids=extra_ids, - is_admin=is_admin, - locale=locale, + agent_default_ids=agent_defaults, ) + configurable = dict(request.get("configurable") or {}) + configurable["knowledge_base_ids"] = selected_ids + configurable["knowledge_base_catalog"] = catalog_for_selected_bases(bases, selected_ids) + configurable["user_is_admin"] = is_admin + configurable["locale"] = locale + request["configurable"] = configurable + + def _resolve_conversation_mode( + self, + meta: dict[str, Any] | None, + *, + agent_id: str | None = None, + thread_id: str | None = None, + ) -> ConversationMode: + """explicit meta → thread /mode sticky → agent default → craft.""" + from octop.infra.agents.conversation_mode import ( # noqa: PLC0415 + default_conversation_mode_from_config, + resolve_conversation_mode, + ) + + explicit = (meta or {}).get("conversation_mode") + thread_override: ConversationMode | None = None + if agent_id and thread_id and self._agent_manager is not None: + raw_sticky = self._agent_manager.get_thread_conversation_mode(agent_id, thread_id) + if raw_sticky in ("ask", "plan", "craft"): + thread_override = raw_sticky # type: ignore[assignment] + agent_default: ConversationMode | None = None + if agent_id and self._agent_manager is not None: + agent_default = default_conversation_mode_from_config( + self._agent_manager.get_config(agent_id) + ) + return resolve_conversation_mode( + explicit=explicit, + thread_override=thread_override, + agent_default=agent_default, + ) + + def _attach_conversation_mode_config( + self, + request: dict[str, Any], + meta: dict[str, Any] | None, + *, + locale: str | None = None, + agent_id: str | None = None, + thread_id: str | None = None, + ) -> None: + """Stamp resolved conversation_mode (+ localized system hint) onto configurable.""" + from octop.i18n.domains.conversation import ( + conversation_mode_plan_brief_block, + conversation_mode_system_hint, + ) + + mode = self._resolve_conversation_mode(meta, agent_id=agent_id, thread_id=thread_id) + configurable = dict(request.get("configurable") or {}) + configurable["conversation_mode"] = mode + hint_locale = locale or str(configurable.get("locale") or "en") + hint = conversation_mode_system_hint(mode, hint_locale) + raw_brief = (meta or {}).get("plan_brief") + brief = raw_brief.strip() if isinstance(raw_brief, str) else "" + if brief: + configurable["plan_brief"] = brief + hint = f"{hint}\n\n{conversation_mode_plan_brief_block(brief, hint_locale)}" + configurable["conversation_mode_hint"] = hint + request["configurable"] = configurable async def _resolve_turn_mcp_servers( self, diff --git a/src/octop/infra/gateway/slash/catalog.py b/src/octop/infra/gateway/slash/catalog.py index 67a1f2ec6..d37d4865c 100644 --- a/src/octop/infra/gateway/slash/catalog.py +++ b/src/octop/infra/gateway/slash/catalog.py @@ -87,6 +87,14 @@ def label_for(self, locale: str) -> str: tone="violet", category="core", ), + SlashCommandSpec( + name="mode", + aliases=("ask", "plan", "craft"), + usage="/mode [ask|plan|craft|reset]", + icon="Shield", + tone="violet", + category="core", + ), SlashCommandSpec( name="new", aliases=("clear",), diff --git a/src/octop/infra/gateway/slash/dispatcher.py b/src/octop/infra/gateway/slash/dispatcher.py index 3820a5791..d0a7c8141 100644 --- a/src/octop/infra/gateway/slash/dispatcher.py +++ b/src/octop/infra/gateway/slash/dispatcher.py @@ -74,6 +74,21 @@ def clear_thread_model_override(self, ctx: SlashCtx, thread_id: str) -> None: if ctx.agent_manager is not None: ctx.agent_manager.clear_thread_model(ctx.agent_id, thread_id) + def get_thread_conversation_mode_override(self, ctx: SlashCtx, thread_id: str) -> str | None: + if ctx.agent_manager is None: + return None + return ctx.agent_manager.get_thread_conversation_mode(ctx.agent_id, thread_id) + + def set_thread_conversation_mode_override( + self, ctx: SlashCtx, thread_id: str, mode: str + ) -> None: + if ctx.agent_manager is not None: + ctx.agent_manager.set_thread_conversation_mode(ctx.agent_id, thread_id, mode) + + def clear_thread_conversation_mode_override(self, ctx: SlashCtx, thread_id: str) -> None: + if ctx.agent_manager is not None: + ctx.agent_manager.clear_thread_conversation_mode(ctx.agent_id, thread_id) + def build_default_dispatcher() -> SlashDispatcher: from octop.infra.gateway.slash.handlers import register_all # noqa: PLC0415 diff --git a/src/octop/infra/gateway/slash/handlers/composite.py b/src/octop/infra/gateway/slash/handlers/composite.py index 926fa8c49..d4f3a3a67 100644 --- a/src/octop/infra/gateway/slash/handlers/composite.py +++ b/src/octop/infra/gateway/slash/handlers/composite.py @@ -322,10 +322,61 @@ async def cmd_model(d: SlashDispatcher, cmd: SlashCommand, ctx: SlashCtx, sink: await sink.text(tr("model.set", lang, model=name)) +_MODE_ALIASES = { + "ask": "ask", + "plan": "plan", + "craft": "craft", + "default": "craft", + "问答": "ask", + "计划": "plan", + "默认": "craft", +} + + +async def cmd_mode(d: SlashDispatcher, cmd: SlashCommand, ctx: SlashCtx, sink: SlashSink) -> None: + """Set sticky conversation mode for this thread (IM + slash).""" + from octop.infra.agents.conversation_mode import ( # noqa: PLC0415 + default_conversation_mode_from_config, + ) + + lang = lang_of(ctx) + tid = await ensure_thread_id(ctx) + # /ask /plan /craft as aliases — treat command name as the mode when args empty. + raw = cmd.args.strip() or (cmd.name if cmd.name in ("ask", "plan", "craft") else "") + if not raw: + override = d.get_thread_conversation_mode_override(ctx, tid) + agent_default = "craft" + if ctx.agent_manager is not None: + agent_default = default_conversation_mode_from_config( + ctx.agent_manager.get_config(ctx.agent_id) + ) + if override: + await sink.text(tr("mode.override", lang, mode=override)) + else: + await sink.text(tr("mode.current_default", lang, mode=agent_default)) + return + key = raw.lower() + if key == "reset": + d.clear_thread_conversation_mode_override(ctx, tid) + if hasattr(sink, "action"): + await sink.action("clear_conversation_mode") + await sink.text(tr("mode.cleared", lang)) + return + mode = _MODE_ALIASES.get(key) or _MODE_ALIASES.get(raw) + if mode is None: + await sink.text(tr("mode.usage", lang)) + return + d.set_thread_conversation_mode_override(ctx, tid, mode) + if hasattr(sink, "action"): + await sink.action("set_conversation_mode", mode=mode) + await sink.text(tr("mode.set", lang, mode=mode)) + + COMPOSITE_HANDLERS: dict[str, GatewayHandler] = { "compact": cmd_compact, "history": cmd_history, "status": cmd_status, "model": cmd_model, "models": cmd_model, + "mode": cmd_mode, } diff --git a/src/octop/infra/knowledge/default_open.py b/src/octop/infra/knowledge/default_open.py index 50d0d7b3a..0071a5ed3 100644 --- a/src/octop/infra/knowledge/default_open.py +++ b/src/octop/infra/knowledge/default_open.py @@ -7,6 +7,64 @@ from octop.infra.db.repos.knowledge import KnowledgeBaseRow +DEFAULT_KNOWLEDGE_BASE_IDS_CONFIG_KEY = "default_knowledge_base_ids" + + +def default_knowledge_base_ids_from_config(cfg: object | None) -> list[str]: + """Read agent ``default_knowledge_base_ids``; missing/invalid → empty.""" + if not isinstance(cfg, dict): + return [] + raw = cfg.get(DEFAULT_KNOWLEDGE_BASE_IDS_CONFIG_KEY) + if not isinstance(raw, list): + return [] + out: list[str] = [] + seen: set[str] = set() + for item in raw: + if not isinstance(item, str): + continue + kid = item.strip() + if not kid or kid in seen: + continue + seen.add(kid) + out.append(kid) + return out + + +def normalize_config_default_knowledge_base_ids(cfg: dict[str, object]) -> dict[str, object]: + """Validate/normalize ``default_knowledge_base_ids``; raises ``ValueError`` on bad types.""" + if DEFAULT_KNOWLEDGE_BASE_IDS_CONFIG_KEY not in cfg: + return dict(cfg) + out = dict(cfg) + raw = out.get(DEFAULT_KNOWLEDGE_BASE_IDS_CONFIG_KEY) + if raw is None: + out.pop(DEFAULT_KNOWLEDGE_BASE_IDS_CONFIG_KEY, None) + return out + if not isinstance(raw, list): + raise ValueError("default_knowledge_base_ids must be a list of strings") + cleaned = default_knowledge_base_ids_from_config({DEFAULT_KNOWLEDGE_BASE_IDS_CONFIG_KEY: raw}) + if cleaned: + out[DEFAULT_KNOWLEDGE_BASE_IDS_CONFIG_KEY] = cleaned + else: + out.pop(DEFAULT_KNOWLEDGE_BASE_IDS_CONFIG_KEY, None) + return out + + +def apply_default_knowledge_base_ids( + config: dict[str, object], + ids: object | None, +) -> dict[str, object]: + """Write ``default_knowledge_base_ids`` when *ids* is set. Mutates and returns *config*.""" + if ids is None: + return config + if not isinstance(ids, list): + raise ValueError("default_knowledge_base_ids must be a list of strings") + cleaned = default_knowledge_base_ids_from_config({DEFAULT_KNOWLEDGE_BASE_IDS_CONFIG_KEY: ids}) + if cleaned: + config[DEFAULT_KNOWLEDGE_BASE_IDS_CONFIG_KEY] = cleaned + else: + config.pop(DEFAULT_KNOWLEDGE_BASE_IDS_CONFIG_KEY, None) + return config + def merge_knowledge_base_ids( visible_bases: Sequence[KnowledgeBaseRow], @@ -14,12 +72,14 @@ def merge_knowledge_base_ids( *, owner_user_id: int, extra_ids: Sequence[str] | None = None, + agent_default_ids: Sequence[str] | None = None, ) -> list[str]: """Use the actor's own default-open bases when a turn omits a list. ``default_open`` is per-owner preference: shared bases marked default-open are auto-injected only for the creating user, not for other viewers. - ``extra_ids`` (expert composer picks) are unioned in when still visible. + ``extra_ids`` (expert composer picks) and ``agent_default_ids`` (agent + config defaults) are unioned in when still visible. """ if explicit_ids is not None: return list(explicit_ids) @@ -28,8 +88,11 @@ def merge_knowledge_base_ids( for base in visible_bases: if base.default_open and int(base.owner_user_id) == int(owner_user_id): selected.append(base.id) - for kb_id in extra_ids or []: - text = str(kb_id).strip() + extras: list[str] = [] + extras.extend(str(kb_id) for kb_id in extra_ids or []) + extras.extend(str(kb_id) for kb_id in agent_default_ids or []) + for kb_id in extras: + text = kb_id.strip() if text and text in visible_ids and text not in selected: selected.append(text) return selected diff --git a/tests/unit/agents/test_agent_manager.py b/tests/unit/agents/test_agent_manager.py index 2e4d708ee..a46abc89d 100644 --- a/tests/unit/agents/test_agent_manager.py +++ b/tests/unit/agents/test_agent_manager.py @@ -242,12 +242,14 @@ def test_build_harness_config_includes_cronjob_tools_when_cron_manager_set( def test_build_harness_config_includes_search_knowledge_without_cron( manager: AgentManager, ) -> None: + from octop.infra.agents.middleware.conversation_mode import ConversationModeMiddleware from octop.infra.knowledge.hint import KnowledgeSearchHintMiddleware cfg = manager._build_harness_config(_row(agent_id="AGT001")) assert cfg.tools is not None assert {t.name for t in cfg.tools} == {"search_knowledge"} assert any(isinstance(item, KnowledgeSearchHintMiddleware) for item in (cfg.middleware or [])) + assert any(isinstance(item, ConversationModeMiddleware) for item in (cfg.middleware or [])) def test_build_harness_config_defaults_local_shell_backend(manager: AgentManager) -> None: diff --git a/tests/unit/agents/test_conversation_mode.py b/tests/unit/agents/test_conversation_mode.py new file mode 100644 index 000000000..dd2064706 --- /dev/null +++ b/tests/unit/agents/test_conversation_mode.py @@ -0,0 +1,135 @@ +"""Unit tests for conversation mode parse + turn-scoped tool overlays (Sprint 0 / #616).""" + +from __future__ import annotations + +import pytest + +from octop.infra.agents.conversation_mode import ( + DEFAULT_CONVERSATION_MODE, + conversation_mode_tools_disabled, + parse_conversation_mode, + resolve_conversation_mode, +) + + +def test_parse_conversation_mode_accepts_ask_plan_craft() -> None: + assert parse_conversation_mode("ask") == "ask" + assert parse_conversation_mode("plan") == "plan" + assert parse_conversation_mode("craft") == "craft" + + +def test_parse_conversation_mode_rejects_unknown() -> None: + with pytest.raises(ValueError): + parse_conversation_mode("agent") + with pytest.raises(ValueError): + parse_conversation_mode("") + with pytest.raises(ValueError): + parse_conversation_mode(123) + + +def test_parse_conversation_mode_none_defaults_to_craft() -> None: + assert parse_conversation_mode(None) == "craft" + assert parse_conversation_mode(None) == DEFAULT_CONVERSATION_MODE + + +def test_ask_tools_disabled_includes_mutating_builtins() -> None: + disabled = conversation_mode_tools_disabled("ask") + for name in ( + "write_file", + "edit_file", + "execute", + "write_env_file", + "send_file_to_user", + "browser_use", + "desktop_screenshot", + "generate_image", + "generate_video", + "mobile_tap", + "mobile_swipe", + "mobile_launch_app", + "mobile_screenshot", + "mobile_ui_dump", + "mobile_handoff_to_user", + ): + assert name in disabled, name + + +def test_ask_tools_disabled_keeps_explore_tools_blocks_orchestration_escape() -> None: + disabled = conversation_mode_tools_disabled("ask") + for name in ("ls", "read_file", "glob", "grep"): + assert name not in disabled, name + assert "write_todos" in disabled # Ask is Q&A only — no plan todos + assert "search_knowledge" not in disabled + assert "web_fetch" not in disabled + # CRITICAL ``task`` must still be turn-blocked so Plan/Ask cannot escape via subagents. + for name in ("task", "acp_runner", "ask_agent"): + assert name in disabled, name + + +def test_craft_tools_disabled_is_empty() -> None: + assert conversation_mode_tools_disabled("craft") == frozenset() + + +def test_plan_allows_write_todos_and_forbids_mutating_and_delegation() -> None: + """Plan keeps write_todos; blocks execute/write/edit and task/ACP escape.""" + disabled = conversation_mode_tools_disabled("plan") + assert "write_todos" not in disabled + assert "execute" in disabled + assert "write_file" in disabled + assert "edit_file" in disabled + assert "task" in disabled + assert "acp_runner" in disabled + assert "ask_agent" in disabled + # Ask is stricter than Plan on write_todos. + assert "write_todos" in conversation_mode_tools_disabled("ask") + + +def test_resolve_conversation_mode_priority() -> None: + """explicit → thread sticky → agent default → craft.""" + assert resolve_conversation_mode(explicit="ask") == "ask" + assert resolve_conversation_mode(explicit=None, thread_override="plan") == "plan" + assert ( + resolve_conversation_mode(explicit=None, thread_override=None, agent_default="ask") == "ask" + ) + assert resolve_conversation_mode(explicit=None) == "craft" + # Unknown explicit stays craft (compat); does not fall through. + assert resolve_conversation_mode(explicit="nope", agent_default="ask") == "craft" + # Explicit craft wins over agent ask. + assert resolve_conversation_mode(explicit="craft", agent_default="ask") == "craft" + + +def test_default_conversation_mode_from_config() -> None: + from octop.infra.agents.conversation_mode import default_conversation_mode_from_config + + assert default_conversation_mode_from_config(None) == "craft" + assert default_conversation_mode_from_config({}) == "craft" + assert default_conversation_mode_from_config({"default_conversation_mode": "ask"}) == "ask" + with pytest.raises(ValueError): + default_conversation_mode_from_config({"default_conversation_mode": "agent"}) + + +def test_normalize_config_default_conversation_mode() -> None: + from octop.infra.agents.conversation_mode import ( + normalize_config_default_conversation_mode, + ) + + assert normalize_config_default_conversation_mode({"x": 1}) == {"x": 1} + assert normalize_config_default_conversation_mode({"default_conversation_mode": "plan"}) == { + "default_conversation_mode": "plan" + } + cleaned = normalize_config_default_conversation_mode( + {"default_conversation_mode": None, "x": 1} + ) + assert "default_conversation_mode" not in cleaned + assert cleaned["x"] == 1 + with pytest.raises(ValueError): + normalize_config_default_conversation_mode({"default_conversation_mode": "nope"}) + + +def test_apply_default_conversation_mode() -> None: + from octop.infra.agents.conversation_mode import apply_default_conversation_mode + + cfg: dict[str, object] = {"x": 1} + assert apply_default_conversation_mode(cfg, None) == {"x": 1} + apply_default_conversation_mode(cfg, "ask") + assert cfg["default_conversation_mode"] == "ask" diff --git a/tests/unit/agents/test_conversation_mode_middleware.py b/tests/unit/agents/test_conversation_mode_middleware.py new file mode 100644 index 000000000..5508b0ad0 --- /dev/null +++ b/tests/unit/agents/test_conversation_mode_middleware.py @@ -0,0 +1,128 @@ +"""Unit tests for ConversationModeMiddleware (#616).""" + +from __future__ import annotations + +from contextlib import contextmanager +from unittest.mock import AsyncMock, MagicMock + +import pytest +from langchain.agents.middleware import ModelRequest +from langchain_core.messages import SystemMessage, ToolMessage +from langchain_core.tools import StructuredTool +from langgraph.config import var_child_runnable_config +from langgraph.prebuilt.tool_node import ToolCallRequest + +from octop.infra.agents.middleware.conversation_mode import ( + ConversationModeMiddleware, + apply_conversation_mode_to_request, +) + + +@contextmanager +def _configurable(**kwargs: object): + token = var_child_runnable_config.set({"configurable": kwargs}) + try: + yield + finally: + var_child_runnable_config.reset(token) + + +def _tool(name: str) -> StructuredTool: + return StructuredTool.from_function( + func=lambda: "x", + name=name, + description=name, + ) + + +def test_ask_filters_mutating_tools_and_keeps_read_tools() -> None: + request = ModelRequest( + model=MagicMock(), + messages=[], + system_message=SystemMessage(content="base"), + tools=[_tool("read_file"), _tool("write_file"), _tool("execute"), _tool("write_todos")], + ) + with _configurable( + conversation_mode="ask", + conversation_mode_hint="ASK_HINT", + ): + out = apply_conversation_mode_to_request(request) + names = [getattr(t, "name", None) for t in (out.tools or [])] + assert names == ["read_file"] + assert out.system_message is not None + assert "base" in str(out.system_message.content) + assert "ASK_HINT" in str(out.system_message.content) + + +def test_plan_allows_write_todos_and_blocks_execute() -> None: + request = ModelRequest( + model=MagicMock(), + messages=[], + tools=[_tool("write_todos"), _tool("execute"), _tool("edit_file")], + ) + with _configurable(conversation_mode="plan", conversation_mode_hint="PLAN_HINT"): + out = apply_conversation_mode_to_request(request) + names = [getattr(t, "name", None) for t in (out.tools or [])] + assert names == ["write_todos"] + assert "PLAN_HINT" in str(out.system_message.content) + + +def test_craft_is_passthrough_for_tools() -> None: + tools = [_tool("write_file"), _tool("execute")] + request = ModelRequest( + model=MagicMock(), + messages=[], + system_message=SystemMessage(content="base"), + tools=tools, + ) + with _configurable(conversation_mode="craft", conversation_mode_hint="CRAFT_HINT"): + out = apply_conversation_mode_to_request(request) + assert [getattr(t, "name", None) for t in (out.tools or [])] == ["write_file", "execute"] + assert "CRAFT_HINT" in str(out.system_message.content) + + +def test_hint_not_duplicated_on_repeat() -> None: + request = ModelRequest( + model=MagicMock(), + messages=[], + system_message=SystemMessage(content="base\n\nHINT"), + tools=[], + ) + with _configurable(conversation_mode="ask", conversation_mode_hint="HINT"): + out = apply_conversation_mode_to_request(request) + assert str(out.system_message.content).count("HINT") == 1 + + +@pytest.mark.asyncio +async def test_awrap_tool_call_blocks_ask_denylist() -> None: + mw = ConversationModeMiddleware() + request = ToolCallRequest( + tool_call={"name": "execute", "args": {}, "id": "c1"}, + tool=None, # type: ignore[arg-type] + state={}, # type: ignore[arg-type] + runtime=None, # type: ignore[arg-type] + ) + handler = AsyncMock(return_value=ToolMessage(content="ran", tool_call_id="c1")) + with _configurable(conversation_mode="ask", locale="en"): + result = await mw.awrap_tool_call(request, handler) + assert isinstance(result, ToolMessage) + assert result.status == "error" + assert "execute" in result.content + handler.assert_not_called() + + +@pytest.mark.asyncio +async def test_awrap_tool_call_allows_craft() -> None: + mw = ConversationModeMiddleware() + request = ToolCallRequest( + tool_call={"name": "execute", "args": {}, "id": "c1"}, + tool=None, # type: ignore[arg-type] + state={}, # type: ignore[arg-type] + runtime=None, # type: ignore[arg-type] + ) + ok = ToolMessage(content="ran", tool_call_id="c1") + handler = AsyncMock(return_value=ok) + with _configurable(conversation_mode="craft"): + result = await mw.awrap_tool_call(request, handler) + assert result is ok + handler.assert_awaited_once() diff --git a/tests/unit/agents/test_plan_artifact.py b/tests/unit/agents/test_plan_artifact.py new file mode 100644 index 000000000..c650f2ee7 --- /dev/null +++ b/tests/unit/agents/test_plan_artifact.py @@ -0,0 +1,40 @@ +"""Unit tests for Plan → Craft brief artifact (#616 P5).""" + +from __future__ import annotations + +from octop.infra.agents.plan_artifact import format_plan_brief, parse_plan_brief_steps + + +def test_format_plan_brief_round_trip_steps() -> None: + brief = format_plan_brief( + summary="Ship conversation modes.", + todos=[ + {"id": "1", "content": "Ask denylist", "status": "completed"}, + {"id": "2", "content": "Plan CTA", "status": "pending"}, + ], + ) + assert "Approved plan" in brief + assert "Ship conversation modes." in brief + assert "Execute this plan now." in brief + steps = parse_plan_brief_steps(brief) + assert steps[0].startswith("Ask denylist") + assert "Plan CTA" in steps[1] + + +def test_format_plan_brief_summary_only() -> None: + brief = format_plan_brief(summary="Just do the thing.") + assert "Just do the thing." in brief + assert parse_plan_brief_steps(brief) == [] + + +def test_format_plan_brief_skips_empty_todos_without_gaps() -> None: + brief = format_plan_brief( + todos=[ + {"content": "First"}, + {"content": ""}, + {"content": "Third"}, + ] + ) + assert "1. First" in brief + assert "2. Third" in brief + assert "3." not in brief diff --git a/tests/unit/api/test_agent_conversation_mode.py b/tests/unit/api/test_agent_conversation_mode.py new file mode 100644 index 000000000..266af857a --- /dev/null +++ b/tests/unit/api/test_agent_conversation_mode.py @@ -0,0 +1,64 @@ +"""Agent default_conversation_mode in API row payload (#616 M8).""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from octop.api.routers.agents import _row_dict, _validated_agent_config +from octop.infra.errors import ErrorCode, OctopError + + +def _agent_row(**kwargs: object) -> SimpleNamespace: + base = { + "id": 1, + "agent_id": "ag1", + "user_id": 1, + "name": "A", + "description": None, + "persona_mbti": None, + "default_model": None, + "system_prompt": None, + "last_state": "stopped", + "last_error": None, + "config_json": "{}", + "icon": None, + "template_name": None, + "icon_name": None, + "icon_url": None, + "color": None, + "skill_package_ids": None, + "published_expert_id": None, + "welcome_message": None, + "is_shared": 0, + "updated_at": None, + } + base.update(kwargs) + return SimpleNamespace(**base) + + +def test_row_dict_exposes_default_conversation_mode_from_config() -> None: + import json + + row = _agent_row(config_json=json.dumps({"default_conversation_mode": "ask"})) + payload = _row_dict(row, viewer_user_id=1) + assert payload["default_conversation_mode"] == "ask" + assert payload["config"]["default_conversation_mode"] == "ask" + + +def test_row_dict_defaults_conversation_mode_to_craft() -> None: + payload = _row_dict(_agent_row(), viewer_user_id=1) + assert payload["default_conversation_mode"] == "craft" + + +def test_validated_agent_config_rejects_invalid_mode() -> None: + with pytest.raises(OctopError) as ei: + _validated_agent_config({"default_conversation_mode": "agent"}) + assert ei.value.code == ErrorCode.INTERNAL_ERROR + assert ei.value.status == 400 + + +def test_validated_agent_config_accepts_ask() -> None: + out = _validated_agent_config({"default_conversation_mode": "ask", "foo": 1}) + assert out == {"default_conversation_mode": "ask", "foo": 1} diff --git a/tests/unit/api/test_chat_conversation_mode.py b/tests/unit/api/test_chat_conversation_mode.py new file mode 100644 index 000000000..b907d48a8 --- /dev/null +++ b/tests/unit/api/test_chat_conversation_mode.py @@ -0,0 +1,110 @@ +"""ChatTurnBody / inbound metadata for conversation_mode (#616 M1 API).""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from octop.api.routers.chat.models import ChatTurnBody + + +def test_chat_turn_body_accepts_ask_plan_craft() -> None: + for mode in ("ask", "plan", "craft"): + body = ChatTurnBody(text="hi", conversation_mode=mode) # type: ignore[arg-type] + assert body.conversation_mode == mode + + +def test_chat_turn_body_rejects_unknown_mode() -> None: + with pytest.raises(ValidationError): + ChatTurnBody(text="hi", conversation_mode="agent") # type: ignore[arg-type] + + +def test_chat_turn_body_omitted_mode_is_none() -> None: + body = ChatTurnBody(text="hi") + assert body.conversation_mode is None + + +def test_from_ws_payload_parses_conversation_mode() -> None: + body = ChatTurnBody.from_ws_payload({"text": "hi", "conversation_mode": "ask"}) + assert body.conversation_mode == "ask" + + +def test_from_ws_payload_drops_unknown_conversation_mode() -> None: + body = ChatTurnBody.from_ws_payload({"text": "hi", "conversation_mode": "agent"}) + assert body.conversation_mode is None + + +def test_build_dashboard_inbound_stamps_conversation_mode_metadata() -> None: + from harness_gateway.models import TextContent + + from octop.api.routers.chat.turn import PreparedDashboardTurn, build_dashboard_inbound + + prepared = PreparedDashboardTurn( + thread_id="t1", + session_key="sk", + mcp_servers=None, + skills=None, + model_ref=None, + inbound_content=[TextContent(text="hi")], + composer_context=None, + inbound_attachments=[], + ) + inbound = build_dashboard_inbound( + agent_id="agent-1", + user_id=1, + prepared=prepared, + turn=ChatTurnBody(text="hi", conversation_mode="ask"), + ws_connection_id="conn", + ) + assert inbound.metadata["conversation_mode"] == "ask" + + +def test_build_dashboard_inbound_stamps_plan_brief_metadata() -> None: + from harness_gateway.models import TextContent + + from octop.api.routers.chat.turn import PreparedDashboardTurn, build_dashboard_inbound + + prepared = PreparedDashboardTurn( + thread_id="t1", + session_key="sk", + mcp_servers=None, + skills=None, + model_ref=None, + inbound_content=[TextContent(text="按计划执行")], + composer_context=None, + inbound_attachments=[], + ) + inbound = build_dashboard_inbound( + agent_id="agent-1", + user_id=1, + prepared=prepared, + turn=ChatTurnBody( + text="按计划执行", + conversation_mode="craft", + plan_brief="## Approved plan\n\nDo it.", + ), + ws_connection_id="conn", + ) + assert inbound.metadata["conversation_mode"] == "craft" + assert inbound.metadata["plan_brief"] == "## Approved plan\n\nDo it." + + +def test_turn_has_content_accepts_plan_brief_only() -> None: + from octop.api.routers.chat.turn import turn_has_content + + assert turn_has_content(ChatTurnBody(text="", plan_brief="## Approved plan\n\nDo it.")) + assert not turn_has_content(ChatTurnBody(text="")) + + +def test_content_parts_injects_trigger_for_plan_brief_only() -> None: + from harness_gateway.models import TextContent + + from octop.api.routers.chat.turn import content_parts_from_dashboard_turn + from octop.infra.agents.plan_artifact import PLAN_EXECUTE_USER_TRIGGER + + parts = content_parts_from_dashboard_turn( + ChatTurnBody(text="", plan_brief="## Approved plan\n\nDo it.") + ) + assert len(parts) == 1 + assert isinstance(parts[0], TextContent) + assert parts[0].text == PLAN_EXECUTE_USER_TRIGGER diff --git a/tests/unit/gateway/test_processor_conversation_mode.py b/tests/unit/gateway/test_processor_conversation_mode.py new file mode 100644 index 000000000..a87c5d394 --- /dev/null +++ b/tests/unit/gateway/test_processor_conversation_mode.py @@ -0,0 +1,247 @@ +"""GlobalProcessor turn-scoped conversation_mode configurable stamp (#616).""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from harness_gateway.models import ChannelSubject, InboundMessage, TextContent + +from octop.infra.gateway.process.processor import GlobalProcessor +from octop.infra.gateway.slash.dispatcher import SlashDispatcher + + +def _processor(agent_manager: MagicMock) -> GlobalProcessor: + thread_registry = MagicMock() + thread_registry.get_or_create_by_key = AsyncMock(return_value="thr-1") + thread_registry.touch_last_active = MagicMock() + thread_registry.set_title_if_null = MagicMock() + return GlobalProcessor( + agent_manager=agent_manager, + thread_registry=thread_registry, + audit_repo=MagicMock(), + agent_repo=MagicMock(get=MagicMock(return_value=MagicMock(user_id=1, default_model=None))), + user_repo=MagicMock(get=MagicMock(return_value=None)), + connector_repo=MagicMock(), + dispatcher=SlashDispatcher(), + usage_repo=None, + gateway=None, + ) + + +def _agent_manager(*, config: dict | None = None) -> MagicMock: + mgr = MagicMock() + mgr.merge_turn_mcp_servers = MagicMock(return_value=None) + mgr.prepare_chat_mcp = AsyncMock(return_value=[]) + mgr.get_row = MagicMock(return_value=None) + mgr.get_config = MagicMock(return_value=config or {"tools_disabled": ["web_fetch"]}) + mgr.get_thread_conversation_mode = MagicMock(return_value=None) + mgr.sync_effective_tools_disabled = MagicMock() + mgr.sync_tools_disabled = MagicMock() + mgr.providers = MagicMock() + mgr.providers.is_model_ref_usable = MagicMock(return_value=False) + mgr.providers.resolve_explicit_default_model = MagicMock(return_value=None) + mgr.providers.resolve_model_for_multimodal_turn = MagicMock(side_effect=lambda ref, **_k: ref) + mgr.providers.get_model_reasoning_capability = MagicMock(return_value=None) + mgr.get_thread_model = MagicMock(return_value=None) + + async def fake_stream(_aid: str, _req: dict): + yield {"type": "token", "content": "ok"} + + mgr.stream = fake_stream + return mgr + + +@pytest.mark.asyncio +async def test_dashboard_request_stamps_conversation_mode_ask() -> None: + processor = _processor(_agent_manager()) + msg = InboundMessage( + channel_id="ws", + channel_type="dashboard", + tenant_id="agent-1", + channel_subject=ChannelSubject(subject_id="1"), + content=[TextContent(text="hi")], + metadata={"conversation_mode": "ask"}, + ) + request = await processor._build_dashboard_request( + msg, + agent_id="agent-1", + user_id=1, + session_key="sk", + thread_id="thr", + meta=msg.metadata or {}, + ) + assert (request.get("configurable") or {})["conversation_mode"] == "ask" + hint = (request.get("configurable") or {}).get("conversation_mode_hint") + assert isinstance(hint, str) and hint + + +@pytest.mark.asyncio +async def test_dashboard_request_stamps_plan_system_hint() -> None: + """Plan turn attaches plan-mode system hint for ConversationModeMiddleware.""" + processor = _processor(_agent_manager()) + msg = InboundMessage( + channel_id="ws", + channel_type="dashboard", + tenant_id="agent-1", + channel_subject=ChannelSubject(subject_id="1"), + content=[TextContent(text="plan it")], + metadata={"conversation_mode": "plan"}, + ) + request = await processor._build_dashboard_request( + msg, + agent_id="agent-1", + user_id=1, + session_key="sk", + thread_id="thr", + meta=msg.metadata or {}, + ) + cfg = request.get("configurable") or {} + assert cfg["conversation_mode"] == "plan" + hint = str(cfg.get("conversation_mode_hint") or "") + assert "plan" in hint.lower() or "想一想" in hint + assert "write_todos" in hint.lower() or "plan" in hint.lower() + + +@pytest.mark.asyncio +async def test_dashboard_request_defaults_conversation_mode_to_craft() -> None: + processor = _processor(_agent_manager()) + msg = InboundMessage( + channel_id="ws", + channel_type="dashboard", + tenant_id="agent-1", + channel_subject=ChannelSubject(subject_id="1"), + content=[TextContent(text="hi")], + metadata={}, + ) + request = await processor._build_dashboard_request( + msg, + agent_id="agent-1", + user_id=1, + session_key="sk", + thread_id="thr", + meta={}, + ) + assert (request.get("configurable") or {})["conversation_mode"] == "craft" + + +@pytest.mark.asyncio +async def test_dashboard_request_falls_back_to_agent_default_conversation_mode() -> None: + processor = _processor( + _agent_manager(config={"default_conversation_mode": "ask", "tools_disabled": []}) + ) + request = await processor._build_dashboard_request( + InboundMessage( + channel_id="ws", + channel_type="dashboard", + tenant_id="agent-1", + channel_subject=ChannelSubject(subject_id="1"), + content=[TextContent(text="hi")], + metadata={}, + ), + agent_id="agent-1", + user_id=1, + session_key="sk", + thread_id="thr", + meta={}, + ) + assert (request.get("configurable") or {})["conversation_mode"] == "ask" + + +@pytest.mark.asyncio +async def test_dashboard_request_thread_sticky_mode_beats_agent_default() -> None: + mgr = _agent_manager(config={"default_conversation_mode": "ask"}) + mgr.get_thread_conversation_mode = MagicMock(return_value="plan") + processor = _processor(mgr) + request = await processor._build_dashboard_request( + InboundMessage( + channel_id="ws", + channel_type="dashboard", + tenant_id="agent-1", + channel_subject=ChannelSubject(subject_id="1"), + content=[TextContent(text="hi")], + metadata={}, + ), + agent_id="agent-1", + user_id=1, + session_key="sk", + thread_id="thr", + meta={}, + ) + assert (request.get("configurable") or {})["conversation_mode"] == "plan" + + +@pytest.mark.asyncio +async def test_dashboard_request_explicit_mode_beats_sticky_and_agent_default() -> None: + mgr = _agent_manager(config={"default_conversation_mode": "ask"}) + mgr.get_thread_conversation_mode = MagicMock(return_value="plan") + processor = _processor(mgr) + request = await processor._build_dashboard_request( + InboundMessage( + channel_id="ws", + channel_type="dashboard", + tenant_id="agent-1", + channel_subject=ChannelSubject(subject_id="1"), + content=[TextContent(text="hi")], + metadata={"conversation_mode": "craft"}, + ), + agent_id="agent-1", + user_id=1, + session_key="sk", + thread_id="thr", + meta={"conversation_mode": "craft"}, + ) + assert (request.get("configurable") or {})["conversation_mode"] == "craft" + + +@pytest.mark.asyncio +async def test_dashboard_request_stamps_plan_brief_into_system_hint() -> None: + processor = _processor(_agent_manager()) + brief = "## Approved plan\n\nWrite load.sh\n\nExecute this plan now." + msg = InboundMessage( + channel_id="ws", + channel_type="dashboard", + tenant_id="agent-1", + channel_subject=ChannelSubject(subject_id="1"), + content=[TextContent(text="按计划执行")], + metadata={"conversation_mode": "craft", "plan_brief": brief}, + ) + request = await processor._build_dashboard_request( + msg, + agent_id="agent-1", + user_id=1, + session_key="sk", + thread_id="thr", + meta=msg.metadata or {}, + ) + cfg = request.get("configurable") or {} + assert cfg["conversation_mode"] == "craft" + assert cfg.get("plan_brief") == brief + hint = str(cfg.get("conversation_mode_hint") or "") + assert "Write load.sh" in hint + assert "Approved plan" in hint + from octop.infra.gateway.process.message_keys import UI_HIDDEN_KEY + + human = (request.get("messages") or [None])[0] + assert human is not None + assert human.additional_kwargs.get(UI_HIDDEN_KEY) is True + + +@pytest.mark.asyncio +async def test_iter_turn_chunks_does_not_mutate_agent_tools_disabled() -> None: + """Ask/Plan enforcement is middleware-scoped; processor must not hot-patch tools.""" + mgr = _agent_manager() + processor = _processor(mgr) + + msg = InboundMessage( + channel_id="ws", + channel_type="dashboard", + tenant_id="agent-1", + channel_subject=ChannelSubject(subject_id="1"), + content=[TextContent(text="hi")], + metadata={"conversation_mode": "ask", "thread_id": "thr-1"}, + ) + chunks = [c async for c in processor.iter_turn_chunks(msg)] + assert any(c.get("type") == "token" for c in chunks) + mgr.sync_tools_disabled.assert_not_called() + mgr.sync_effective_tools_disabled.assert_not_called() diff --git a/tests/unit/i18n/test_conversation.py b/tests/unit/i18n/test_conversation.py new file mode 100644 index 000000000..dee98f492 --- /dev/null +++ b/tests/unit/i18n/test_conversation.py @@ -0,0 +1,58 @@ +"""tests/unit/i18n/test_conversation.py — Ask/Plan/Craft hint key parity (#616 M7).""" + +from __future__ import annotations + +from octop.i18n import all_keys_for_locale, tr +from octop.i18n.domains.conversation import conversation_mode_system_hint + + +def test_conversation_mode_keys_parity() -> None: + en_keys = {k for k in all_keys_for_locale("en") if k.startswith("conversation.")} + zh_keys = {k for k in all_keys_for_locale("zh") if k.startswith("conversation.")} + assert en_keys == zh_keys + for mode in ("ask", "plan", "craft"): + key = f"conversation.mode.{mode}_system_hint" + assert key in en_keys + assert tr(key, "en") + assert tr(key, "zh") + + +def test_conversation_mode_system_hint_helper() -> None: + ask_en = conversation_mode_system_hint("ask", "en") + ask_zh = conversation_mode_system_hint("ask", "zh") + assert "Ask" in ask_en or "read-only" in ask_en or "Ask mode" in ask_en + assert "问答" in ask_zh + assert ask_en != ask_zh + plan_en = conversation_mode_system_hint("plan", "en") + assert "task" in plan_en.lower() or "ACP" in plan_en or "delegate" in plan_en.lower() + + +def test_conversation_mode_tool_blocked_helper() -> None: + from octop.i18n.domains.conversation import conversation_mode_tool_blocked + + en = conversation_mode_tool_blocked("execute", "ask", "en") + zh = conversation_mode_tool_blocked("execute", "ask", "zh") + assert "execute" in en + assert "execute" in zh + assert en != zh + + +def test_conversation_mode_plan_brief_block() -> None: + from octop.i18n.domains.conversation import conversation_mode_plan_brief_block + + en = conversation_mode_plan_brief_block("## Approved plan\n\nDo it.", "en") + zh = conversation_mode_plan_brief_block("## Approved plan\n\nDo it.", "zh") + assert "Approved plan" in en + assert "Do it." in en + assert en != zh or "计划" in zh or "确认" in zh + + +def test_plan_ready_cta_keys_parity() -> None: + """Backend keeps mode system hints; dashboard owns Ready-to-build CTA copy.""" + for mode in ("ask", "plan", "craft"): + assert tr(f"conversation.mode.{mode}_system_hint", "en") + assert tr(f"conversation.mode.{mode}_system_hint", "zh") + assert tr("conversation.mode.tool_blocked", "en", tool_name="x", mode="ask") + assert tr("conversation.mode.tool_blocked", "zh", tool_name="x", mode="ask") + assert tr("conversation.mode.plan_brief_prefix", "en") + assert tr("conversation.mode.plan_brief_prefix", "zh") diff --git a/tests/unit/knowledge/test_knowledge_default_open.py b/tests/unit/knowledge/test_knowledge_default_open.py index a859327b1..cc7dbc269 100644 --- a/tests/unit/knowledge/test_knowledge_default_open.py +++ b/tests/unit/knowledge/test_knowledge_default_open.py @@ -35,6 +35,32 @@ def test_merge_knowledge_base_ids_default_open_only_for_owner() -> None: assert merge_knowledge_base_ids(visible, None, owner_user_id=2) == ["shared-default"] +def test_merge_knowledge_base_ids_unions_agent_default_ids() -> None: + visible = [ + SimpleNamespace(id="default", owner_user_id=1, default_open=True, shared=False), + SimpleNamespace(id="agent-kb", owner_user_id=1, default_open=False, shared=False), + SimpleNamespace(id="other", owner_user_id=1, default_open=False, shared=False), + ] + assert merge_knowledge_base_ids( + visible, + None, + owner_user_id=1, + agent_default_ids=["agent-kb", "missing"], + ) == ["default", "agent-kb"] + + +def test_merge_knowledge_base_ids_falls_back_when_agent_defaults_invisible() -> None: + visible = [ + SimpleNamespace(id="default", owner_user_id=1, default_open=True, shared=False), + ] + assert merge_knowledge_base_ids( + visible, + None, + owner_user_id=1, + agent_default_ids=["gone"], + ) == ["default"] + + def test_merge_knowledge_base_ids_unions_visible_extra_ids() -> None: visible = [ SimpleNamespace(id="default", owner_user_id=1, default_open=True, shared=False),